feat(config): add reusable security profiles and network targets with route reference resolution
This commit is contained in:
@@ -1 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.nogit/
|
||||||
|
.git/
|
||||||
|
.playwright-mcp/
|
||||||
|
.vscode/
|
||||||
|
test/
|
||||||
|
test_watch/
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.0 - feat(config)
|
||||||
|
add reusable security profiles and network targets with route reference resolution
|
||||||
|
|
||||||
|
- introduces persisted security profile and network target models plus typed OpsServer CRUD and usage endpoints
|
||||||
|
- adds route metadata support so routes can reference profiles and targets and be re-resolved after updates
|
||||||
|
- supports optional seeding of default profiles and targets when the database is empty
|
||||||
|
- adds dashboard views and state management for managing security profiles and network targets
|
||||||
|
- includes tests for reference resolver behavior and API fallback/auth handling
|
||||||
|
|
||||||
## 2026-04-01 - 12.1.0 - feat(vpn)
|
## 2026-04-01 - 12.1.0 - feat(vpn)
|
||||||
add per-client routing controls and bridge forwarding support for VPN clients
|
add per-client routing controls and bridge forwarding support for VPN clients
|
||||||
|
|
||||||
|
|||||||
371
test/test.reference-resolver.ts
Normal file
371
test/test.reference-resolver.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||||
|
import type { ISecurityProfile, 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: ISecurityProfile): void {
|
||||||
|
(resolver as any).profiles.set(profile.id, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
|
||||||
|
(resolver as any).targets.set(target.id, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile {
|
||||||
|
return {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: 'STANDARD',
|
||||||
|
description: 'Test profile',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
|
||||||
|
maxConnections: 1000,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
|
||||||
|
return {
|
||||||
|
id: 'target-1',
|
||||||
|
name: 'INFRA',
|
||||||
|
description: 'Test target',
|
||||||
|
host: '192.168.5.247',
|
||||||
|
port: 443,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: 'test',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||||
|
return {
|
||||||
|
name: 'test-route',
|
||||||
|
match: { ports: 443, domains: 'test.example.com' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
|
||||||
|
...overrides,
|
||||||
|
} as IRouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Resolution tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let resolver: ReferenceResolver;
|
||||||
|
|
||||||
|
tap.test('should create ReferenceResolver instance', async () => {
|
||||||
|
resolver = new ReferenceResolver();
|
||||||
|
expect(resolver).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should list empty profiles and targets initially', async () => {
|
||||||
|
expect(resolver.listProfiles()).toBeArray();
|
||||||
|
expect(resolver.listProfiles().length).toEqual(0);
|
||||||
|
expect(resolver.listTargets()).toBeArray();
|
||||||
|
expect(resolver.listTargets().length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Security profile resolution ----
|
||||||
|
|
||||||
|
tap.test('should resolve security profile onto a route', async () => {
|
||||||
|
const profile = makeProfile();
|
||||||
|
injectProfile(resolver, profile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: '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.securityProfileName).toEqual('STANDARD');
|
||||||
|
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should merge inline route security with profile security', async () => {
|
||||||
|
const route = makeRoute({
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['127.0.0.1'],
|
||||||
|
maxConnections: 5000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// IP lists are unioned
|
||||||
|
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).toContain('127.0.0.1');
|
||||||
|
|
||||||
|
// Inline maxConnections overrides profile
|
||||||
|
expect(result.route.security!.maxConnections).toEqual(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = { securityProfileRef: '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 () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' };
|
||||||
|
|
||||||
|
const result = resolver.resolveRoute(route, metadata);
|
||||||
|
|
||||||
|
// Route should be unchanged
|
||||||
|
expect(result.route.security).toBeUndefined();
|
||||||
|
expect(result.metadata.securityProfileName).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Profile inheritance ----
|
||||||
|
|
||||||
|
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||||
|
const baseProfile = makeProfile({
|
||||||
|
id: 'base-profile',
|
||||||
|
name: 'BASE',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['10.0.0.0/8'],
|
||||||
|
maxConnections: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
injectProfile(resolver, baseProfile);
|
||||||
|
|
||||||
|
const extendedProfile = makeProfile({
|
||||||
|
id: 'extended-profile',
|
||||||
|
name: 'EXTENDED',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['160.79.104.0/21'],
|
||||||
|
},
|
||||||
|
extendsProfiles: ['base-profile'],
|
||||||
|
});
|
||||||
|
injectProfile(resolver, extendedProfile);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { securityProfileRef: '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.securityProfileName).toEqual('EXTENDED');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = { securityProfileRef: '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);
|
||||||
|
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const route = makeRoute();
|
||||||
|
const metadata: IRouteMetadata = {
|
||||||
|
securityProfileRef: '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.action.targets![0].host).toEqual('192.168.5.247');
|
||||||
|
expect(result.route.action.targets![0].port).toEqual(443);
|
||||||
|
|
||||||
|
// Both names recorded
|
||||||
|
expect(result.metadata.securityProfileName).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 = {
|
||||||
|
securityProfileRef: '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 () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-a', {
|
||||||
|
id: 'route-a',
|
||||||
|
route: makeRoute({ name: 'route-a' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { securityProfileRef: 'profile-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-b', {
|
||||||
|
id: 'route-b',
|
||||||
|
route: makeRoute({ name: 'route-b' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
storedRoutes.set('route-c', {
|
||||||
|
id: 'route-c',
|
||||||
|
route: makeRoute({ name: 'route-c' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||||
|
expect(profileRefs.length).toEqual(2);
|
||||||
|
expect(profileRefs).toContain('route-a');
|
||||||
|
expect(profileRefs).toContain('route-c');
|
||||||
|
|
||||||
|
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||||
|
expect(targetRefs.length).toEqual(2);
|
||||||
|
expect(targetRefs).toContain('route-b');
|
||||||
|
expect(targetRefs).toContain('route-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||||
|
const storedRoutes = new Map<string, any>();
|
||||||
|
storedRoutes.set('route-x', {
|
||||||
|
id: 'route-x',
|
||||||
|
route: makeRoute({ name: 'my-route' }),
|
||||||
|
enabled: true,
|
||||||
|
metadata: { securityProfileRef: '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' }),
|
||||||
|
enabled: true,
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
208
test/test.security-profiles-api.ts
Normal file
208
test/test.security-profiles-api.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3200;
|
||||||
|
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup — db disabled, handlers return graceful fallbacks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
opsServerPort: TEST_PORT,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
TEST_URL,
|
||||||
|
'adminLoginWithUsernameAndPassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('identity');
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profiles).toBeArray();
|
||||||
|
expect(response.profiles.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.profile).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||||
|
TEST_URL,
|
||||||
|
'createSecurityProfile'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
security: { ipAllowList: ['*'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfileUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target endpoints (graceful fallbacks when resolver unavailable)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should return empty targets list when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.targets).toBeArray();
|
||||||
|
expect(response.targets.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return null for single target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.target).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return failure for create target when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
TEST_URL,
|
||||||
|
'createNetworkTarget'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
name: 'TEST',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 443,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toBeFalse();
|
||||||
|
expect(response.message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return empty target usage when resolver not initialized', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargetUsage'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await req.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
id: 'nonexistent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.routes).toBeArray();
|
||||||
|
expect(response.routes.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth rejection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated profile requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||||
|
TEST_URL,
|
||||||
|
'getSecurityProfiles'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated target requests', async () => {
|
||||||
|
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
TEST_URL,
|
||||||
|
'getNetworkTargets'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.fire({} as any);
|
||||||
|
expect(true).toBeFalse();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cleanup
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.1.0',
|
version: '12.2.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { MetricsManager } from './monitoring/index.js';
|
|||||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||||
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder } from './config/index.js';
|
||||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
|
|
||||||
@@ -137,6 +137,10 @@ export interface IDcRouterOptions {
|
|||||||
dbName?: string;
|
dbName?: string;
|
||||||
/** Cache cleanup interval in hours (default: 1) */
|
/** Cache cleanup interval in hours (default: 1) */
|
||||||
cleanupIntervalHours?: number;
|
cleanupIntervalHours?: number;
|
||||||
|
/** Seed default security profiles and network targets when DB is empty on first startup. */
|
||||||
|
seedOnEmpty?: boolean;
|
||||||
|
/** Custom seed data for profiles and targets (overrides built-in defaults). */
|
||||||
|
seedData?: import('./config/classes.db-seeder.js').ISeedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,6 +273,7 @@ export class DcRouter {
|
|||||||
// Programmatic config API
|
// Programmatic config API
|
||||||
public routeConfigManager?: RouteConfigManager;
|
public routeConfigManager?: RouteConfigManager;
|
||||||
public apiTokenManager?: ApiTokenManager;
|
public apiTokenManager?: ApiTokenManager;
|
||||||
|
public referenceResolver?: ReferenceResolver;
|
||||||
|
|
||||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
public detectedPublicIp: string | null = null;
|
public detectedPublicIp: string | null = null;
|
||||||
@@ -456,6 +461,10 @@ export class DcRouter {
|
|||||||
.optional()
|
.optional()
|
||||||
.dependsOn('SmartProxy', 'DcRouterDb')
|
.dependsOn('SmartProxy', 'DcRouterDb')
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
|
// Initialize reference resolver first (profiles + targets)
|
||||||
|
this.referenceResolver = new ReferenceResolver();
|
||||||
|
await this.referenceResolver.initialize();
|
||||||
|
|
||||||
this.routeConfigManager = new RouteConfigManager(
|
this.routeConfigManager = new RouteConfigManager(
|
||||||
() => this.getConstructorRoutes(),
|
() => this.getConstructorRoutes(),
|
||||||
() => this.smartProxy,
|
() => this.smartProxy,
|
||||||
@@ -468,14 +477,23 @@ export class DcRouter {
|
|||||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
this.referenceResolver,
|
||||||
);
|
);
|
||||||
this.apiTokenManager = new ApiTokenManager();
|
this.apiTokenManager = new ApiTokenManager();
|
||||||
await this.apiTokenManager.initialize();
|
await this.apiTokenManager.initialize();
|
||||||
await this.routeConfigManager.initialize();
|
await this.routeConfigManager.initialize();
|
||||||
|
|
||||||
|
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||||
|
const seeder = new DbSeeder(this.referenceResolver);
|
||||||
|
await seeder.seedIfEmpty(
|
||||||
|
this.options.dbConfig?.seedOnEmpty,
|
||||||
|
this.options.dbConfig?.seedData,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.withStop(async () => {
|
.withStop(async () => {
|
||||||
this.routeConfigManager = undefined;
|
this.routeConfigManager = undefined;
|
||||||
this.apiTokenManager = undefined;
|
this.apiTokenManager = undefined;
|
||||||
|
this.referenceResolver = undefined;
|
||||||
})
|
})
|
||||||
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
|
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
|
||||||
);
|
);
|
||||||
|
|||||||
95
ts/config/classes.db-seeder.ts
Normal file
95
ts/config/classes.db-seeder.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
import type { IRouteSecurity } from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
export interface ISeedData {
|
||||||
|
profiles?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
}>;
|
||||||
|
targets?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DbSeeder {
|
||||||
|
constructor(private referenceResolver: ReferenceResolver) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if DB is empty and seed if configured.
|
||||||
|
* Called once during ConfigManagers service startup, after initialize().
|
||||||
|
*/
|
||||||
|
public async seedIfEmpty(
|
||||||
|
seedOnEmpty?: boolean,
|
||||||
|
seedData?: ISeedData,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!seedOnEmpty) return;
|
||||||
|
|
||||||
|
const existingProfiles = this.referenceResolver.listProfiles();
|
||||||
|
const existingTargets = this.referenceResolver.listTargets();
|
||||||
|
|
||||||
|
if (existingProfiles.length > 0 || existingTargets.length > 0) {
|
||||||
|
logger.log('info', 'DB already contains profiles/targets, skipping seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'Seeding database with initial profiles and targets...');
|
||||||
|
|
||||||
|
const profilesToSeed: NonNullable<ISeedData['profiles']> = seedData?.profiles ?? DEFAULT_PROFILES;
|
||||||
|
const targetsToSeed: NonNullable<ISeedData['targets']> = seedData?.targets ?? DEFAULT_TARGETS;
|
||||||
|
|
||||||
|
for (const p of profilesToSeed) {
|
||||||
|
await this.referenceResolver.createProfile({
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
security: p.security,
|
||||||
|
extendsProfiles: p.extendsProfiles,
|
||||||
|
createdBy: 'system-seed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of targetsToSeed) {
|
||||||
|
await this.referenceResolver.createTarget({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
createdBy: 'system-seed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Seeded ${profilesToSeed.length} profile(s) and ${targetsToSeed.length} target(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
|
||||||
|
{
|
||||||
|
name: 'PUBLIC',
|
||||||
|
description: 'Allow all traffic — no IP restrictions',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'STANDARD',
|
||||||
|
description: 'Standard internal access with common private subnets',
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8', '127.0.0.1', '::1'],
|
||||||
|
maxConnections: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TARGETS: Array<NonNullable<ISeedData['targets']>[number]> = [
|
||||||
|
{
|
||||||
|
name: 'LOCALHOST',
|
||||||
|
description: 'Local machine on port 443',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 443,
|
||||||
|
},
|
||||||
|
];
|
||||||
576
ts/config/classes.reference-resolver.ts
Normal file
576
ts/config/classes.reference-resolver.ts
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||||
|
import type {
|
||||||
|
ISecurityProfile,
|
||||||
|
INetworkTarget,
|
||||||
|
IRouteMetadata,
|
||||||
|
IStoredRoute,
|
||||||
|
IRouteSecurity,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const MAX_INHERITANCE_DEPTH = 5;
|
||||||
|
|
||||||
|
export class ReferenceResolver {
|
||||||
|
private profiles = new Map<string, ISecurityProfile>();
|
||||||
|
private targets = new Map<string, INetworkTarget>();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadProfiles();
|
||||||
|
await this.loadTargets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Profile CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createProfile(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const profile: ISecurityProfile = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
security: data.security,
|
||||||
|
extendsProfiles: data.extendsProfiles,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.profiles.set(id, profile);
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Created security profile '${profile.name}' (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateProfile(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||||
|
): Promise<{ affectedRouteIds: string[] }> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`Security profile '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined) profile.name = patch.name;
|
||||||
|
if (patch.description !== undefined) profile.description = patch.description;
|
||||||
|
if (patch.security !== undefined) profile.security = patch.security;
|
||||||
|
if (patch.extendsProfiles !== undefined) profile.extendsProfiles = patch.extendsProfiles;
|
||||||
|
profile.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
|
||||||
|
|
||||||
|
// Find routes referencing this profile
|
||||||
|
const affectedRouteIds = await this.findRoutesByProfileRef(id);
|
||||||
|
return { affectedRouteIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteProfile(
|
||||||
|
id: string,
|
||||||
|
force: boolean,
|
||||||
|
storedRoutes?: Map<string, IStoredRoute>,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const profile = this.profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, message: `Security profile '${id}' not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check usage
|
||||||
|
const affectedIds = storedRoutes
|
||||||
|
? this.findRoutesByProfileRefSync(id, storedRoutes)
|
||||||
|
: await this.findRoutesByProfileRef(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Profile '${profile.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from DB
|
||||||
|
const doc = await SecurityProfileDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.profiles.delete(id);
|
||||||
|
|
||||||
|
// If force-deleting with referencing routes, clear refs but keep resolved values
|
||||||
|
if (affectedIds.length > 0) {
|
||||||
|
await this.clearProfileRefsOnRoutes(affectedIds);
|
||||||
|
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Deleted security profile '${profile.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfile(id: string): ISecurityProfile | undefined {
|
||||||
|
return this.profiles.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileByName(name: string): ISecurityProfile | undefined {
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
if (profile.name === name) return profile;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public listProfiles(): ISecurityProfile[] {
|
||||||
|
return [...this.profiles.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||||
|
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
usage.set(profile.id, []);
|
||||||
|
}
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
const ref = stored.metadata?.securityProfileRef;
|
||||||
|
if (ref && usage.has(ref)) {
|
||||||
|
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getProfileUsageForId(
|
||||||
|
profileId: string,
|
||||||
|
storedRoutes: Map<string, IStoredRoute>,
|
||||||
|
): Array<{ id: string; routeName: string }> {
|
||||||
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.securityProfileRef === profileId) {
|
||||||
|
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Target CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createTarget(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
createdBy: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const target: INetworkTarget = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
host: data.host,
|
||||||
|
port: data.port,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: data.createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.targets.set(id, target);
|
||||||
|
await this.persistTarget(target);
|
||||||
|
logger.log('info', `Created network target '${target.name}' (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateTarget(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<INetworkTarget, 'id' | 'createdAt' | 'createdBy'>>,
|
||||||
|
): Promise<{ affectedRouteIds: string[] }> {
|
||||||
|
const target = this.targets.get(id);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`Network target '${id}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined) target.name = patch.name;
|
||||||
|
if (patch.description !== undefined) target.description = patch.description;
|
||||||
|
if (patch.host !== undefined) target.host = patch.host;
|
||||||
|
if (patch.port !== undefined) target.port = patch.port;
|
||||||
|
target.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistTarget(target);
|
||||||
|
logger.log('info', `Updated network target '${target.name}' (${id})`);
|
||||||
|
|
||||||
|
const affectedRouteIds = await this.findRoutesByTargetRef(id);
|
||||||
|
return { affectedRouteIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteTarget(
|
||||||
|
id: string,
|
||||||
|
force: boolean,
|
||||||
|
storedRoutes?: Map<string, IStoredRoute>,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const target = this.targets.get(id);
|
||||||
|
if (!target) {
|
||||||
|
return { success: false, message: `Network target '${id}' not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedIds = storedRoutes
|
||||||
|
? this.findRoutesByTargetRefSync(id, storedRoutes)
|
||||||
|
: await this.findRoutesByTargetRef(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0 && !force) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Target '${target.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await NetworkTargetDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
this.targets.delete(id);
|
||||||
|
|
||||||
|
if (affectedIds.length > 0) {
|
||||||
|
await this.clearTargetRefsOnRoutes(affectedIds);
|
||||||
|
logger.log('warn', `Force-deleted target '${target.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Deleted network target '${target.name}' (${id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTarget(id: string): INetworkTarget | undefined {
|
||||||
|
return this.targets.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTargetByName(name: string): INetworkTarget | undefined {
|
||||||
|
for (const target of this.targets.values()) {
|
||||||
|
if (target.name === name) return target;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public listTargets(): INetworkTarget[] {
|
||||||
|
return [...this.targets.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTargetUsageForId(
|
||||||
|
targetId: string,
|
||||||
|
storedRoutes: Map<string, IStoredRoute>,
|
||||||
|
): Array<{ id: string; routeName: string }> {
|
||||||
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.networkTargetRef === targetId) {
|
||||||
|
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Resolution
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve references for a single route.
|
||||||
|
* Materializes security profile and/or network target into the route's fields.
|
||||||
|
* Returns the resolved route and updated metadata.
|
||||||
|
*/
|
||||||
|
public resolveRoute(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
metadata?: IRouteMetadata,
|
||||||
|
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||||
|
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||||
|
|
||||||
|
if (resolvedMetadata.securityProfileRef) {
|
||||||
|
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
|
||||||
|
if (resolvedSecurity) {
|
||||||
|
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
|
||||||
|
// Merge: profile provides base, route's inline values override
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
security: this.mergeSecurityFields(resolvedSecurity, route.security),
|
||||||
|
};
|
||||||
|
resolvedMetadata.securityProfileName = profile?.name;
|
||||||
|
resolvedMetadata.lastResolvedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedMetadata.networkTargetRef) {
|
||||||
|
const target = this.targets.get(resolvedMetadata.networkTargetRef);
|
||||||
|
if (target) {
|
||||||
|
route = {
|
||||||
|
...route,
|
||||||
|
action: {
|
||||||
|
...route.action,
|
||||||
|
targets: [{
|
||||||
|
host: target.host as string,
|
||||||
|
port: target.port,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
resolvedMetadata.networkTargetName = target.name;
|
||||||
|
resolvedMetadata.lastResolvedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
logger.log('warn', `Network target '${resolvedMetadata.networkTargetRef}' not found during resolution`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route, metadata: resolvedMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Reference lookup helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||||
|
const docs = await StoredRouteDoc.findAll();
|
||||||
|
return docs
|
||||||
|
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
|
||||||
|
.map((doc) => doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||||
|
const docs = await StoredRouteDoc.findAll();
|
||||||
|
return docs
|
||||||
|
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||||
|
.map((doc) => doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.securityProfileRef === profileId) {
|
||||||
|
ids.push(routeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
|
if (stored.metadata?.networkTargetRef === targetId) {
|
||||||
|
ids.push(routeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: security profile resolution with inheritance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private resolveSecurityProfile(
|
||||||
|
profileId: string,
|
||||||
|
visited: Set<string> = new Set(),
|
||||||
|
depth: number = 0,
|
||||||
|
): IRouteSecurity | null {
|
||||||
|
if (depth > MAX_INHERITANCE_DEPTH) {
|
||||||
|
logger.log('warn', `Max inheritance depth (${MAX_INHERITANCE_DEPTH}) exceeded resolving profile '${profileId}'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(profileId)) {
|
||||||
|
logger.log('warn', `Circular inheritance detected for profile '${profileId}'`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = this.profiles.get(profileId);
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
visited.add(profileId);
|
||||||
|
|
||||||
|
// Start with an empty base
|
||||||
|
let baseSecurity: IRouteSecurity = {};
|
||||||
|
|
||||||
|
// Resolve parent profiles first (top-down, later overrides earlier)
|
||||||
|
if (profile.extendsProfiles?.length) {
|
||||||
|
for (const parentId of profile.extendsProfiles) {
|
||||||
|
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1);
|
||||||
|
if (parentSecurity) {
|
||||||
|
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply this profile's security on top
|
||||||
|
return this.mergeSecurityFields(baseSecurity, profile.security);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two IRouteSecurity objects.
|
||||||
|
* `override` values take precedence over `base` values.
|
||||||
|
* For ipAllowList/ipBlockList: union arrays and deduplicate.
|
||||||
|
* For scalar/object fields: override wins if present.
|
||||||
|
*/
|
||||||
|
private mergeSecurityFields(
|
||||||
|
base: IRouteSecurity | undefined,
|
||||||
|
override: IRouteSecurity | undefined,
|
||||||
|
): IRouteSecurity {
|
||||||
|
if (!base && !override) return {};
|
||||||
|
if (!base) return { ...override };
|
||||||
|
if (!override) return { ...base };
|
||||||
|
|
||||||
|
const merged: IRouteSecurity = { ...base };
|
||||||
|
|
||||||
|
// IP lists: union
|
||||||
|
if (override.ipAllowList || base.ipAllowList) {
|
||||||
|
merged.ipAllowList = [...new Set([
|
||||||
|
...(base.ipAllowList || []),
|
||||||
|
...(override.ipAllowList || []),
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (override.ipBlockList || base.ipBlockList) {
|
||||||
|
merged.ipBlockList = [...new Set([
|
||||||
|
...(base.ipBlockList || []),
|
||||||
|
...(override.ipBlockList || []),
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar/object fields: override wins
|
||||||
|
if (override.maxConnections !== undefined) merged.maxConnections = override.maxConnections;
|
||||||
|
if (override.rateLimit !== undefined) merged.rateLimit = override.rateLimit;
|
||||||
|
if (override.authentication !== undefined) merged.authentication = override.authentication;
|
||||||
|
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
|
||||||
|
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadProfiles(): Promise<void> {
|
||||||
|
const docs = await SecurityProfileDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.profiles.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
security: doc.security,
|
||||||
|
extendsProfiles: doc.extendsProfiles,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.profiles.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTargets(): Promise<void> {
|
||||||
|
const docs = await NetworkTargetDoc.findAll();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.id) {
|
||||||
|
this.targets.set(doc.id, {
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
host: doc.host,
|
||||||
|
port: doc.port,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.targets.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.targets.size} network target(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistProfile(profile: ISecurityProfile): Promise<void> {
|
||||||
|
const existingDoc = await SecurityProfileDoc.findById(profile.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.name = profile.name;
|
||||||
|
existingDoc.description = profile.description;
|
||||||
|
existingDoc.security = profile.security;
|
||||||
|
existingDoc.extendsProfiles = profile.extendsProfiles;
|
||||||
|
existingDoc.updatedAt = profile.updatedAt;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new SecurityProfileDoc();
|
||||||
|
doc.id = profile.id;
|
||||||
|
doc.name = profile.name;
|
||||||
|
doc.description = profile.description;
|
||||||
|
doc.security = profile.security;
|
||||||
|
doc.extendsProfiles = profile.extendsProfiles;
|
||||||
|
doc.createdAt = profile.createdAt;
|
||||||
|
doc.updatedAt = profile.updatedAt;
|
||||||
|
doc.createdBy = profile.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistTarget(target: INetworkTarget): Promise<void> {
|
||||||
|
const existingDoc = await NetworkTargetDoc.findById(target.id);
|
||||||
|
if (existingDoc) {
|
||||||
|
existingDoc.name = target.name;
|
||||||
|
existingDoc.description = target.description;
|
||||||
|
existingDoc.host = target.host;
|
||||||
|
existingDoc.port = target.port;
|
||||||
|
existingDoc.updatedAt = target.updatedAt;
|
||||||
|
await existingDoc.save();
|
||||||
|
} else {
|
||||||
|
const doc = new NetworkTargetDoc();
|
||||||
|
doc.id = target.id;
|
||||||
|
doc.name = target.name;
|
||||||
|
doc.description = target.description;
|
||||||
|
doc.host = target.host;
|
||||||
|
doc.port = target.port;
|
||||||
|
doc.createdAt = target.createdAt;
|
||||||
|
doc.updatedAt = target.updatedAt;
|
||||||
|
doc.createdBy = target.createdBy;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: ref cleanup on force-delete
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const doc = await StoredRouteDoc.findById(routeId);
|
||||||
|
if (doc?.metadata) {
|
||||||
|
doc.metadata = {
|
||||||
|
...doc.metadata,
|
||||||
|
securityProfileRef: undefined,
|
||||||
|
securityProfileName: undefined,
|
||||||
|
};
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const doc = await StoredRouteDoc.findById(routeId);
|
||||||
|
if (doc?.metadata) {
|
||||||
|
doc.metadata = {
|
||||||
|
...doc.metadata,
|
||||||
|
networkTargetRef: undefined,
|
||||||
|
networkTargetName: undefined,
|
||||||
|
};
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import type {
|
|||||||
IRouteOverride,
|
IRouteOverride,
|
||||||
IMergedRoute,
|
IMergedRoute,
|
||||||
IRouteWarning,
|
IRouteWarning,
|
||||||
|
IRouteMetadata,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||||
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
|
||||||
export class RouteConfigManager {
|
export class RouteConfigManager {
|
||||||
private storedRoutes = new Map<string, IStoredRoute>();
|
private storedRoutes = new Map<string, IStoredRoute>();
|
||||||
@@ -20,8 +22,14 @@ export class RouteConfigManager {
|
|||||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
private getVpnAllowList?: (tags?: string[]) => string[],
|
private getVpnAllowList?: (tags?: string[]) => string[],
|
||||||
|
private referenceResolver?: ReferenceResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Expose stored routes map for reference resolution lookups. */
|
||||||
|
public getStoredRoutes(): Map<string, IStoredRoute> {
|
||||||
|
return this.storedRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||||
*/
|
*/
|
||||||
@@ -62,6 +70,7 @@ export class RouteConfigManager {
|
|||||||
storedRouteId: stored.id,
|
storedRouteId: stored.id,
|
||||||
createdAt: stored.createdAt,
|
createdAt: stored.createdAt,
|
||||||
updatedAt: stored.updatedAt,
|
updatedAt: stored.updatedAt,
|
||||||
|
metadata: stored.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +85,7 @@ export class RouteConfigManager {
|
|||||||
route: plugins.smartproxy.IRouteConfig,
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
metadata?: IRouteMetadata,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -85,6 +95,14 @@ export class RouteConfigManager {
|
|||||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve references if metadata has refs and resolver is available
|
||||||
|
let resolvedMetadata = metadata;
|
||||||
|
if (metadata && this.referenceResolver) {
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(route, metadata);
|
||||||
|
route = resolved.route;
|
||||||
|
resolvedMetadata = resolved.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
const stored: IStoredRoute = {
|
const stored: IStoredRoute = {
|
||||||
id,
|
id,
|
||||||
route,
|
route,
|
||||||
@@ -92,6 +110,7 @@ export class RouteConfigManager {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy,
|
createdBy,
|
||||||
|
metadata: resolvedMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.storedRoutes.set(id, stored);
|
this.storedRoutes.set(id, stored);
|
||||||
@@ -102,7 +121,11 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
public async updateRoute(
|
public async updateRoute(
|
||||||
id: string,
|
id: string,
|
||||||
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
patch: {
|
||||||
|
route?: Partial<plugins.smartproxy.IRouteConfig>;
|
||||||
|
enabled?: boolean;
|
||||||
|
metadata?: Partial<IRouteMetadata>;
|
||||||
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const stored = this.storedRoutes.get(id);
|
const stored = this.storedRoutes.get(id);
|
||||||
if (!stored) return false;
|
if (!stored) return false;
|
||||||
@@ -113,6 +136,17 @@ export class RouteConfigManager {
|
|||||||
if (patch.enabled !== undefined) {
|
if (patch.enabled !== undefined) {
|
||||||
stored.enabled = patch.enabled;
|
stored.enabled = patch.enabled;
|
||||||
}
|
}
|
||||||
|
if (patch.metadata !== undefined) {
|
||||||
|
stored.metadata = { ...stored.metadata, ...patch.metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-resolve if metadata refs exist and resolver is available
|
||||||
|
if (stored.metadata && this.referenceResolver) {
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
|
stored.route = resolved.route;
|
||||||
|
stored.metadata = resolved.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
stored.updatedAt = Date.now();
|
stored.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.persistRoute(stored);
|
await this.persistRoute(stored);
|
||||||
@@ -188,6 +222,7 @@ export class RouteConfigManager {
|
|||||||
createdAt: doc.createdAt,
|
createdAt: doc.createdAt,
|
||||||
updatedAt: doc.updatedAt,
|
updatedAt: doc.updatedAt,
|
||||||
createdBy: doc.createdBy,
|
createdBy: doc.createdBy,
|
||||||
|
metadata: doc.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,6 +255,7 @@ export class RouteConfigManager {
|
|||||||
existingDoc.enabled = stored.enabled;
|
existingDoc.enabled = stored.enabled;
|
||||||
existingDoc.updatedAt = stored.updatedAt;
|
existingDoc.updatedAt = stored.updatedAt;
|
||||||
existingDoc.createdBy = stored.createdBy;
|
existingDoc.createdBy = stored.createdBy;
|
||||||
|
existingDoc.metadata = stored.metadata;
|
||||||
await existingDoc.save();
|
await existingDoc.save();
|
||||||
} else {
|
} else {
|
||||||
const doc = new StoredRouteDoc();
|
const doc = new StoredRouteDoc();
|
||||||
@@ -229,6 +265,7 @@ export class RouteConfigManager {
|
|||||||
doc.createdAt = stored.createdAt;
|
doc.createdAt = stored.createdAt;
|
||||||
doc.updatedAt = stored.updatedAt;
|
doc.updatedAt = stored.updatedAt;
|
||||||
doc.createdBy = stored.createdBy;
|
doc.createdBy = stored.createdBy;
|
||||||
|
doc.metadata = stored.metadata;
|
||||||
await doc.save();
|
await doc.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,6 +314,32 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Re-resolve routes after profile/target changes
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve specific routes by ID (after a profile or target is updated).
|
||||||
|
* Persists each route and calls applyRoutes() once at the end.
|
||||||
|
*/
|
||||||
|
public async reResolveRoutes(routeIds: string[]): Promise<void> {
|
||||||
|
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||||
|
|
||||||
|
for (const routeId of routeIds) {
|
||||||
|
const stored = this.storedRoutes.get(routeId);
|
||||||
|
if (!stored?.metadata) continue;
|
||||||
|
|
||||||
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
|
stored.route = resolved.route;
|
||||||
|
stored.metadata = resolved.metadata;
|
||||||
|
stored.updatedAt = Date.now();
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applyRoutes();
|
||||||
|
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: apply merged routes to SmartProxy
|
// Private: apply merged routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Export validation tools only
|
// Export validation tools only
|
||||||
export * from './validator.js';
|
export * from './validator.js';
|
||||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||||
|
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||||
|
export { DbSeeder } from './classes.db-seeder.js';
|
||||||
48
ts/db/documents/classes.network-target.doc.ts
Normal file
48
ts/db/documents/classes.network-target.doc.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public host!: string | string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public port!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
|
||||||
|
return await NetworkTargetDoc.getInstance({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<NetworkTargetDoc[]> {
|
||||||
|
return await NetworkTargetDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
49
ts/db/documents/classes.security-profile.doc.ts
Normal file
49
ts/db/documents/classes.security-profile.doc.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public security!: IRouteSecurity;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public extendsProfiles?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<SecurityProfileDoc | null> {
|
||||||
|
return await SecurityProfileDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<SecurityProfileDoc | null> {
|
||||||
|
return await SecurityProfileDoc.getInstance({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<SecurityProfileDoc[]> {
|
||||||
|
return await SecurityProfileDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
|||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public createdBy!: string;
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public metadata?: IRouteMetadata;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export * from './classes.cached.ip.reputation.js';
|
|||||||
export * from './classes.stored-route.doc.js';
|
export * from './classes.stored-route.doc.js';
|
||||||
export * from './classes.route-override.doc.js';
|
export * from './classes.route-override.doc.js';
|
||||||
export * from './classes.api-token.doc.js';
|
export * from './classes.api-token.doc.js';
|
||||||
|
export * from './classes.security-profile.doc.js';
|
||||||
|
export * from './classes.network-target.doc.js';
|
||||||
|
|
||||||
// VPN document classes
|
// VPN document classes
|
||||||
export * from './classes.vpn-server-keys.doc.js';
|
export * from './classes.vpn-server-keys.doc.js';
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export class OpsServer {
|
|||||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||||
private vpnHandler!: handlers.VpnHandler;
|
private vpnHandler!: handlers.VpnHandler;
|
||||||
|
private securityProfileHandler!: handlers.SecurityProfileHandler;
|
||||||
|
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -88,6 +90,8 @@ export class OpsServer {
|
|||||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||||
this.vpnHandler = new handlers.VpnHandler(this);
|
this.vpnHandler = new handlers.VpnHandler(this);
|
||||||
|
this.securityProfileHandler = new handlers.SecurityProfileHandler(this);
|
||||||
|
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export * from './certificate.handler.js';
|
|||||||
export * from './remoteingress.handler.js';
|
export * from './remoteingress.handler.js';
|
||||||
export * from './route-management.handler.js';
|
export * from './route-management.handler.js';
|
||||||
export * from './api-token.handler.js';
|
export * from './api-token.handler.js';
|
||||||
export * from './vpn.handler.js';
|
export * from './vpn.handler.js';
|
||||||
|
export * from './security-profile.handler.js';
|
||||||
|
export * from './network-target.handler.js';
|
||||||
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
167
ts/opsserver/handlers/network-target.handler.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class NetworkTargetHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all network targets
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
|
||||||
|
'getNetworkTargets',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { targets: [] };
|
||||||
|
}
|
||||||
|
return { targets: resolver.listTargets() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTarget>(
|
||||||
|
'getNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { target: null };
|
||||||
|
}
|
||||||
|
return { target: resolver.getTarget(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||||
|
'createNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { success: false, message: 'Reference resolver not initialized' };
|
||||||
|
}
|
||||||
|
const id = await resolver.createTarget({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateNetworkTarget>(
|
||||||
|
'updateNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { affectedRouteIds } = await resolver.updateTarget(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await manager.reResolveRoutes(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, affectedRouteCount: affectedRouteIds.length };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteNetworkTarget>(
|
||||||
|
'deleteNetworkTarget',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolver.deleteTarget(
|
||||||
|
dataArg.id,
|
||||||
|
dataArg.force ?? false,
|
||||||
|
manager.getStoredRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && dataArg.force) {
|
||||||
|
await manager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get routes using a network target
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||||
|
'getNetworkTargetUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'targets:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||||
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export class RouteManagementHandler {
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Route management not initialized' };
|
return { success: false, message: 'Route management not initialized' };
|
||||||
}
|
}
|
||||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true);
|
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
||||||
return { success: true, storedRouteId: id };
|
return { success: true, storedRouteId: id };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -90,6 +90,7 @@ export class RouteManagementHandler {
|
|||||||
const ok = await manager.updateRoute(dataArg.id, {
|
const ok = await manager.updateRoute(dataArg.id, {
|
||||||
route: dataArg.route as any,
|
route: dataArg.route as any,
|
||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
|
metadata: dataArg.metadata,
|
||||||
});
|
});
|
||||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||||
},
|
},
|
||||||
|
|||||||
169
ts/opsserver/handlers/security-profile.handler.ts
Normal file
169
ts/opsserver/handlers/security-profile.handler.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class SecurityProfileHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get all security profiles
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>(
|
||||||
|
'getSecurityProfiles',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { profiles: [] };
|
||||||
|
}
|
||||||
|
return { profiles: resolver.listProfiles() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>(
|
||||||
|
'getSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { profile: null };
|
||||||
|
}
|
||||||
|
return { profile: resolver.getProfile(dataArg.id) || null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>(
|
||||||
|
'createSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return { success: false, message: 'Reference resolver not initialized' };
|
||||||
|
}
|
||||||
|
const id = await resolver.createProfile({
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>(
|
||||||
|
'updateSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { affectedRouteIds } = await resolver.updateProfile(dataArg.id, {
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Propagate to affected routes
|
||||||
|
if (affectedRouteIds.length > 0) {
|
||||||
|
await manager.reResolveRoutes(affectedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, affectedRouteCount: affectedRouteIds.length };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>(
|
||||||
|
'deleteSecurityProfile',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:write');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { success: false, message: 'Not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolver.deleteProfile(
|
||||||
|
dataArg.id,
|
||||||
|
dataArg.force ?? false,
|
||||||
|
manager.getStoredRoutes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If force-deleted with affected routes, re-apply
|
||||||
|
if (result.success && dataArg.force) {
|
||||||
|
await manager.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get routes using a security profile
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>(
|
||||||
|
'getSecurityProfileUsage',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'profiles:read');
|
||||||
|
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!resolver || !manager) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
|
||||||
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,77 @@
|
|||||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
// Derive IRouteSecurity from IRouteConfig since it's not directly exported
|
||||||
|
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Data Types
|
// Route Management Data Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage';
|
export type TApiTokenScope =
|
||||||
|
| 'routes:read' | 'routes:write'
|
||||||
|
| 'config:read'
|
||||||
|
| 'tokens:read' | 'tokens:manage'
|
||||||
|
| 'profiles:read' | 'profiles:write'
|
||||||
|
| 'targets:read' | 'targets:write';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Profile Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable, named security profile that can be referenced by routes.
|
||||||
|
* Stores the full IRouteSecurity shape from SmartProxy.
|
||||||
|
*/
|
||||||
|
export interface ISecurityProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
/** The security configuration — mirrors SmartProxy's IRouteSecurity. */
|
||||||
|
security: IRouteSecurity;
|
||||||
|
/** IDs of profiles this one extends (resolved top-down, later overrides earlier). */
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable, named network target (host + port) that can be referenced by routes.
|
||||||
|
*/
|
||||||
|
export interface INetworkTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Route Metadata Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata on a stored route tracking where its resolved values came from.
|
||||||
|
*/
|
||||||
|
export interface IRouteMetadata {
|
||||||
|
/** ID of the SecurityProfileDoc used to resolve this route's security. */
|
||||||
|
securityProfileRef?: string;
|
||||||
|
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
|
||||||
|
networkTargetRef?: string;
|
||||||
|
/** Snapshot of the profile name at resolution time, for display. */
|
||||||
|
securityProfileName?: string;
|
||||||
|
/** Snapshot of the target name at resolution time, for display. */
|
||||||
|
networkTargetName?: string;
|
||||||
|
/** Timestamp of last reference resolution. */
|
||||||
|
lastResolvedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A merged route combining hardcoded and programmatic sources.
|
* A merged route combining hardcoded and programmatic sources.
|
||||||
@@ -17,6 +84,7 @@ export interface IMergedRoute {
|
|||||||
storedRouteId?: string;
|
storedRouteId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
|
metadata?: IRouteMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +123,7 @@ export interface IStoredRoute {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
metadata?: IRouteMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export * from './certificate.js';
|
|||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
export * from './route-management.js';
|
export * from './route-management.js';
|
||||||
export * from './api-tokens.js';
|
export * from './api-tokens.js';
|
||||||
export * from './vpn.js';
|
export * from './vpn.js';
|
||||||
|
export * from './security-profiles.js';
|
||||||
|
export * from './network-targets.js';
|
||||||
127
ts_interfaces/requests/network-targets.ts
Normal file
127
ts_interfaces/requests/network-targets.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { INetworkTarget } from '../data/route-management.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network Target Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all network targets.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetNetworkTargets extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkTargets
|
||||||
|
> {
|
||||||
|
method: 'getNetworkTargets';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
targets: INetworkTarget[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single network target by ID.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'getNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
target: INetworkTarget | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'createNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'updateNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
host?: string | string[];
|
||||||
|
port?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
affectedRouteCount?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteNetworkTarget extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteNetworkTarget
|
||||||
|
> {
|
||||||
|
method: 'deleteNetworkTarget';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which routes reference a network target.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetNetworkTargetUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkTargetUsage
|
||||||
|
> {
|
||||||
|
method: 'getNetworkTargetUsage';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
routes: Array<{ id: string; name: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type * as authInterfaces from '../data/auth.js';
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js';
|
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
||||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -38,6 +38,7 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
apiToken?: string;
|
apiToken?: string;
|
||||||
route: IRouteConfig;
|
route: IRouteConfig;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
metadata?: IRouteMetadata;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -60,6 +61,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
id: string;
|
id: string;
|
||||||
route?: Partial<IRouteConfig>;
|
route?: Partial<IRouteConfig>;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
metadata?: Partial<IRouteMetadata>;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
127
ts_interfaces/requests/security-profiles.ts
Normal file
127
ts_interfaces/requests/security-profiles.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { ISecurityProfile, IRouteSecurity } from '../data/route-management.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Profile Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all security profiles.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetSecurityProfiles extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetSecurityProfiles
|
||||||
|
> {
|
||||||
|
method: 'getSecurityProfiles';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
profiles: ISecurityProfile[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single security profile by ID.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetSecurityProfile
|
||||||
|
> {
|
||||||
|
method: 'getSecurityProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
profile: ISecurityProfile | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new security profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateSecurityProfile
|
||||||
|
> {
|
||||||
|
method: 'createSecurityProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a security profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateSecurityProfile
|
||||||
|
> {
|
||||||
|
method: 'updateSecurityProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
security?: IRouteSecurity;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
affectedRouteCount?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a security profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteSecurityProfile
|
||||||
|
> {
|
||||||
|
method: 'deleteSecurityProfile';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get which routes reference a security profile.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetSecurityProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetSecurityProfileUsage
|
||||||
|
> {
|
||||||
|
method: 'getSecurityProfileUsage';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
routes: Array<{ id: string; name: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.1.0',
|
version: '12.2.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
|||||||
// Determine initial view from URL path
|
// Determine initial view from URL path
|
||||||
const getInitialView = (): string => {
|
const getInitialView = (): string => {
|
||||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||||
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'];
|
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'securityprofiles', 'networktargets'];
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
return validViews.includes(view) ? view : 'overview';
|
return validViews.includes(view) ? view : 'overview';
|
||||||
@@ -444,6 +444,13 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If switching to security profiles or network targets views, fetch profiles/targets data
|
||||||
|
if ((viewName === 'securityprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
|
||||||
|
setTimeout(() => {
|
||||||
|
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
activeView: viewName,
|
activeView: viewName,
|
||||||
@@ -1133,6 +1140,241 @@ export const clearNewClientConfigAction = vpnStatePart.createAction(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Profiles & Network Targets State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IProfilesTargetsState {
|
||||||
|
profiles: interfaces.data.ISecurityProfile[];
|
||||||
|
targets: interfaces.data.INetworkTarget[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profilesTargetsStatePart = await appState.getStatePart<IProfilesTargetsState>(
|
||||||
|
'profilesTargets',
|
||||||
|
{
|
||||||
|
profiles: [],
|
||||||
|
targets: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Profiles & Network Targets Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction(
|
||||||
|
async (statePartArg): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profilesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetSecurityProfiles
|
||||||
|
>('/typedrequest', 'getSecurityProfiles');
|
||||||
|
|
||||||
|
const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetNetworkTargets
|
||||||
|
>('/typedrequest', 'getNetworkTargets');
|
||||||
|
|
||||||
|
const [profilesResponse, targetsResponse] = await Promise.all([
|
||||||
|
profilesRequest.fire({ identity: context.identity }),
|
||||||
|
targetsRequest.fire({ identity: context.identity }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles: profilesResponse.profiles,
|
||||||
|
targets: targetsResponse.targets,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch profiles/targets',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createProfileAction = profilesTargetsStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
security: any;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateSecurityProfile
|
||||||
|
>('/typedrequest', 'createSecurityProfile');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateProfileAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
security?: any;
|
||||||
|
extendsProfiles?: string[];
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateSecurityProfile
|
||||||
|
>('/typedrequest', 'updateSecurityProfile');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
security: dataArg.security,
|
||||||
|
extendsProfiles: dataArg.extendsProfiles,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteProfileAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteSecurityProfile
|
||||||
|
>('/typedrequest', 'deleteSecurityProfile');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
force: dataArg.force,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to delete profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createTargetAction = profilesTargetsStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
host: string | string[];
|
||||||
|
port: number;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateNetworkTarget
|
||||||
|
>('/typedrequest', 'createNetworkTarget');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateTargetAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
host?: string | string[];
|
||||||
|
port?: number;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateNetworkTarget
|
||||||
|
>('/typedrequest', 'updateNetworkTarget');
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
host: dataArg.host,
|
||||||
|
port: dataArg.port,
|
||||||
|
});
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteTargetAction = profilesTargetsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IProfilesTargetsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteNetworkTarget
|
||||||
|
>('/typedrequest', 'deleteNetworkTarget');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
force: dataArg.force,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to delete target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchProfilesAndTargetsAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete target',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Actions
|
// Route Management Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ export * from './ops-view-security.js';
|
|||||||
export * from './ops-view-certificates.js';
|
export * from './ops-view-certificates.js';
|
||||||
export * from './ops-view-remoteingress.js';
|
export * from './ops-view-remoteingress.js';
|
||||||
export * from './ops-view-vpn.js';
|
export * from './ops-view-vpn.js';
|
||||||
|
export * from './ops-view-securityprofiles.js';
|
||||||
|
export * from './ops-view-networktargets.js';
|
||||||
export * from './shared/index.js';
|
export * from './shared/index.js';
|
||||||
@@ -25,6 +25,8 @@ import { OpsViewSecurity } from './ops-view-security.js';
|
|||||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||||
|
import { OpsViewSecurityProfiles } from './ops-view-securityprofiles.js';
|
||||||
|
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
|
||||||
|
|
||||||
@customElement('ops-dashboard')
|
@customElement('ops-dashboard')
|
||||||
export class OpsDashboard extends DeesElement {
|
export class OpsDashboard extends DeesElement {
|
||||||
@@ -73,6 +75,16 @@ export class OpsDashboard extends DeesElement {
|
|||||||
iconName: 'lucide:route',
|
iconName: 'lucide:route',
|
||||||
element: OpsViewRoutes,
|
element: OpsViewRoutes,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'SecurityProfiles',
|
||||||
|
iconName: 'lucide:shieldCheck',
|
||||||
|
element: OpsViewSecurityProfiles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NetworkTargets',
|
||||||
|
iconName: 'lucide:server',
|
||||||
|
element: OpsViewNetworkTargets,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'ApiTokens',
|
name: 'ApiTokens',
|
||||||
iconName: 'lucide:key',
|
iconName: 'lucide:key',
|
||||||
|
|||||||
214
ts_web/elements/ops-view-networktargets.ts
Normal file
214
ts_web/elements/ops-view-networktargets.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from './shared/css.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-networktargets': OpsViewNetworkTargets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('ops-view-networktargets')
|
||||||
|
export class OpsViewNetworkTargets extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.profilesTargetsStatePart.select().subscribe((newState) => {
|
||||||
|
this.profilesState = newState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.targetsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const targets = this.profilesState.targets;
|
||||||
|
|
||||||
|
const statsTiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'totalTargets',
|
||||||
|
title: 'Total Targets',
|
||||||
|
type: 'number',
|
||||||
|
value: targets.length,
|
||||||
|
icon: 'lucide:server',
|
||||||
|
description: 'Reusable network targets',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="targetsContainer">
|
||||||
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
|
<dees-table
|
||||||
|
.heading=${'Network Targets'}
|
||||||
|
.data=${targets}
|
||||||
|
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({
|
||||||
|
Name: target.name,
|
||||||
|
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,
|
||||||
|
Port: target.port,
|
||||||
|
Description: target.description || '-',
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create Target',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header' as const],
|
||||||
|
action: async (_: any, table: any) => {
|
||||||
|
await this.showCreateTargetDialog(table);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['header' as const],
|
||||||
|
action: async () => {
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil',
|
||||||
|
type: ['contextmenu' as const],
|
||||||
|
action: async (target: interfaces.data.INetworkTarget, table: any) => {
|
||||||
|
await this.showEditTargetDialog(target, table);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['contextmenu' as const],
|
||||||
|
action: async (target: interfaces.data.INetworkTarget) => {
|
||||||
|
await this.deleteTarget(target);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateTargetDialog(table: any) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Create Network Target',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'host'} .label=${'Host'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'port'} .label=${'Port'} .required=${true} .value=${'443'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createTargetAction, {
|
||||||
|
name: String(data.name),
|
||||||
|
description: data.description ? String(data.description) : undefined,
|
||||||
|
host: String(data.host),
|
||||||
|
port: parseInt(String(data.port)) || 443,
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showEditTargetDialog(target: interfaces.data.INetworkTarget, table: any) {
|
||||||
|
const hostStr = Array.isArray(target.host) ? target.host.join(', ') : target.host;
|
||||||
|
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Edit Target: ${target.name}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Name'} .value=${target.name}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'host'} .label=${'Host'} .value=${hostStr}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'port'} .label=${'Port'} .value=${String(target.port)}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'description'} .label=${'Description'} .value=${target.description || ''}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateTargetAction, {
|
||||||
|
id: target.id,
|
||||||
|
name: String(data.name),
|
||||||
|
description: data.description ? String(data.description) : undefined,
|
||||||
|
host: String(data.host),
|
||||||
|
port: parseInt(String(data.port)) || 443,
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteTarget(target: interfaces.data.INetworkTarget) {
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteTargetAction, {
|
||||||
|
id: target.id,
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentState = appstate.profilesTargetsStatePart.getState()!;
|
||||||
|
if (currentState.error?.includes('in use')) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Target In Use',
|
||||||
|
content: html`<p>${currentState.error} Force delete?</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Force Delete',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteTargetAction, {
|
||||||
|
id: target.id,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
ts_web/elements/ops-view-securityprofiles.ts
Normal file
242
ts_web/elements/ops-view-securityprofiles.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from './shared/css.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-securityprofiles': OpsViewSecurityProfiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('ops-view-securityprofiles')
|
||||||
|
export class OpsViewSecurityProfiles extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.profilesTargetsStatePart.select().subscribe((newState) => {
|
||||||
|
this.profilesState = newState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.profilesContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const profiles = this.profilesState.profiles;
|
||||||
|
|
||||||
|
const statsTiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'totalProfiles',
|
||||||
|
title: 'Total Profiles',
|
||||||
|
type: 'number',
|
||||||
|
value: profiles.length,
|
||||||
|
icon: 'lucide:shieldCheck',
|
||||||
|
description: 'Reusable security profiles',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="profilesContainer">
|
||||||
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
|
<dees-table
|
||||||
|
.heading=${'Security Profiles'}
|
||||||
|
.data=${profiles}
|
||||||
|
.displayFunction=${(profile: interfaces.data.ISecurityProfile) => ({
|
||||||
|
Name: profile.name,
|
||||||
|
Description: profile.description || '-',
|
||||||
|
'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-',
|
||||||
|
'IP Block List': (profile.security?.ipBlockList || []).join(', ') || '-',
|
||||||
|
'Max Connections': profile.security?.maxConnections ?? '-',
|
||||||
|
'Rate Limit': profile.security?.rateLimit?.enabled ? `${profile.security.rateLimit.maxRequests}/${profile.security.rateLimit.window}s` : '-',
|
||||||
|
Extends: (profile.extendsProfiles || []).length > 0
|
||||||
|
? profile.extendsProfiles!.map(id => {
|
||||||
|
const p = profiles.find(pp => pp.id === id);
|
||||||
|
return p ? p.name : id.slice(0, 8);
|
||||||
|
}).join(', ')
|
||||||
|
: '-',
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Create Profile',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header' as const],
|
||||||
|
action: async (_: any, table: any) => {
|
||||||
|
await this.showCreateProfileDialog(table);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['header' as const],
|
||||||
|
action: async () => {
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil',
|
||||||
|
type: ['contextmenu' as const],
|
||||||
|
action: async (profile: interfaces.data.ISecurityProfile, table: any) => {
|
||||||
|
await this.showEditProfileDialog(profile, table);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['contextmenu' as const],
|
||||||
|
action: async (profile: interfaces.data.ISecurityProfile) => {
|
||||||
|
await this.deleteProfile(profile);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateProfileDialog(table: any) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Create Security Profile',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ipAllowList'} .label=${'IP Allow List (comma-separated)'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ipBlockList'} .label=${'IP Block List (comma-separated)'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const ipAllowList = data.ipAllowList
|
||||||
|
? String(data.ipAllowList).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const ipBlockList = data.ipBlockList
|
||||||
|
? String(data.ipBlockList).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
|
||||||
|
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
|
||||||
|
name: String(data.name),
|
||||||
|
description: data.description ? String(data.description) : undefined,
|
||||||
|
security: {
|
||||||
|
...(ipAllowList ? { ipAllowList } : {}),
|
||||||
|
...(ipBlockList ? { ipBlockList } : {}),
|
||||||
|
...(maxConnections ? { maxConnections } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showEditProfileDialog(profile: interfaces.data.ISecurityProfile, table: any) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Edit Profile: ${profile.name}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ipAllowList'} .label=${'IP Allow List (comma-separated)'} .value=${(profile.security?.ipAllowList || []).join(', ')}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ipBlockList'} .label=${'IP Block List (comma-separated)'} .value=${(profile.security?.ipBlockList || []).join(', ')}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'} .value=${String(profile.security?.maxConnections || '')}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const ipAllowList = data.ipAllowList
|
||||||
|
? String(data.ipAllowList).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const ipBlockList = data.ipBlockList
|
||||||
|
? String(data.ipBlockList).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
|
||||||
|
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
|
||||||
|
id: profile.id,
|
||||||
|
name: String(data.name),
|
||||||
|
description: data.description ? String(data.description) : undefined,
|
||||||
|
security: {
|
||||||
|
ipAllowList,
|
||||||
|
ipBlockList,
|
||||||
|
...(maxConnections ? { maxConnections } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteProfile(profile: interfaces.data.ISecurityProfile) {
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
|
||||||
|
id: profile.id,
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentState = appstate.profilesTargetsStatePart.getState()!;
|
||||||
|
if (currentState.error?.includes('in use')) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Profile In Use',
|
||||||
|
content: html`<p>${currentState.error} Force delete?</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Force Delete',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
|
||||||
|
id: profile.id,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
|||||||
|
|
||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||||
|
|
||||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn'] as const;
|
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'securityprofiles', 'networktargets'] as const;
|
||||||
|
|
||||||
export type TValidView = typeof validViews[number];
|
export type TValidView = typeof validViews[number];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user