From 55699f66182e58d849dcd79e6c96ccf293c36438 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 2 Apr 2026 15:44:36 +0000 Subject: [PATCH] feat(config): add reusable security profiles and network targets with route reference resolution --- .dockerignore | 6 + changelog.md | 9 + test/test.reference-resolver.ts | 371 +++++++++++ test/test.security-profiles-api.ts | 208 +++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 20 +- ts/config/classes.db-seeder.ts | 95 +++ ts/config/classes.reference-resolver.ts | 576 ++++++++++++++++++ ts/config/classes.route-config-manager.ts | 65 +- ts/config/index.ts | 4 +- ts/db/documents/classes.network-target.doc.ts | 48 ++ .../documents/classes.security-profile.doc.ts | 49 ++ ts/db/documents/classes.stored-route.doc.ts | 4 + ts/db/documents/index.ts | 2 + ts/opsserver/classes.opsserver.ts | 4 + ts/opsserver/handlers/index.ts | 4 +- .../handlers/network-target.handler.ts | 167 +++++ .../handlers/route-management.handler.ts | 3 +- .../handlers/security-profile.handler.ts | 169 +++++ ts_interfaces/data/route-management.ts | 71 ++- ts_interfaces/requests/index.ts | 4 +- ts_interfaces/requests/network-targets.ts | 127 ++++ ts_interfaces/requests/route-management.ts | 4 +- ts_interfaces/requests/security-profiles.ts | 127 ++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 244 +++++++- ts_web/elements/index.ts | 2 + ts_web/elements/ops-dashboard.ts | 12 + ts_web/elements/ops-view-networktargets.ts | 214 +++++++ ts_web/elements/ops-view-securityprofiles.ts | 242 ++++++++ ts_web/router.ts | 2 +- 31 files changed, 2845 insertions(+), 12 deletions(-) create mode 100644 test/test.reference-resolver.ts create mode 100644 test/test.security-profiles-api.ts create mode 100644 ts/config/classes.db-seeder.ts create mode 100644 ts/config/classes.reference-resolver.ts create mode 100644 ts/db/documents/classes.network-target.doc.ts create mode 100644 ts/db/documents/classes.security-profile.doc.ts create mode 100644 ts/opsserver/handlers/network-target.handler.ts create mode 100644 ts/opsserver/handlers/security-profile.handler.ts create mode 100644 ts_interfaces/requests/network-targets.ts create mode 100644 ts_interfaces/requests/security-profiles.ts create mode 100644 ts_web/elements/ops-view-networktargets.ts create mode 100644 ts_web/elements/ops-view-securityprofiles.ts diff --git a/.dockerignore b/.dockerignore index c2658d7..b21d6a7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,7 @@ node_modules/ +.nogit/ +.git/ +.playwright-mcp/ +.vscode/ +test/ +test_watch/ diff --git a/changelog.md b/changelog.md index 1408a0a..240fb7c 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/test/test.reference-resolver.ts b/test/test.reference-resolver.ts new file mode 100644 index 0000000..5019550 --- /dev/null +++ b/test/test.reference-resolver.ts @@ -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 { + 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 { + 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 { + 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(); + 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(); + 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(); + 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(); diff --git a/test/test.security-profiles-api.ts b/test/test.security-profiles-api.ts new file mode 100644 index 0000000..ca58e65 --- /dev/null +++ b/test/test.security-profiles-api.ts @@ -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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index dbd8e41..3ae6944 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index bdec1ea..dd31ef0 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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 }), ); diff --git a/ts/config/classes.db-seeder.ts b/ts/config/classes.db-seeder.ts new file mode 100644 index 0000000..e584530 --- /dev/null +++ b/ts/config/classes.db-seeder.ts @@ -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 { + 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 = seedData?.profiles ?? DEFAULT_PROFILES; + const targetsToSeed: NonNullable = 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[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[number]> = [ + { + name: 'LOCALHOST', + description: 'Local machine on port 443', + host: '127.0.0.1', + port: 443, + }, +]; diff --git a/ts/config/classes.reference-resolver.ts b/ts/config/classes.reference-resolver.ts new file mode 100644 index 0000000..e201216 --- /dev/null +++ b/ts/config/classes.reference-resolver.ts @@ -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(); + private targets = new Map(); + + // ========================================================================= + // Lifecycle + // ========================================================================= + + public async initialize(): Promise { + await this.loadProfiles(); + await this.loadTargets(); + } + + // ========================================================================= + // Profile CRUD + // ========================================================================= + + public async createProfile(data: { + name: string; + description?: string; + security: IRouteSecurity; + extendsProfiles?: string[]; + createdBy: string; + }): Promise { + 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>, + ): 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, + ): 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): Map> { + const usage = new Map>(); + 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, + ): 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 { + 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>, + ): 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, + ): 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, + ): 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 { + const docs = await StoredRouteDoc.findAll(); + return docs + .filter((doc) => doc.metadata?.securityProfileRef === profileId) + .map((doc) => doc.id); + } + + public async findRoutesByTargetRef(targetId: string): Promise { + const docs = await StoredRouteDoc.findAll(); + return docs + .filter((doc) => doc.metadata?.networkTargetRef === targetId) + .map((doc) => doc.id); + } + + public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map): 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[] { + 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } + } + } +} diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 718f485..142ecd6 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -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(); @@ -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 { + 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 { 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; enabled?: boolean }, + patch: { + route?: Partial; + enabled?: boolean; + metadata?: Partial; + }, ): Promise { 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 { + 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 // ========================================================================= diff --git a/ts/config/index.ts b/ts/config/index.ts index ecf449f..2d9908c 100644 --- a/ts/config/index.ts +++ b/ts/config/index.ts @@ -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'; \ No newline at end of file +export { ApiTokenManager } from './classes.api-token-manager.js'; +export { ReferenceResolver } from './classes.reference-resolver.js'; +export { DbSeeder } from './classes.db-seeder.js'; \ No newline at end of file diff --git a/ts/db/documents/classes.network-target.doc.ts b/ts/db/documents/classes.network-target.doc.ts new file mode 100644 index 0000000..2596987 --- /dev/null +++ b/ts/db/documents/classes.network-target.doc.ts @@ -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 { + @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 { + return await NetworkTargetDoc.getInstance({ id }); + } + + public static async findByName(name: string): Promise { + return await NetworkTargetDoc.getInstance({ name }); + } + + public static async findAll(): Promise { + return await NetworkTargetDoc.getInstances({}); + } +} diff --git a/ts/db/documents/classes.security-profile.doc.ts b/ts/db/documents/classes.security-profile.doc.ts new file mode 100644 index 0000000..1e39b13 --- /dev/null +++ b/ts/db/documents/classes.security-profile.doc.ts @@ -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 { + @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 { + return await SecurityProfileDoc.getInstance({ id }); + } + + public static async findByName(name: string): Promise { + return await SecurityProfileDoc.getInstance({ name }); + } + + public static async findAll(): Promise { + return await SecurityProfileDoc.getInstances({}); + } +} diff --git a/ts/db/documents/classes.stored-route.doc.ts b/ts/db/documents/classes.stored-route.doc.ts index 1d0fbcc..542bcc2 100644 --- a/ts/db/documents/classes.stored-route.doc.ts +++ b/ts/db/documents/classes.stored-route.doc.ts @@ -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 { + 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( + '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( + '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( + '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( + '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( + '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( + '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 })) }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/route-management.handler.ts b/ts/opsserver/handlers/route-management.handler.ts index da7f8f1..0502cf4 100644 --- a/ts/opsserver/handlers/route-management.handler.ts +++ b/ts/opsserver/handlers/route-management.handler.ts @@ -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' }; }, diff --git a/ts/opsserver/handlers/security-profile.handler.ts b/ts/opsserver/handlers/security-profile.handler.ts new file mode 100644 index 0000000..6091243 --- /dev/null +++ b/ts/opsserver/handlers/security-profile.handler.ts @@ -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 { + 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( + '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( + '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( + '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( + '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( + '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( + '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 })) }; + }, + ), + ); + } +} diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts index f2100cc..6c6ee2e 100644 --- a/ts_interfaces/data/route-management.ts +++ b/ts_interfaces/data/route-management.ts @@ -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; + // ============================================================================ // 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; } /** diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index b024db1..88fdf0e 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -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'; \ No newline at end of file +export * from './vpn.js'; +export * from './security-profiles.js'; +export * from './network-targets.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/network-targets.ts b/ts_interfaces/requests/network-targets.ts new file mode 100644 index 0000000..92a1c7b --- /dev/null +++ b/ts_interfaces/requests/network-targets.ts @@ -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 }>; + }; +} diff --git a/ts_interfaces/requests/route-management.ts b/ts_interfaces/requests/route-management.ts index 55c3bc9..698bd65 100644 --- a/ts_interfaces/requests/route-management.ts +++ b/ts_interfaces/requests/route-management.ts @@ -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; enabled?: boolean; + metadata?: Partial; }; response: { success: boolean; diff --git a/ts_interfaces/requests/security-profiles.ts b/ts_interfaces/requests/security-profiles.ts new file mode 100644 index 0000000..279db0e --- /dev/null +++ b/ts_interfaces/requests/security-profiles.ts @@ -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 }>; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index dbd8e41..3ae6944 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index abab24c..46c0182 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -110,7 +110,7 @@ export const configStatePart = await appState.getStatePart( // 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(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( + 'profilesTargets', + { + profiles: [], + targets: [], + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft' +); + +// ============================================================================ +// Security Profiles & Network Targets Actions +// ============================================================================ + +export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction( + async (statePartArg): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 // ============================================================================ diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 7b8a92a..fab7676 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -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'; \ No newline at end of file diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 92a7f8d..1178ec5 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -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', diff --git a/ts_web/elements/ops-view-networktargets.ts b/ts_web/elements/ops-view-networktargets.ts new file mode 100644 index 0000000..94eaec4 --- /dev/null +++ b/ts_web/elements/ops-view-networktargets.ts @@ -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` +
+ + ({ + 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); + }, + }, + ]} + > +
+ `; + } + + private async showCreateTargetDialog(table: any) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Create Network Target', + content: html` + + + + + + + `, + 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` + + + + + + + `, + 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`

${currentState.error} Force delete?

`, + 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() }, + ], + }); + } + } +} diff --git a/ts_web/elements/ops-view-securityprofiles.ts b/ts_web/elements/ops-view-securityprofiles.ts new file mode 100644 index 0000000..bc77d5f --- /dev/null +++ b/ts_web/elements/ops-view-securityprofiles.ts @@ -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` +
+ + ({ + 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); + }, + }, + ]} + > +
+ `; + } + + private async showCreateProfileDialog(table: any) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Create Security Profile', + content: html` + + + + + + + + `, + 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` + + + + + + + + `, + 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`

${currentState.error} Force delete?

`, + 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() }, + ], + }); + } + } +} diff --git a/ts_web/router.ts b/ts_web/router.ts index 95bbfd9..cfaec9d 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -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];