feat(config): add reusable security profiles and network targets with route reference resolution
This commit is contained in:
@@ -1 +1,7 @@
|
||||
node_modules/
|
||||
.nogit/
|
||||
.git/
|
||||
.playwright-mcp/
|
||||
.vscode/
|
||||
test/
|
||||
test_watch/
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 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)
|
||||
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 = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '12.1.0',
|
||||
version: '12.2.0',
|
||||
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 { RemoteIngressManager, TunnelManager } from './remoteingress/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 { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
|
||||
@@ -137,6 +137,10 @@ export interface IDcRouterOptions {
|
||||
dbName?: string;
|
||||
/** Cache cleanup interval in hours (default: 1) */
|
||||
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
|
||||
public routeConfigManager?: RouteConfigManager;
|
||||
public apiTokenManager?: ApiTokenManager;
|
||||
public referenceResolver?: ReferenceResolver;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
@@ -456,6 +461,10 @@ export class DcRouter {
|
||||
.optional()
|
||||
.dependsOn('SmartProxy', 'DcRouterDb')
|
||||
.withStart(async () => {
|
||||
// Initialize reference resolver first (profiles + targets)
|
||||
this.referenceResolver = new ReferenceResolver();
|
||||
await this.referenceResolver.initialize();
|
||||
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
@@ -468,14 +477,23 @@ export class DcRouter {
|
||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||
}
|
||||
: undefined,
|
||||
this.referenceResolver,
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
await this.apiTokenManager.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 () => {
|
||||
this.routeConfigManager = undefined;
|
||||
this.apiTokenManager = undefined;
|
||||
this.referenceResolver = undefined;
|
||||
})
|
||||
.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,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
@@ -20,8 +22,14 @@ export class RouteConfigManager {
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
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.
|
||||
*/
|
||||
@@ -62,6 +70,7 @@ export class RouteConfigManager {
|
||||
storedRouteId: stored.id,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
metadata: stored.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,6 +85,7 @@ export class RouteConfigManager {
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: IRouteMetadata,
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
@@ -85,6 +95,14 @@ export class RouteConfigManager {
|
||||
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 = {
|
||||
id,
|
||||
route,
|
||||
@@ -92,6 +110,7 @@ export class RouteConfigManager {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
metadata: resolvedMetadata,
|
||||
};
|
||||
|
||||
this.storedRoutes.set(id, stored);
|
||||
@@ -102,7 +121,11 @@ export class RouteConfigManager {
|
||||
|
||||
public async updateRoute(
|
||||
id: string,
|
||||
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
||||
patch: {
|
||||
route?: Partial<plugins.smartproxy.IRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const stored = this.storedRoutes.get(id);
|
||||
if (!stored) return false;
|
||||
@@ -113,6 +136,17 @@ export class RouteConfigManager {
|
||||
if (patch.enabled !== undefined) {
|
||||
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();
|
||||
|
||||
await this.persistRoute(stored);
|
||||
@@ -188,6 +222,7 @@ export class RouteConfigManager {
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
metadata: doc.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -220,6 +255,7 @@ export class RouteConfigManager {
|
||||
existingDoc.enabled = stored.enabled;
|
||||
existingDoc.updatedAt = stored.updatedAt;
|
||||
existingDoc.createdBy = stored.createdBy;
|
||||
existingDoc.metadata = stored.metadata;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new StoredRouteDoc();
|
||||
@@ -229,6 +265,7 @@ export class RouteConfigManager {
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.updatedAt = stored.updatedAt;
|
||||
doc.createdBy = stored.createdBy;
|
||||
doc.metadata = stored.metadata;
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Export validation tools only
|
||||
export * from './validator.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 { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@@ -24,6 +25,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.stored-route.doc.js';
|
||||
export * from './classes.route-override.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
|
||||
export * from './classes.vpn-server-keys.doc.js';
|
||||
|
||||
@@ -29,6 +29,8 @@ export class OpsServer {
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler!: handlers.ApiTokenHandler;
|
||||
private vpnHandler!: handlers.VpnHandler;
|
||||
private securityProfileHandler!: handlers.SecurityProfileHandler;
|
||||
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -88,6 +90,8 @@ export class OpsServer {
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
this.apiTokenHandler = new handlers.ApiTokenHandler(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');
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ export * from './certificate.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './route-management.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) {
|
||||
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 };
|
||||
},
|
||||
),
|
||||
@@ -90,6 +90,7 @@ export class RouteManagementHandler {
|
||||
const ok = await manager.updateRoute(dataArg.id, {
|
||||
route: dataArg.route as any,
|
||||
enabled: dataArg.enabled,
|
||||
metadata: dataArg.metadata,
|
||||
});
|
||||
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';
|
||||
|
||||
// Derive IRouteSecurity from IRouteConfig since it's not directly exported
|
||||
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||
|
||||
// ============================================================================
|
||||
// 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.
|
||||
@@ -17,6 +84,7 @@ export interface IMergedRoute {
|
||||
storedRouteId?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +123,7 @@ export interface IStoredRoute {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,4 +9,6 @@ export * from './certificate.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.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 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';
|
||||
|
||||
// ============================================================================
|
||||
@@ -38,6 +38,7 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
apiToken?: string;
|
||||
route: IRouteConfig;
|
||||
enabled?: boolean;
|
||||
metadata?: IRouteMetadata;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
@@ -60,6 +61,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
id: string;
|
||||
route?: Partial<IRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
};
|
||||
response: {
|
||||
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 = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '12.1.0',
|
||||
version: '12.2.0',
|
||||
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
|
||||
const getInitialView = (): string => {
|
||||
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 view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
@@ -444,6 +444,13 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
||||
}, 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 {
|
||||
...currentState,
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -10,4 +10,6 @@ export * from './ops-view-security.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
export * from './ops-view-securityprofiles.js';
|
||||
export * from './ops-view-networktargets.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 { OpsViewRemoteIngress } from './ops-view-remoteingress.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')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -73,6 +75,16 @@ export class OpsDashboard extends DeesElement {
|
||||
iconName: 'lucide:route',
|
||||
element: OpsViewRoutes,
|
||||
},
|
||||
{
|
||||
name: 'SecurityProfiles',
|
||||
iconName: 'lucide:shieldCheck',
|
||||
element: OpsViewSecurityProfiles,
|
||||
},
|
||||
{
|
||||
name: 'NetworkTargets',
|
||||
iconName: 'lucide:server',
|
||||
element: OpsViewNetworkTargets,
|
||||
},
|
||||
{
|
||||
name: 'ApiTokens',
|
||||
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;
|
||||
|
||||
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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user