diff --git a/changelog.md b/changelog.md index 90c7f18..754dc23 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-05 - 13.0.0 - BREAKING CHANGE(vpn) +replace tag-based VPN access control with source and target profiles + +- Renames Security Profiles to Source Profiles across APIs, persistence, route metadata, tests, and UI. +- Adds TargetProfile management, storage, API handlers, and dashboard views to define VPN-accessible domains, targets, and route references. +- Replaces route-level vpn configuration with vpnOnly and switches VPN clients from serverDefinedClientTags to targetProfileIds for access resolution. +- Updates route application and VPN AllowedIPs generation to derive client access from matching target profiles instead of tags. + ## 2026-04-04 - 12.10.0 - feat(routes) add TLS configuration controls for route create and edit flows diff --git a/test/test.reference-resolver.ts b/test/test.reference-resolver.ts index 5019550..0fa37c1 100644 --- a/test/test.reference-resolver.ts +++ b/test/test.reference-resolver.ts @@ -1,13 +1,13 @@ 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 { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js'; import type { IRouteConfig } from '@push.rocks/smartproxy'; // ============================================================================ // Helpers: access private maps for direct unit testing without DB // ============================================================================ -function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void { +function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void { (resolver as any).profiles.set(profile.id, profile); } @@ -15,7 +15,7 @@ function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void (resolver as any).targets.set(target.id, target); } -function makeProfile(overrides: Partial = {}): ISecurityProfile { +function makeProfile(overrides: Partial = {}): ISourceProfile { return { id: 'profile-1', name: 'STANDARD', @@ -72,14 +72,14 @@ tap.test('should list empty profiles and targets initially', async () => { expect(resolver.listTargets().length).toEqual(0); }); -// ---- Security profile resolution ---- +// ---- Source profile resolution ---- -tap.test('should resolve security profile onto a route', async () => { +tap.test('should resolve source profile onto a route', async () => { const profile = makeProfile(); injectProfile(resolver, profile); const route = makeRoute(); - const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' }; + const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' }; const result = resolver.resolveRoute(route, metadata); @@ -87,7 +87,7 @@ tap.test('should resolve security profile onto a route', async () => { 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.sourceProfileName).toEqual('STANDARD'); expect(result.metadata.lastResolvedAt).toBeTruthy(); }); @@ -98,7 +98,7 @@ tap.test('should merge inline route security with profile security', async () => maxConnections: 5000, }, }); - const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' }; + const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' }; const result = resolver.resolveRoute(route, metadata); @@ -117,7 +117,7 @@ tap.test('should deduplicate IP lists during merge', async () => { ipAllowList: ['192.168.0.0/16', '127.0.0.1'], }, }); - const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' }; + const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' }; const result = resolver.resolveRoute(route, metadata); @@ -128,13 +128,13 @@ tap.test('should deduplicate IP lists during merge', async () => { tap.test('should handle missing profile gracefully', async () => { const route = makeRoute(); - const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' }; + const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' }; const result = resolver.resolveRoute(route, metadata); // Route should be unchanged expect(result.route.security).toBeUndefined(); - expect(result.metadata.securityProfileName).toBeUndefined(); + expect(result.metadata.sourceProfileName).toBeUndefined(); }); // ---- Profile inheritance ---- @@ -161,7 +161,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => { injectProfile(resolver, extendedProfile); const route = makeRoute(); - const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' }; + const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' }; const result = resolver.resolveRoute(route, metadata); @@ -170,7 +170,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => { 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'); + expect(result.metadata.sourceProfileName).toEqual('EXTENDED'); }); tap.test('should detect circular profile inheritance', async () => { @@ -190,7 +190,7 @@ tap.test('should detect circular profile inheritance', async () => { injectProfile(resolver, profileB); const route = makeRoute(); - const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' }; + const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' }; // Should not infinite loop — resolves what it can const result = resolver.resolveRoute(route, metadata); @@ -232,7 +232,7 @@ tap.test('should handle missing target gracefully', async () => { tap.test('should resolve both profile and target simultaneously', async () => { const route = makeRoute(); const metadata: IRouteMetadata = { - securityProfileRef: 'profile-1', + sourceProfileRef: 'profile-1', networkTargetRef: 'target-1', }; @@ -247,7 +247,7 @@ tap.test('should resolve both profile and target simultaneously', async () => { expect(result.route.action.targets![0].port).toEqual(443); // Both names recorded - expect(result.metadata.securityProfileName).toEqual('STANDARD'); + expect(result.metadata.sourceProfileName).toEqual('STANDARD'); expect(result.metadata.networkTargetName).toEqual('INFRA'); }); @@ -268,7 +268,7 @@ tap.test('should skip resolution when no metadata refs', async () => { tap.test('should be idempotent — resolving twice gives same result', async () => { const route = makeRoute(); const metadata: IRouteMetadata = { - securityProfileRef: 'profile-1', + sourceProfileRef: 'profile-1', networkTargetRef: 'target-1', }; @@ -288,7 +288,7 @@ tap.test('should find routes by profile ref (sync)', async () => { id: 'route-a', route: makeRoute({ name: 'route-a' }), enabled: true, - metadata: { securityProfileRef: 'profile-1' }, + metadata: { sourceProfileRef: 'profile-1' }, }); storedRoutes.set('route-b', { id: 'route-b', @@ -300,7 +300,7 @@ tap.test('should find routes by profile ref (sync)', async () => { id: 'route-c', route: makeRoute({ name: 'route-c' }), enabled: true, - metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' }, + metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' }, }); const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes); @@ -320,7 +320,7 @@ tap.test('should get profile usage for a specific profile ID', async () => { id: 'route-x', route: makeRoute({ name: 'my-route' }), enabled: true, - metadata: { securityProfileRef: 'profile-1' }, + metadata: { sourceProfileRef: 'profile-1' }, }); const usage = resolver.getProfileUsageForId('profile-1', storedRoutes); diff --git a/test/test.security-profiles-api.ts b/test/test.source-profiles-api.ts similarity index 89% rename from test/test.security-profiles-api.ts rename to test/test.source-profiles-api.ts index ca58e65..b7e8bab 100644 --- a/test/test.security-profiles-api.ts +++ b/test/test.source-profiles-api.ts @@ -39,13 +39,13 @@ tap.test('should login as admin', async () => { }); // ============================================================================ -// Security Profile endpoints (graceful fallbacks when resolver unavailable) +// Source Profile endpoints (graceful fallbacks when resolver unavailable) // ============================================================================ tap.test('should return empty profiles list when resolver not initialized', async () => { - const req = new TypedRequest( + const req = new TypedRequest( TEST_URL, - 'getSecurityProfiles' + 'getSourceProfiles' ); const response = await req.fire({ @@ -57,9 +57,9 @@ tap.test('should return empty profiles list when resolver not initialized', asyn }); tap.test('should return null for single profile when resolver not initialized', async () => { - const req = new TypedRequest( + const req = new TypedRequest( TEST_URL, - 'getSecurityProfile' + 'getSourceProfile' ); const response = await req.fire({ @@ -71,9 +71,9 @@ tap.test('should return null for single profile when resolver not initialized', }); tap.test('should return failure for create profile when resolver not initialized', async () => { - const req = new TypedRequest( + const req = new TypedRequest( TEST_URL, - 'createSecurityProfile' + 'createSourceProfile' ); const response = await req.fire({ @@ -87,9 +87,9 @@ tap.test('should return failure for create profile when resolver not initialized }); tap.test('should return empty profile usage when resolver not initialized', async () => { - const req = new TypedRequest( + const req = new TypedRequest( TEST_URL, - 'getSecurityProfileUsage' + 'getSourceProfileUsage' ); const response = await req.fire({ @@ -170,9 +170,9 @@ tap.test('should return empty target usage when resolver not initialized', async // ============================================================================ tap.test('should reject unauthenticated profile requests', async () => { - const req = new TypedRequest( + const req = new TypedRequest( TEST_URL, - 'getSecurityProfiles' + 'getSourceProfiles' ); try { diff --git a/test_watch/devserver.ts b/test_watch/devserver.ts index 266cb53..649ab12 100644 --- a/test_watch/devserver.ts +++ b/test_watch/devserver.ts @@ -29,13 +29,13 @@ const devRouter = new DcRouter({ name: 'vpn-internal-app', match: { ports: [18080], domains: ['internal.example.com'] }, action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] }, - vpn: { enabled: true }, + vpnOnly: true, }, { name: 'vpn-eng-dashboard', match: { ports: [18080], domains: ['eng.example.com'] }, action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] }, - vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] }, + vpnOnly: true, }, ] as any[], }, @@ -44,9 +44,9 @@ const devRouter = new DcRouter({ enabled: true, serverEndpoint: 'vpn.dev.local', clients: [ - { clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' }, - { clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' }, - { clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' }, + { clientId: 'dev-laptop', description: 'Developer laptop' }, + { clientId: 'ci-runner', description: 'CI/CD pipeline' }, + { clientId: 'admin-desktop', description: 'Admin workstation' }, ], }, dbConfig: { enabled: true }, diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index aafa914..e8815b5 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.10.0', + version: '13.0.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 51d7d26..266dbf1 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, ReferenceResolver, DbSeeder } from './config/index.js'; +import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; @@ -180,8 +180,8 @@ export interface IDcRouterOptions { /** * VPN server configuration. - * Enables VPN-based access control: routes with vpn.enabled are only - * accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports. + * Enables VPN-based access control: routes with vpnOnly are only + * accessible from VPN clients whose TargetProfile matches. Supports WireGuard + native (WS/QUIC) transports. */ vpnConfig?: { /** Enable VPN server (default: false) */ @@ -197,7 +197,7 @@ export interface IDcRouterOptions { /** Pre-defined VPN clients created on startup */ clients?: Array<{ clientId: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; description?: string; }>; /** Destination routing policy for VPN client traffic. @@ -274,6 +274,7 @@ export class DcRouter { public routeConfigManager?: RouteConfigManager; public apiTokenManager?: ApiTokenManager; public referenceResolver?: ReferenceResolver; + public targetProfileManager?: TargetProfileManager; // Auto-discovered public IP (populated by generateAuthoritativeRecords) public detectedPublicIp: string | null = null; @@ -465,16 +466,22 @@ export class DcRouter { this.referenceResolver = new ReferenceResolver(); await this.referenceResolver.initialize(); + // Initialize target profile manager + this.targetProfileManager = new TargetProfileManager(); + await this.targetProfileManager.initialize(); + this.routeConfigManager = new RouteConfigManager( () => this.getConstructorRoutes(), () => this.smartProxy, () => this.options.http3, this.options.vpnConfig?.enabled - ? (tags?: string[]) => { - if (tags?.length && this.vpnManager) { - return this.vpnManager.getClientIpsForServerDefinedTags(tags); + ? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => { + if (!this.vpnManager || !this.targetProfileManager) { + return [this.options.vpnConfig?.subnet || '10.8.0.0/24']; } - return [this.options.vpnConfig?.subnet || '10.8.0.0/24']; + return this.targetProfileManager.getMatchingClientIps( + route, routeId, this.vpnManager.listClients(), + ); } : undefined, this.referenceResolver, @@ -504,6 +511,7 @@ export class DcRouter { this.routeConfigManager = undefined; this.apiTokenManager = undefined; this.referenceResolver = undefined; + this.targetProfileManager = undefined; }) .withRetry({ maxRetries: 2, baseDelayMs: 1000 }), ); @@ -2137,56 +2145,31 @@ export class DcRouter { bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart, bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd, onClientChanged: () => { - // Re-apply routes so tag-based ipAllowLists get updated + // Re-apply routes so profile-based ipAllowLists get updated this.routeConfigManager?.applyRoutes(); }, - getClientAllowedIPs: async (clientTags: string[]) => { + getClientAllowedIPs: async (targetProfileIds: string[]) => { const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24'; const ips = new Set([subnet]); - // Check routes for VPN-gated tag match and collect domains - const routes = this.options.smartProxyConfig?.routes || []; - const domainsToResolve = new Set(); - for (const route of routes) { - const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig; - if (!dcRoute.vpn?.enabled) continue; + if (!this.targetProfileManager) return [...ips]; - const routeTags = dcRoute.vpn.allowedServerDefinedClientTags; - if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) { - // Collect domains from this route - const domains = (route.match as any)?.domains; - if (Array.isArray(domains)) { - for (const d of domains) { - // Strip wildcard prefix for DNS resolution (*.example.com → example.com) - domainsToResolve.add(d.replace(/^\*\./, '')); - } - } - } - } + const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[]; + const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map(); - // Also scan stored/programmatic routes - const storedRoutes = this.routeConfigManager?.getStoredRoutes(); - if (storedRoutes) { - for (const [, stored] of storedRoutes) { - if (!stored.enabled) continue; - const dcRoute = stored.route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig; - if (!dcRoute.vpn?.enabled) continue; + const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec( + targetProfileIds, routes, storedRoutes, + ); - const routeTags = dcRoute.vpn.allowedServerDefinedClientTags; - if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) { - const domains = (stored.route.match as any)?.domains; - if (Array.isArray(domains)) { - for (const d of domains) { - domainsToResolve.add(d.replace(/^\*\./, '')); - } - } - } - } + // Add target IPs directly + for (const ip of targetIps) { + ips.add(`${ip}/32`); } // Resolve DNS A records for matched domains (with caching) - for (const domain of domainsToResolve) { - const resolvedIps = await this.resolveVpnDomainIPs(domain); + for (const domain of domains) { + const stripped = domain.replace(/^\*\./, ''); + const resolvedIps = await this.resolveVpnDomainIPs(stripped); for (const ip of resolvedIps) { ips.add(`${ip}/32`); } @@ -2199,7 +2182,7 @@ export class DcRouter { await this.vpnManager.start(); // Re-apply routes now that VPN clients are loaded — ensures hardcoded routes - // get correct tag-based ipAllowLists (not possible during setupSmartProxy since + // get correct profile-based ipAllowLists (not possible during setupSmartProxy since // VPN server wasn't ready yet) this.routeConfigManager?.applyRoutes(); } diff --git a/ts/config/classes.reference-resolver.ts b/ts/config/classes.reference-resolver.ts index e201216..1b06554 100644 --- a/ts/config/classes.reference-resolver.ts +++ b/ts/config/classes.reference-resolver.ts @@ -1,8 +1,8 @@ import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; -import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js'; +import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js'; import type { - ISecurityProfile, + ISourceProfile, INetworkTarget, IRouteMetadata, IStoredRoute, @@ -12,7 +12,7 @@ import type { const MAX_INHERITANCE_DEPTH = 5; export class ReferenceResolver { - private profiles = new Map(); + private profiles = new Map(); private targets = new Map(); // ========================================================================= @@ -38,7 +38,7 @@ export class ReferenceResolver { const id = plugins.uuid.v4(); const now = Date.now(); - const profile: ISecurityProfile = { + const profile: ISourceProfile = { id, name: data.name, description: data.description, @@ -51,17 +51,17 @@ export class ReferenceResolver { this.profiles.set(id, profile); await this.persistProfile(profile); - logger.log('info', `Created security profile '${profile.name}' (${id})`); + logger.log('info', `Created source profile '${profile.name}' (${id})`); return id; } public async updateProfile( id: string, - patch: Partial>, + patch: Partial>, ): Promise<{ affectedRouteIds: string[] }> { const profile = this.profiles.get(id); if (!profile) { - throw new Error(`Security profile '${id}' not found`); + throw new Error(`Source profile '${id}' not found`); } if (patch.name !== undefined) profile.name = patch.name; @@ -71,7 +71,7 @@ export class ReferenceResolver { profile.updatedAt = Date.now(); await this.persistProfile(profile); - logger.log('info', `Updated security profile '${profile.name}' (${id})`); + logger.log('info', `Updated source profile '${profile.name}' (${id})`); // Find routes referencing this profile const affectedRouteIds = await this.findRoutesByProfileRef(id); @@ -85,7 +85,7 @@ export class ReferenceResolver { ): Promise<{ success: boolean; message?: string }> { const profile = this.profiles.get(id); if (!profile) { - return { success: false, message: `Security profile '${id}' not found` }; + return { success: false, message: `Source profile '${id}' not found` }; } // Check usage @@ -101,7 +101,7 @@ export class ReferenceResolver { } // Delete from DB - const doc = await SecurityProfileDoc.findById(id); + const doc = await SourceProfileDoc.findById(id); if (doc) await doc.delete(); this.profiles.delete(id); @@ -110,24 +110,24 @@ export class ReferenceResolver { 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})`); + logger.log('info', `Deleted source profile '${profile.name}' (${id})`); } return { success: true }; } - public getProfile(id: string): ISecurityProfile | undefined { + public getProfile(id: string): ISourceProfile | undefined { return this.profiles.get(id); } - public getProfileByName(name: string): ISecurityProfile | undefined { + public getProfileByName(name: string): ISourceProfile | undefined { for (const profile of this.profiles.values()) { if (profile.name === name) return profile; } return undefined; } - public listProfiles(): ISecurityProfile[] { + public listProfiles(): ISourceProfile[] { return [...this.profiles.values()]; } @@ -137,7 +137,7 @@ export class ReferenceResolver { usage.set(profile.id, []); } for (const [routeId, stored] of storedRoutes) { - const ref = stored.metadata?.securityProfileRef; + const ref = stored.metadata?.sourceProfileRef; if (ref && usage.has(ref)) { usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId }); } @@ -151,7 +151,7 @@ export class ReferenceResolver { ): Array<{ id: string; routeName: string }> { const routes: Array<{ id: string; routeName: string }> = []; for (const [routeId, stored] of storedRoutes) { - if (stored.metadata?.securityProfileRef === profileId) { + if (stored.metadata?.sourceProfileRef === profileId) { routes.push({ id: routeId, routeName: stored.route.name || routeId }); } } @@ -280,7 +280,7 @@ export class ReferenceResolver { /** * Resolve references for a single route. - * Materializes security profile and/or network target into the route's fields. + * Materializes source profile and/or network target into the route's fields. * Returns the resolved route and updated metadata. */ public resolveRoute( @@ -289,19 +289,19 @@ export class ReferenceResolver { ): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } { const resolvedMetadata: IRouteMetadata = { ...metadata }; - if (resolvedMetadata.securityProfileRef) { - const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef); + if (resolvedMetadata.sourceProfileRef) { + const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef); if (resolvedSecurity) { - const profile = this.profiles.get(resolvedMetadata.securityProfileRef); + const profile = this.profiles.get(resolvedMetadata.sourceProfileRef); // Merge: profile provides base, route's inline values override route = { ...route, security: this.mergeSecurityFields(resolvedSecurity, route.security), }; - resolvedMetadata.securityProfileName = profile?.name; + resolvedMetadata.sourceProfileName = profile?.name; resolvedMetadata.lastResolvedAt = Date.now(); } else { - logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`); + logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`); } } @@ -335,7 +335,7 @@ export class ReferenceResolver { public async findRoutesByProfileRef(profileId: string): Promise { const docs = await StoredRouteDoc.findAll(); return docs - .filter((doc) => doc.metadata?.securityProfileRef === profileId) + .filter((doc) => doc.metadata?.sourceProfileRef === profileId) .map((doc) => doc.id); } @@ -349,7 +349,7 @@ export class ReferenceResolver { public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map): string[] { const ids: string[] = []; for (const [routeId, stored] of storedRoutes) { - if (stored.metadata?.securityProfileRef === profileId) { + if (stored.metadata?.sourceProfileRef === profileId) { ids.push(routeId); } } @@ -367,10 +367,10 @@ export class ReferenceResolver { } // ========================================================================= - // Private: security profile resolution with inheritance + // Private: source profile resolution with inheritance // ========================================================================= - private resolveSecurityProfile( + private resolveSourceProfile( profileId: string, visited: Set = new Set(), depth: number = 0, @@ -396,7 +396,7 @@ export class ReferenceResolver { // 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); + const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1); if (parentSecurity) { baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity); } @@ -453,7 +453,7 @@ export class ReferenceResolver { // ========================================================================= private async loadProfiles(): Promise { - const docs = await SecurityProfileDoc.findAll(); + const docs = await SourceProfileDoc.findAll(); for (const doc of docs) { if (doc.id) { this.profiles.set(doc.id, { @@ -469,7 +469,7 @@ export class ReferenceResolver { } } if (this.profiles.size > 0) { - logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`); + logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`); } } @@ -494,8 +494,8 @@ export class ReferenceResolver { } } - private async persistProfile(profile: ISecurityProfile): Promise { - const existingDoc = await SecurityProfileDoc.findById(profile.id); + private async persistProfile(profile: ISourceProfile): Promise { + const existingDoc = await SourceProfileDoc.findById(profile.id); if (existingDoc) { existingDoc.name = profile.name; existingDoc.description = profile.description; @@ -504,7 +504,7 @@ export class ReferenceResolver { existingDoc.updatedAt = profile.updatedAt; await existingDoc.save(); } else { - const doc = new SecurityProfileDoc(); + const doc = new SourceProfileDoc(); doc.id = profile.id; doc.name = profile.name; doc.description = profile.description; @@ -550,8 +550,8 @@ export class ReferenceResolver { if (doc?.metadata) { doc.metadata = { ...doc.metadata, - securityProfileRef: undefined, - securityProfileName: undefined, + sourceProfileRef: undefined, + sourceProfileName: 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 023fa3a..4de6eef 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -21,7 +21,7 @@ export class RouteConfigManager { private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getHttp3Config?: () => IHttp3Config | undefined, - private getVpnAllowList?: (tags?: string[]) => string[], + private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => string[], private referenceResolver?: ReferenceResolver, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, ) {} @@ -363,22 +363,19 @@ export class RouteConfigManager { const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; const http3Config = this.getHttp3Config?.(); - const vpnAllowList = this.getVpnAllowList; + const vpnCallback = this.getVpnClientIpsForRoute; - // Helper: inject VPN security into a route if vpn.enabled is set - const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => { - if (!vpnAllowList) return route; + // Helper: inject VPN security into a vpnOnly route + const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => { + if (!vpnCallback) return route; const dcRoute = route as IDcRouterRouteConfig; - if (!dcRoute.vpn?.enabled) return route; - const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags); - const mandatory = dcRoute.vpn.mandatory === true; // defaults to false + if (!dcRoute.vpnOnly) return route; + const allowList = vpnCallback(dcRoute, routeId); return { ...route, security: { ...route.security, - ipAllowList: mandatory - ? allowList - : [...(route.security?.ipAllowList || []), ...allowList], + ipAllowList: allowList, }, }; }; @@ -400,7 +397,7 @@ export class RouteConfigManager { if (http3Config?.enabled !== false) { route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); } - enabledRoutes.push(injectVpn(route)); + enabledRoutes.push(injectVpn(route, stored.id)); } } diff --git a/ts/config/classes.target-profile-manager.ts b/ts/config/classes.target-profile-manager.ts new file mode 100644 index 0000000..06b16f3 --- /dev/null +++ b/ts/config/classes.target-profile-manager.ts @@ -0,0 +1,348 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import { TargetProfileDoc, VpnClientDoc } from '../db/index.js'; +import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js'; +import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; +import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'; + +/** + * Manages TargetProfiles (target-side: what can be accessed). + * TargetProfiles define what resources a VPN client can reach: + * domains, specific IP:port targets, and/or direct route references. + */ +export class TargetProfileManager { + private profiles = new Map(); + + // ========================================================================= + // Lifecycle + // ========================================================================= + + public async initialize(): Promise { + await this.loadProfiles(); + } + + // ========================================================================= + // CRUD + // ========================================================================= + + public async createProfile(data: { + name: string; + description?: string; + domains?: string[]; + targets?: ITargetProfileTarget[]; + routeRefs?: string[]; + createdBy: string; + }): Promise { + const id = plugins.uuid.v4(); + const now = Date.now(); + + const profile: ITargetProfile = { + id, + name: data.name, + description: data.description, + domains: data.domains, + targets: data.targets, + routeRefs: data.routeRefs, + createdAt: now, + updatedAt: now, + createdBy: data.createdBy, + }; + + this.profiles.set(id, profile); + await this.persistProfile(profile); + logger.log('info', `Created target profile '${profile.name}' (${id})`); + return id; + } + + public async updateProfile( + id: string, + patch: Partial>, + ): Promise { + const profile = this.profiles.get(id); + if (!profile) { + throw new Error(`Target profile '${id}' not found`); + } + + if (patch.name !== undefined) profile.name = patch.name; + if (patch.description !== undefined) profile.description = patch.description; + if (patch.domains !== undefined) profile.domains = patch.domains; + if (patch.targets !== undefined) profile.targets = patch.targets; + if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs; + profile.updatedAt = Date.now(); + + await this.persistProfile(profile); + logger.log('info', `Updated target profile '${profile.name}' (${id})`); + } + + public async deleteProfile( + id: string, + force?: boolean, + ): Promise<{ success: boolean; message?: string }> { + const profile = this.profiles.get(id); + if (!profile) { + return { success: false, message: `Target profile '${id}' not found` }; + } + + // Check if any VPN clients reference this profile + const clients = await VpnClientDoc.findAll(); + const referencingClients = clients.filter( + (c) => c.targetProfileIds?.includes(id), + ); + + if (referencingClients.length > 0 && !force) { + return { + success: false, + message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`, + }; + } + + // Delete from DB + const doc = await TargetProfileDoc.findById(id); + if (doc) await doc.delete(); + this.profiles.delete(id); + + if (referencingClients.length > 0) { + // Remove profile ref from clients + for (const client of referencingClients) { + client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id); + client.updatedAt = Date.now(); + await client.save(); + } + logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`); + } else { + logger.log('info', `Deleted target profile '${profile.name}' (${id})`); + } + + return { success: true }; + } + + public getProfile(id: string): ITargetProfile | undefined { + return this.profiles.get(id); + } + + public listProfiles(): ITargetProfile[] { + return [...this.profiles.values()]; + } + + /** + * Get which VPN clients reference a target profile. + */ + public async getProfileUsage(profileId: string): Promise> { + const clients = await VpnClientDoc.findAll(); + return clients + .filter((c) => c.targetProfileIds?.includes(profileId)) + .map((c) => ({ clientId: c.clientId, description: c.description })); + } + + // ========================================================================= + // Core matching: route → client IPs + // ========================================================================= + + /** + * For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile + * matches the route. Returns their assigned IPs for injection into ipAllowList. + */ + public getMatchingClientIps( + route: IDcRouterRouteConfig, + routeId: string | undefined, + clients: VpnClientDoc[], + ): string[] { + const ips: string[] = []; + + for (const client of clients) { + if (!client.enabled || !client.assignedIp) continue; + if (!client.targetProfileIds?.length) continue; + + // Check if any of the client's profiles match this route + const matches = client.targetProfileIds.some((profileId) => { + const profile = this.profiles.get(profileId); + if (!profile) return false; + return this.routeMatchesProfile(route, routeId, profile); + }); + + if (matches) { + ips.push(client.assignedIp); + } + } + + return ips; + } + + /** + * For a given client (by its targetProfileIds), compute the set of + * domains and target IPs it can access. Used for WireGuard AllowedIPs. + */ + public getClientAccessSpec( + targetProfileIds: string[], + allRoutes: IDcRouterRouteConfig[], + storedRoutes: Map, + ): { domains: string[]; targetIps: string[] } { + const domains = new Set(); + const targetIps = new Set(); + + // Collect all access specifiers from assigned profiles + for (const profileId of targetProfileIds) { + const profile = this.profiles.get(profileId); + if (!profile) continue; + + // Direct domain entries + if (profile.domains?.length) { + for (const d of profile.domains) { + domains.add(d); + } + } + + // Direct target IP entries + if (profile.targets?.length) { + for (const t of profile.targets) { + targetIps.add(t.host); + } + } + + // Route references: scan constructor routes + for (const route of allRoutes) { + if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) { + const routeDomains = (route.match as any)?.domains; + if (Array.isArray(routeDomains)) { + for (const d of routeDomains) { + domains.add(d); + } + } + } + } + + // Route references: scan stored routes + for (const [storedId, stored] of storedRoutes) { + if (!stored.enabled) continue; + if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) { + const routeDomains = (stored.route.match as any)?.domains; + if (Array.isArray(routeDomains)) { + for (const d of routeDomains) { + domains.add(d); + } + } + } + } + } + + return { + domains: [...domains], + targetIps: [...targetIps], + }; + } + + // ========================================================================= + // Private: matching logic + // ========================================================================= + + /** + * Check if a route matches a profile. A profile matches if ANY condition is true: + * 1. Profile's routeRefs contains the route's name or stored route id + * 2. Profile's domains overlaps with route.match.domains (wildcard matching) + * 3. Profile's targets overlaps with route.action.targets (host + port match) + */ + private routeMatchesProfile( + route: IDcRouterRouteConfig, + routeId: string | undefined, + profile: ITargetProfile, + ): boolean { + // 1. Route reference match + if (profile.routeRefs?.length) { + if (routeId && profile.routeRefs.includes(routeId)) return true; + if (route.name && profile.routeRefs.includes(route.name)) return true; + } + + // 2. Domain match + if (profile.domains?.length) { + const routeDomains: string[] = (route.match as any)?.domains || []; + for (const profileDomain of profile.domains) { + for (const routeDomain of routeDomains) { + if (this.domainMatchesPattern(routeDomain, profileDomain)) return true; + } + } + } + + // 3. Target match (host + port) + if (profile.targets?.length) { + const routeTargets = (route.action as any)?.targets; + if (Array.isArray(routeTargets)) { + for (const profileTarget of profile.targets) { + for (const routeTarget of routeTargets) { + const routeHost = routeTarget.host; + const routePort = routeTarget.port; + if (routeHost === profileTarget.host && routePort === profileTarget.port) { + return true; + } + } + } + } + } + + return false; + } + + /** + * Check if a domain matches a pattern. + * - '*.example.com' matches 'sub.example.com', 'a.b.example.com' + * - 'example.com' matches only 'example.com' + */ + private domainMatchesPattern(domain: string, pattern: string): boolean { + if (pattern === domain) return true; + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(1); // '.example.com' + return domain.endsWith(suffix) && domain.length > suffix.length; + } + return false; + } + + // ========================================================================= + // Private: persistence + // ========================================================================= + + private async loadProfiles(): Promise { + const docs = await TargetProfileDoc.findAll(); + for (const doc of docs) { + if (doc.id) { + this.profiles.set(doc.id, { + id: doc.id, + name: doc.name, + description: doc.description, + domains: doc.domains, + targets: doc.targets, + routeRefs: doc.routeRefs, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + createdBy: doc.createdBy, + }); + } + } + if (this.profiles.size > 0) { + logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`); + } + } + + private async persistProfile(profile: ITargetProfile): Promise { + const existingDoc = await TargetProfileDoc.findById(profile.id); + if (existingDoc) { + existingDoc.name = profile.name; + existingDoc.description = profile.description; + existingDoc.domains = profile.domains; + existingDoc.targets = profile.targets; + existingDoc.routeRefs = profile.routeRefs; + existingDoc.updatedAt = profile.updatedAt; + await existingDoc.save(); + } else { + const doc = new TargetProfileDoc(); + doc.id = profile.id; + doc.name = profile.name; + doc.description = profile.description; + doc.domains = profile.domains; + doc.targets = profile.targets; + doc.routeRefs = profile.routeRefs; + doc.createdAt = profile.createdAt; + doc.updatedAt = profile.updatedAt; + doc.createdBy = profile.createdBy; + await doc.save(); + } + } +} diff --git a/ts/config/index.ts b/ts/config/index.ts index 2d9908c..d207d62 100644 --- a/ts/config/index.ts +++ b/ts/config/index.ts @@ -3,4 +3,5 @@ export * from './validator.js'; export { RouteConfigManager } from './classes.route-config-manager.js'; 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 +export { DbSeeder } from './classes.db-seeder.js'; +export { TargetProfileManager } from './classes.target-profile-manager.js'; \ No newline at end of file diff --git a/ts/db/documents/classes.security-profile.doc.ts b/ts/db/documents/classes.source-profile.doc.ts similarity index 61% rename from ts/db/documents/classes.security-profile.doc.ts rename to ts/db/documents/classes.source-profile.doc.ts index 1e39b13..4d38d37 100644 --- a/ts/db/documents/classes.security-profile.doc.ts +++ b/ts/db/documents/classes.source-profile.doc.ts @@ -5,7 +5,7 @@ import type { IRouteSecurity } from '../../../ts_interfaces/data/route-managemen const getDb = () => DcRouterDb.getInstance().getDb(); @plugins.smartdata.Collection(() => getDb()) -export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc { +export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc { @plugins.smartdata.unI() @plugins.smartdata.svDb() public id!: string; @@ -35,15 +35,15 @@ export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc { - return await SecurityProfileDoc.getInstance({ id }); + public static async findById(id: string): Promise { + return await SourceProfileDoc.getInstance({ id }); } - public static async findByName(name: string): Promise { - return await SecurityProfileDoc.getInstance({ name }); + public static async findByName(name: string): Promise { + return await SourceProfileDoc.getInstance({ name }); } - public static async findAll(): Promise { - return await SecurityProfileDoc.getInstances({}); + public static async findAll(): Promise { + return await SourceProfileDoc.getInstances({}); } } diff --git a/ts/db/documents/classes.target-profile.doc.ts b/ts/db/documents/classes.target-profile.doc.ts new file mode 100644 index 0000000..b23cc34 --- /dev/null +++ b/ts/db/documents/classes.target-profile.doc.ts @@ -0,0 +1,52 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class TargetProfileDoc 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 domains?: string[]; + + @plugins.smartdata.svDb() + public targets?: ITargetProfileTarget[]; + + @plugins.smartdata.svDb() + public routeRefs?: 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 TargetProfileDoc.getInstance({ id }); + } + + public static async findByName(name: string): Promise { + return await TargetProfileDoc.getInstance({ name }); + } + + public static async findAll(): Promise { + return await TargetProfileDoc.getInstances({}); + } +} diff --git a/ts/db/documents/classes.vpn-client.doc.ts b/ts/db/documents/classes.vpn-client.doc.ts index dcaa265..f957548 100644 --- a/ts/db/documents/classes.vpn-client.doc.ts +++ b/ts/db/documents/classes.vpn-client.doc.ts @@ -13,7 +13,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc( - 'getSecurityProfiles', + new plugins.typedrequest.TypedHandler( + 'getSourceProfiles', async (dataArg) => { - await this.requireAuth(dataArg, 'profiles:read'); + await this.requireAuth(dataArg, 'source-profiles:read'); const resolver = this.opsServerRef.dcRouterRef.referenceResolver; if (!resolver) { return { profiles: [] }; @@ -55,12 +55,12 @@ export class SecurityProfileHandler { ), ); - // Get a single security profile + // Get a single source profile this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'getSecurityProfile', + new plugins.typedrequest.TypedHandler( + 'getSourceProfile', async (dataArg) => { - await this.requireAuth(dataArg, 'profiles:read'); + await this.requireAuth(dataArg, 'source-profiles:read'); const resolver = this.opsServerRef.dcRouterRef.referenceResolver; if (!resolver) { return { profile: null }; @@ -70,12 +70,12 @@ export class SecurityProfileHandler { ), ); - // Create a security profile + // Create a source profile this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'createSecurityProfile', + new plugins.typedrequest.TypedHandler( + 'createSourceProfile', async (dataArg) => { - const userId = await this.requireAuth(dataArg, 'profiles:write'); + const userId = await this.requireAuth(dataArg, 'source-profiles:write'); const resolver = this.opsServerRef.dcRouterRef.referenceResolver; if (!resolver) { return { success: false, message: 'Reference resolver not initialized' }; @@ -92,12 +92,12 @@ export class SecurityProfileHandler { ), ); - // Update a security profile + // Update a source profile this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'updateSecurityProfile', + new plugins.typedrequest.TypedHandler( + 'updateSourceProfile', async (dataArg) => { - await this.requireAuth(dataArg, 'profiles:write'); + await this.requireAuth(dataArg, 'source-profiles:write'); const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const manager = this.opsServerRef.dcRouterRef.routeConfigManager; if (!resolver || !manager) { @@ -121,12 +121,12 @@ export class SecurityProfileHandler { ), ); - // Delete a security profile + // Delete a source profile this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'deleteSecurityProfile', + new plugins.typedrequest.TypedHandler( + 'deleteSourceProfile', async (dataArg) => { - await this.requireAuth(dataArg, 'profiles:write'); + await this.requireAuth(dataArg, 'source-profiles:write'); const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const manager = this.opsServerRef.dcRouterRef.routeConfigManager; if (!resolver || !manager) { @@ -149,12 +149,12 @@ export class SecurityProfileHandler { ), ); - // Get routes using a security profile + // Get routes using a source profile this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'getSecurityProfileUsage', + new plugins.typedrequest.TypedHandler( + 'getSourceProfileUsage', async (dataArg) => { - await this.requireAuth(dataArg, 'profiles:read'); + await this.requireAuth(dataArg, 'source-profiles:read'); const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const manager = this.opsServerRef.dcRouterRef.routeConfigManager; if (!resolver || !manager) { diff --git a/ts/opsserver/handlers/target-profile.handler.ts b/ts/opsserver/handlers/target-profile.handler.ts new file mode 100644 index 0000000..f0872f8 --- /dev/null +++ b/ts/opsserver/handlers/target-profile.handler.ts @@ -0,0 +1,155 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class TargetProfileHandler { + 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 target profiles + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getTargetProfiles', + async (dataArg) => { + await this.requireAuth(dataArg, 'target-profiles:read'); + const manager = this.opsServerRef.dcRouterRef.targetProfileManager; + if (!manager) { + return { profiles: [] }; + } + return { profiles: manager.listProfiles() }; + }, + ), + ); + + // Get a single target profile + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getTargetProfile', + async (dataArg) => { + await this.requireAuth(dataArg, 'target-profiles:read'); + const manager = this.opsServerRef.dcRouterRef.targetProfileManager; + if (!manager) { + return { profile: null }; + } + return { profile: manager.getProfile(dataArg.id) || null }; + }, + ), + ); + + // Create a target profile + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createTargetProfile', + async (dataArg) => { + const userId = await this.requireAuth(dataArg, 'target-profiles:write'); + const manager = this.opsServerRef.dcRouterRef.targetProfileManager; + if (!manager) { + return { success: false, message: 'Target profile manager not initialized' }; + } + const id = await manager.createProfile({ + name: dataArg.name, + description: dataArg.description, + domains: dataArg.domains, + targets: dataArg.targets, + routeRefs: dataArg.routeRefs, + createdBy: userId, + }); + return { success: true, id }; + }, + ), + ); + + // Update a target profile + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateTargetProfile', + async (dataArg) => { + await this.requireAuth(dataArg, 'target-profiles:write'); + const manager = this.opsServerRef.dcRouterRef.targetProfileManager; + if (!manager) { + return { success: false, message: 'Not initialized' }; + } + await manager.updateProfile(dataArg.id, { + name: dataArg.name, + description: dataArg.description, + domains: dataArg.domains, + targets: dataArg.targets, + routeRefs: dataArg.routeRefs, + }); + // Re-apply routes to update VPN access + this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); + return { success: true }; + }, + ), + ); + + // Delete a target profile + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteTargetProfile', + async (dataArg) => { + await this.requireAuth(dataArg, 'target-profiles:write'); + const manager = this.opsServerRef.dcRouterRef.targetProfileManager; + if (!manager) { + return { success: false, message: 'Not initialized' }; + } + const result = await manager.deleteProfile(dataArg.id, dataArg.force); + if (result.success) { + // Re-apply routes to update VPN access + this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); + } + return result; + }, + ), + ); + + // Get VPN clients using a target profile + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getTargetProfileUsage', + async (dataArg) => { + await this.requireAuth(dataArg, 'target-profiles:read'); + const manager = this.opsServerRef.dcRouterRef.targetProfileManager; + if (!manager) { + return { clients: [] }; + } + return { clients: await manager.getProfileUsage(dataArg.id) }; + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/vpn.handler.ts b/ts/opsserver/handlers/vpn.handler.ts index 2065d4b..1fba457 100644 --- a/ts/opsserver/handlers/vpn.handler.ts +++ b/ts/opsserver/handlers/vpn.handler.ts @@ -25,7 +25,7 @@ export class VpnHandler { const clients = manager.listClients().map((c) => ({ clientId: c.clientId, enabled: c.enabled, - serverDefinedClientTags: c.serverDefinedClientTags, + targetProfileIds: c.targetProfileIds, description: c.description, assignedIp: c.assignedIp, createdAt: c.createdAt, @@ -120,7 +120,7 @@ export class VpnHandler { try { const bundle = await manager.createClient({ clientId: dataArg.clientId, - serverDefinedClientTags: dataArg.serverDefinedClientTags, + targetProfileIds: dataArg.targetProfileIds, description: dataArg.description, forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy, destinationAllowList: dataArg.destinationAllowList, @@ -142,7 +142,7 @@ export class VpnHandler { client: { clientId: bundle.entry.clientId, enabled: bundle.entry.enabled ?? true, - serverDefinedClientTags: bundle.entry.serverDefinedClientTags, + targetProfileIds: persistedClient?.targetProfileIds, description: bundle.entry.description, assignedIp: bundle.entry.assignedIp, createdAt: Date.now(), @@ -179,7 +179,7 @@ export class VpnHandler { try { await manager.updateClient(dataArg.clientId, { description: dataArg.description, - serverDefinedClientTags: dataArg.serverDefinedClientTags, + targetProfileIds: dataArg.targetProfileIds, forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy, destinationAllowList: dataArg.destinationAllowList, destinationBlockList: dataArg.destinationBlockList, diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index 17ec664..d112bae 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -14,7 +14,7 @@ export interface IVpnManagerConfig { /** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */ initialClients?: Array<{ clientId: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; description?: string; }>; /** Called when clients are created/deleted/toggled — triggers route re-application */ @@ -26,10 +26,10 @@ export interface IVpnManagerConfig { allowList?: string[]; blockList?: string[]; }; - /** Compute per-client AllowedIPs based on the client's server-defined tags. + /** Compute per-client AllowedIPs based on the client's target profile IDs. * Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs. * When not set, defaults to [subnet]. */ - getClientAllowedIPs?: (clientTags: string[]) => Promise; + getClientAllowedIPs?: (targetProfileIds: string[]) => Promise; /** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN), * or 'hybrid' (socket default, bridge for clients with useHostIp=true) */ forwardingMode?: 'socket' | 'bridge' | 'hybrid'; @@ -90,7 +90,6 @@ export class VpnManager { publicKey: client.noisePublicKey, wgPublicKey: client.wgPublicKey, enabled: client.enabled, - serverDefinedClientTags: client.serverDefinedClientTags, description: client.description, assignedIp: client.assignedIp, expiresAt: client.expiresAt, @@ -163,7 +162,7 @@ export class VpnManager { if (!this.clients.has(initial.clientId)) { const bundle = await this.createClient({ clientId: initial.clientId, - serverDefinedClientTags: initial.serverDefinedClientTags, + targetProfileIds: initial.targetProfileIds, description: initial.description, }); logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`); @@ -197,7 +196,7 @@ export class VpnManager { */ public async createClient(opts: { clientId: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; description?: string; forceDestinationSmartproxy?: boolean; destinationAllowList?: string[]; @@ -214,13 +213,12 @@ export class VpnManager { const bundle = await this.vpnServer.createClient({ clientId: opts.clientId, - serverDefinedClientTags: opts.serverDefinedClientTags, description: opts.description, }); - // Override AllowedIPs with per-client values based on tag-matched routes + // Override AllowedIPs with per-client values based on target profiles if (this.config.getClientAllowedIPs && bundle.wireguardConfig) { - const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []); + const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []); bundle.wireguardConfig = bundle.wireguardConfig.replace( /AllowedIPs\s*=\s*.+/, `AllowedIPs = ${allowedIPs.join(', ')}`, @@ -231,7 +229,7 @@ export class VpnManager { const doc = new VpnClientDoc(); doc.clientId = bundle.entry.clientId; doc.enabled = bundle.entry.enabled ?? true; - doc.serverDefinedClientTags = bundle.entry.serverDefinedClientTags; + doc.targetProfileIds = opts.targetProfileIds; doc.description = bundle.entry.description; doc.assignedIp = bundle.entry.assignedIp; doc.noisePublicKey = bundle.entry.publicKey; @@ -332,11 +330,11 @@ export class VpnManager { } /** - * Update a client's metadata (description, tags) without rotating keys. + * Update a client's metadata (description, target profiles) without rotating keys. */ public async updateClient(clientId: string, update: { description?: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; forceDestinationSmartproxy?: boolean; destinationAllowList?: string[]; destinationBlockList?: string[]; @@ -349,7 +347,7 @@ export class VpnManager { const client = this.clients.get(clientId); if (!client) throw new Error(`Client not found: ${clientId}`); if (update.description !== undefined) client.description = update.description; - if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags; + if (update.targetProfileIds !== undefined) client.targetProfileIds = update.targetProfileIds; if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy; if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList; if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList; @@ -409,10 +407,10 @@ export class VpnManager { ); } - // Override AllowedIPs with per-client values based on tag-matched routes + // Override AllowedIPs with per-client values based on target profiles if (this.config.getClientAllowedIPs) { - const clientTags = persisted?.serverDefinedClientTags || []; - const allowedIPs = await this.config.getClientAllowedIPs(clientTags); + const profileIds = persisted?.targetProfileIds || []; + const allowedIPs = await this.config.getClientAllowedIPs(profileIds); config = config.replace( /AllowedIPs\s*=\s*.+/, `AllowedIPs = ${allowedIPs.join(', ')}`, @@ -423,22 +421,6 @@ export class VpnManager { return config; } - // ── Tag-based access control ─────────────────────────────────────────── - - /** - * Get assigned IPs for all enabled clients matching any of the given server-defined tags. - */ - public getClientIpsForServerDefinedTags(tags: string[]): string[] { - const ips: string[] = []; - for (const client of this.clients.values()) { - if (!client.enabled || !client.assignedIp) continue; - if (client.serverDefinedClientTags?.some(t => tags.includes(t))) { - ips.push(client.assignedIp); - } - } - return ips; - } - // ── Status and telemetry ─────────────────────────────────────────────── /** @@ -548,12 +530,6 @@ export class VpnManager { private async loadPersistedClients(): Promise { const docs = await VpnClientDoc.findAll(); for (const doc of docs) { - // Migrate legacy `tags` → `serverDefinedClientTags` - if (!doc.serverDefinedClientTags && (doc as any).tags) { - doc.serverDefinedClientTags = (doc as any).tags; - (doc as any).tags = undefined; - await doc.save(); - } this.clients.set(doc.clientId, doc); } if (this.clients.size > 0) { diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 5ca5c34..9bab981 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -2,4 +2,5 @@ export * from './auth.js'; export * from './stats.js'; export * from './remoteingress.js'; export * from './route-management.js'; +export * from './target-profile.js'; export * from './vpn.js'; \ No newline at end of file diff --git a/ts_interfaces/data/remoteingress.ts b/ts_interfaces/data/remoteingress.ts index 5606c27..3de8bf9 100644 --- a/ts_interfaces/data/remoteingress.ts +++ b/ts_interfaces/data/remoteingress.ts @@ -51,26 +51,14 @@ export interface IRouteRemoteIngress { edgeFilter?: string[]; } -/** - * Route-level VPN access configuration. - * When attached to a route, controls VPN client access. - */ -export interface IRouteVpn { - /** Enable VPN client access for this route */ - enabled: boolean; - /** When true (default), ONLY VPN clients can access this route (replaces ipAllowList). - * When false, VPN client IPs are added alongside the existing allowlist. */ - mandatory?: boolean; - /** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */ - allowedServerDefinedClientTags?: string[]; -} - /** * Extended route config used within dcrouter. - * Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig. + * Adds optional `remoteIngress` and `vpnOnly` properties to SmartProxy's IRouteConfig. * SmartProxy ignores unknown properties at runtime. */ export type IDcRouterRouteConfig = IRouteConfig & { remoteIngress?: IRouteRemoteIngress; - vpn?: IRouteVpn; + /** When true, only VPN clients whose TargetProfile matches this route get access. + * Matching is determined by domain overlap, target overlap, or direct routeRef. */ + vpnOnly?: boolean; }; diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts index 059b533..d49bacc 100644 --- a/ts_interfaces/data/route-management.ts +++ b/ts_interfaces/data/route-management.ts @@ -12,18 +12,22 @@ export type TApiTokenScope = | 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage' - | 'profiles:read' | 'profiles:write' + | 'source-profiles:read' | 'source-profiles:write' + | 'target-profiles:read' | 'target-profiles:write' | 'targets:read' | 'targets:write'; // ============================================================================ -// Security Profile Types +// Source Profile Types (source-side: who can access) // ============================================================================ /** - * A reusable, named security profile that can be referenced by routes. + * A reusable, named source profile that can be referenced by routes. * Stores the full IRouteSecurity shape from SmartProxy. + * + * SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth) + * TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs) */ -export interface ISecurityProfile { +export interface ISourceProfile { id: string; name: string; description?: string; @@ -62,12 +66,12 @@ export interface INetworkTarget { * 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 SourceProfileDoc used to resolve this route's security. */ + sourceProfileRef?: 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; + sourceProfileName?: string; /** Snapshot of the target name at resolution time, for display. */ networkTargetName?: string; /** Timestamp of last reference resolution. */ diff --git a/ts_interfaces/data/target-profile.ts b/ts_interfaces/data/target-profile.ts new file mode 100644 index 0000000..6427f54 --- /dev/null +++ b/ts_interfaces/data/target-profile.ts @@ -0,0 +1,29 @@ +/** + * A specific IP:port target within a TargetProfile. + */ +export interface ITargetProfileTarget { + host: string; + port: number; +} + +/** + * A reusable, named target profile that defines what resources a VPN client can reach. + * Assigned to VPN clients via targetProfileIds. + * + * SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth) + * TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs) + */ +export interface ITargetProfile { + id: string; + name: string; + description?: string; + /** Domain patterns this profile grants access to (supports wildcards: '*.example.com') */ + domains?: string[]; + /** Specific IP:port targets this profile grants access to */ + targets?: ITargetProfileTarget[]; + /** Route references by stored route ID or route name */ + routeRefs?: string[]; + createdAt: number; + updatedAt: number; + createdBy: string; +} diff --git a/ts_interfaces/data/vpn.ts b/ts_interfaces/data/vpn.ts index 0b8886e..3910d89 100644 --- a/ts_interfaces/data/vpn.ts +++ b/ts_interfaces/data/vpn.ts @@ -4,7 +4,8 @@ export interface IVpnClient { clientId: string; enabled: boolean; - serverDefinedClientTags?: string[]; + /** IDs of TargetProfiles assigned to this client */ + targetProfileIds?: string[]; description?: string; assignedIp?: string; createdAt: number; diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index 88fdf0e..a30df98 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -10,5 +10,6 @@ export * from './remoteingress.js'; export * from './route-management.js'; export * from './api-tokens.js'; export * from './vpn.js'; -export * from './security-profiles.js'; +export * from './source-profiles.js'; +export * from './target-profiles.js'; export * from './network-targets.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/security-profiles.ts b/ts_interfaces/requests/source-profiles.ts similarity index 58% rename from ts_interfaces/requests/security-profiles.ts rename to ts_interfaces/requests/source-profiles.ts index 279db0e..47b2453 100644 --- a/ts_interfaces/requests/security-profiles.ts +++ b/ts_interfaces/requests/source-profiles.ts @@ -1,54 +1,54 @@ import * as plugins from '../plugins.js'; import type * as authInterfaces from '../data/auth.js'; -import type { ISecurityProfile, IRouteSecurity } from '../data/route-management.js'; +import type { ISourceProfile, IRouteSecurity } from '../data/route-management.js'; // ============================================================================ -// Security Profile Endpoints +// Source Profile Endpoints (source-side: who can access) // ============================================================================ /** - * Get all security profiles. + * Get all source profiles. */ -export interface IReq_GetSecurityProfiles extends plugins.typedrequestInterfaces.implementsTR< +export interface IReq_GetSourceProfiles extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, - IReq_GetSecurityProfiles + IReq_GetSourceProfiles > { - method: 'getSecurityProfiles'; + method: 'getSourceProfiles'; request: { identity?: authInterfaces.IIdentity; apiToken?: string; }; response: { - profiles: ISecurityProfile[]; + profiles: ISourceProfile[]; }; } /** - * Get a single security profile by ID. + * Get a single source profile by ID. */ -export interface IReq_GetSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< +export interface IReq_GetSourceProfile extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, - IReq_GetSecurityProfile + IReq_GetSourceProfile > { - method: 'getSecurityProfile'; + method: 'getSourceProfile'; request: { identity?: authInterfaces.IIdentity; apiToken?: string; id: string; }; response: { - profile: ISecurityProfile | null; + profile: ISourceProfile | null; }; } /** - * Create a new security profile. + * Create a new source profile. */ -export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< +export interface IReq_CreateSourceProfile extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, - IReq_CreateSecurityProfile + IReq_CreateSourceProfile > { - method: 'createSecurityProfile'; + method: 'createSourceProfile'; request: { identity?: authInterfaces.IIdentity; apiToken?: string; @@ -65,13 +65,13 @@ export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfac } /** - * Update a security profile. + * Update a source profile. */ -export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< +export interface IReq_UpdateSourceProfile extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, - IReq_UpdateSecurityProfile + IReq_UpdateSourceProfile > { - method: 'updateSecurityProfile'; + method: 'updateSourceProfile'; request: { identity?: authInterfaces.IIdentity; apiToken?: string; @@ -89,13 +89,13 @@ export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfac } /** - * Delete a security profile. + * Delete a source profile. */ -export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< +export interface IReq_DeleteSourceProfile extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, - IReq_DeleteSecurityProfile + IReq_DeleteSourceProfile > { - method: 'deleteSecurityProfile'; + method: 'deleteSourceProfile'; request: { identity?: authInterfaces.IIdentity; apiToken?: string; @@ -109,13 +109,13 @@ export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfac } /** - * Get which routes reference a security profile. + * Get which routes reference a source profile. */ -export interface IReq_GetSecurityProfileUsage extends plugins.typedrequestInterfaces.implementsTR< +export interface IReq_GetSourceProfileUsage extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, - IReq_GetSecurityProfileUsage + IReq_GetSourceProfileUsage > { - method: 'getSecurityProfileUsage'; + method: 'getSourceProfileUsage'; request: { identity?: authInterfaces.IIdentity; apiToken?: string; diff --git a/ts_interfaces/requests/target-profiles.ts b/ts_interfaces/requests/target-profiles.ts new file mode 100644 index 0000000..81e1c31 --- /dev/null +++ b/ts_interfaces/requests/target-profiles.ts @@ -0,0 +1,128 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { ITargetProfile, ITargetProfileTarget } from '../data/target-profile.js'; + +// ============================================================================ +// Target Profile Endpoints (target-side: what can be accessed) +// ============================================================================ + +/** + * Get all target profiles. + */ +export interface IReq_GetTargetProfiles extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetTargetProfiles +> { + method: 'getTargetProfiles'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + profiles: ITargetProfile[]; + }; +} + +/** + * Get a single target profile by ID. + */ +export interface IReq_GetTargetProfile extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetTargetProfile +> { + method: 'getTargetProfile'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + profile: ITargetProfile | null; + }; +} + +/** + * Create a new target profile. + */ +export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateTargetProfile +> { + method: 'createTargetProfile'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + name: string; + description?: string; + domains?: string[]; + targets?: ITargetProfileTarget[]; + routeRefs?: string[]; + }; + response: { + success: boolean; + id?: string; + message?: string; + }; +} + +/** + * Update a target profile. + */ +export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateTargetProfile +> { + method: 'updateTargetProfile'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + name?: string; + description?: string; + domains?: string[]; + targets?: ITargetProfileTarget[]; + routeRefs?: string[]; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Delete a target profile. + */ +export interface IReq_DeleteTargetProfile extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteTargetProfile +> { + method: 'deleteTargetProfile'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + force?: boolean; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Get which VPN clients reference a target profile. + */ +export interface IReq_GetTargetProfileUsage extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetTargetProfileUsage +> { + method: 'getTargetProfileUsage'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + }; + response: { + clients: Array<{ clientId: string; description?: string }>; + }; +} diff --git a/ts_interfaces/requests/vpn.ts b/ts_interfaces/requests/vpn.ts index 3a18e40..489e280 100644 --- a/ts_interfaces/requests/vpn.ts +++ b/ts_interfaces/requests/vpn.ts @@ -49,7 +49,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp request: { identity: authInterfaces.IIdentity; clientId: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; description?: string; forceDestinationSmartproxy?: boolean; destinationAllowList?: string[]; @@ -81,7 +81,7 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp identity: authInterfaces.IIdentity; clientId: string; description?: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; forceDestinationSmartproxy?: boolean; destinationAllowList?: string[]; destinationBlockList?: string[]; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index aafa914..e8815b5 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.10.0', + version: '13.0.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 f096a07..2123613 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -116,7 +116,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', 'securityprofiles', 'networktargets']; + const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'sourceprofiles', 'networktargets', 'targetprofiles']; const segments = path.split('/').filter(Boolean); const view = segments[0]; return validViews.includes(view) ? view : 'overview'; @@ -459,12 +459,19 @@ export const setActiveViewAction = uiStatePart.createAction(async (state } // If switching to security profiles or network targets views, fetch profiles/targets data - if ((viewName === 'securityprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) { + if ((viewName === 'sourceprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) { setTimeout(() => { profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null); }, 100); } + // If switching to target profiles view, fetch target profiles data + if (viewName === 'targetprofiles' && currentState.activeView !== viewName) { + setTimeout(() => { + targetProfilesStatePart.dispatchAction(fetchTargetProfilesAction, null); + }, 100); + } + return { ...currentState, activeView: viewName, @@ -1006,7 +1013,7 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr export const createVpnClientAction = vpnStatePart.createAction<{ clientId: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; description?: string; forceDestinationSmartproxy?: boolean; destinationAllowList?: string[]; @@ -1028,7 +1035,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{ const response = await request.fire({ identity: context.identity!, clientId: dataArg.clientId, - serverDefinedClientTags: dataArg.serverDefinedClientTags, + targetProfileIds: dataArg.targetProfileIds, description: dataArg.description, forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy, destinationAllowList: dataArg.destinationAllowList, @@ -1105,7 +1112,7 @@ export const toggleVpnClientAction = vpnStatePart.createAction<{ export const updateVpnClientAction = vpnStatePart.createAction<{ clientId: string; description?: string; - serverDefinedClientTags?: string[]; + targetProfileIds?: string[]; forceDestinationSmartproxy?: boolean; destinationAllowList?: string[]; destinationBlockList?: string[]; @@ -1127,7 +1134,7 @@ export const updateVpnClientAction = vpnStatePart.createAction<{ identity: context.identity!, clientId: dataArg.clientId, description: dataArg.description, - serverDefinedClientTags: dataArg.serverDefinedClientTags, + targetProfileIds: dataArg.targetProfileIds, forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy, destinationAllowList: dataArg.destinationAllowList, destinationBlockList: dataArg.destinationBlockList, @@ -1158,11 +1165,167 @@ export const clearNewClientConfigAction = vpnStatePart.createAction( ); // ============================================================================ -// Security Profiles & Network Targets State +// Target Profiles State +// ============================================================================ + +export interface ITargetProfilesState { + profiles: interfaces.data.ITargetProfile[]; + isLoading: boolean; + error: string | null; + lastUpdated: number; +} + +export const targetProfilesStatePart = await appState.getStatePart( + 'targetProfiles', + { + profiles: [], + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft' +); + +// ============================================================================ +// Target Profiles Actions +// ============================================================================ + +export const fetchTargetProfilesAction = targetProfilesStatePart.createAction( + async (statePartArg): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetTargetProfiles + >('/typedrequest', 'getTargetProfiles'); + + const response = await request.fire({ identity: context.identity }); + + return { + profiles: response.profiles, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch target profiles', + }; + } + } +); + +export const createTargetProfileAction = targetProfilesStatePart.createAction<{ + name: string; + description?: string; + domains?: string[]; + targets?: Array<{ host: string; port: number }>; + routeRefs?: string[]; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateTargetProfile + >('/typedrequest', 'createTargetProfile'); + const response = await request.fire({ + identity: context.identity!, + name: dataArg.name, + description: dataArg.description, + domains: dataArg.domains, + targets: dataArg.targets, + routeRefs: dataArg.routeRefs, + }); + if (!response.success) { + return { + ...statePartArg.getState()!, + error: response.message || 'Failed to create target profile', + }; + } + return await actionContext!.dispatch(fetchTargetProfilesAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to create target profile', + }; + } +}); + +export const updateTargetProfileAction = targetProfilesStatePart.createAction<{ + id: string; + name?: string; + description?: string; + domains?: string[]; + targets?: Array<{ host: string; port: number }>; + routeRefs?: string[]; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateTargetProfile + >('/typedrequest', 'updateTargetProfile'); + const response = await request.fire({ + identity: context.identity!, + id: dataArg.id, + name: dataArg.name, + description: dataArg.description, + domains: dataArg.domains, + targets: dataArg.targets, + routeRefs: dataArg.routeRefs, + }); + if (!response.success) { + return { + ...statePartArg.getState()!, + error: response.message || 'Failed to update target profile', + }; + } + return await actionContext!.dispatch(fetchTargetProfilesAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to update target profile', + }; + } +}); + +export const deleteTargetProfileAction = targetProfilesStatePart.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_DeleteTargetProfile + >('/typedrequest', 'deleteTargetProfile'); + 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 profile', + }; + } + return await actionContext!.dispatch(fetchTargetProfilesAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to delete target profile', + }; + } +}); + +// ============================================================================ +// Source Profiles & Network Targets State // ============================================================================ export interface IProfilesTargetsState { - profiles: interfaces.data.ISecurityProfile[]; + profiles: interfaces.data.ISourceProfile[]; targets: interfaces.data.INetworkTarget[]; isLoading: boolean; error: string | null; @@ -1182,7 +1345,7 @@ export const profilesTargetsStatePart = await appState.getStatePart('/typedrequest', 'getSecurityProfiles'); + interfaces.requests.IReq_GetSourceProfiles + >('/typedrequest', 'getSourceProfiles'); const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetNetworkTargets @@ -1231,8 +1394,8 @@ export const createProfileAction = profilesTargetsStatePart.createAction<{ const context = getActionContext(); try { const request = new plugins.domtools.plugins.typedrequest.TypedRequest< - interfaces.requests.IReq_CreateSecurityProfile - >('/typedrequest', 'createSecurityProfile'); + interfaces.requests.IReq_CreateSourceProfile + >('/typedrequest', 'createSourceProfile'); await request.fire({ identity: context.identity!, name: dataArg.name, @@ -1259,8 +1422,8 @@ export const updateProfileAction = profilesTargetsStatePart.createAction<{ const context = getActionContext(); try { const request = new plugins.domtools.plugins.typedrequest.TypedRequest< - interfaces.requests.IReq_UpdateSecurityProfile - >('/typedrequest', 'updateSecurityProfile'); + interfaces.requests.IReq_UpdateSourceProfile + >('/typedrequest', 'updateSourceProfile'); await request.fire({ identity: context.identity!, id: dataArg.id, @@ -1285,8 +1448,8 @@ export const deleteProfileAction = profilesTargetsStatePart.createAction<{ const context = getActionContext(); try { const request = new plugins.domtools.plugins.typedrequest.TypedRequest< - interfaces.requests.IReq_DeleteSecurityProfile - >('/typedrequest', 'deleteSecurityProfile'); + interfaces.requests.IReq_DeleteSourceProfile + >('/typedrequest', 'deleteSourceProfile'); const response = await request.fire({ identity: context.identity!, id: dataArg.id, diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index fab7676..f2b6b5a 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -10,6 +10,7 @@ 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-sourceprofiles.js'; export * from './ops-view-networktargets.js'; +export * from './ops-view-targetprofiles.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 79f7347..bd09c6e 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -24,8 +24,9 @@ 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 { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js'; import { OpsViewNetworkTargets } from './ops-view-networktargets.js'; +import { OpsViewTargetProfiles } from './ops-view-targetprofiles.js'; @customElement('ops-dashboard') export class OpsDashboard extends DeesElement { @@ -81,15 +82,20 @@ export class OpsDashboard extends DeesElement { element: OpsViewRoutes, }, { - name: 'SecurityProfiles', + name: 'SourceProfiles', iconName: 'lucide:shieldCheck', - element: OpsViewSecurityProfiles, + element: OpsViewSourceProfiles, }, { name: 'NetworkTargets', iconName: 'lucide:server', element: OpsViewNetworkTargets, }, + { + name: 'TargetProfiles', + iconName: 'lucide:target', + element: OpsViewTargetProfiles, + }, { name: 'ApiTokens', iconName: 'lucide:key', diff --git a/ts_web/elements/ops-view-routes.ts b/ts_web/elements/ops-view-routes.ts index 4b9f608..746cc06 100644 --- a/ts_web/elements/ops-view-routes.ts +++ b/ts_web/elements/ops-view-routes.ts @@ -337,7 +337,7 @@ export class OpsViewRoutes extends DeesElement {

Source: programmatic

Status: ${merged.enabled ? 'Enabled' : 'Disabled'}

ID: ${merged.storedRouteId}

- ${meta?.securityProfileName ? html`

Security Profile: ${meta.securityProfileName}

` : ''} + ${meta?.sourceProfileName ? html`

Source Profile: ${meta.sourceProfileName}

` : ''} ${meta?.networkTargetName ? html`

Network Target: ${meta.networkTargetName}

` : ''} `, @@ -476,7 +476,7 @@ export class OpsViewRoutes extends DeesElement { - o.key === (merged.metadata?.securityProfileRef || '')) || null}> + o.key === (merged.metadata?.sourceProfileRef || '')) || null}> o.key === (merged.metadata?.networkTargetRef || '')) || null}> @@ -549,10 +549,10 @@ export class OpsViewRoutes extends DeesElement { } const metadata: any = {}; - const profileRefValue = formData.securityProfileRef as any; + const profileRefValue = formData.sourceProfileRef as any; const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key; if (profileKey) { - metadata.securityProfileRef = profileKey; + metadata.sourceProfileRef = profileKey; } const targetRefValue = formData.networkTargetRef as any; const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key; @@ -610,7 +610,7 @@ export class OpsViewRoutes extends DeesElement { - + @@ -682,10 +682,10 @@ export class OpsViewRoutes extends DeesElement { // Build metadata if profile/target selected const metadata: any = {}; - const profileRefValue = formData.securityProfileRef as any; + const profileRefValue = formData.sourceProfileRef as any; const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key; if (profileKey) { - metadata.securityProfileRef = profileKey; + metadata.sourceProfileRef = profileKey; } const targetRefValue = formData.networkTargetRef as any; const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key; diff --git a/ts_web/elements/ops-view-securityprofiles.ts b/ts_web/elements/ops-view-sourceprofiles.ts similarity index 93% rename from ts_web/elements/ops-view-securityprofiles.ts rename to ts_web/elements/ops-view-sourceprofiles.ts index 0fede12..aff6830 100644 --- a/ts_web/elements/ops-view-securityprofiles.ts +++ b/ts_web/elements/ops-view-sourceprofiles.ts @@ -14,12 +14,12 @@ import { type IStatsTile } from '@design.estate/dees-catalog'; declare global { interface HTMLElementTagNameMap { - 'ops-view-securityprofiles': OpsViewSecurityProfiles; + 'ops-view-sourceprofiles': OpsViewSourceProfiles; } } -@customElement('ops-view-securityprofiles') -export class OpsViewSecurityProfiles extends DeesElement { +@customElement('ops-view-sourceprofiles') +export class OpsViewSourceProfiles extends DeesElement { @state() accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!; @@ -58,20 +58,20 @@ export class OpsViewSecurityProfiles extends DeesElement { type: 'number', value: profiles.length, icon: 'lucide:shieldCheck', - description: 'Reusable security profiles', + description: 'Reusable source profiles', color: '#3b82f6', }, ]; return html` - Security Profiles + Source Profiles
({ + .displayFunction=${(profile: interfaces.data.ISourceProfile) => ({ Name: profile.name, Description: profile.description || '-', 'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-', @@ -107,7 +107,7 @@ export class OpsViewSecurityProfiles extends DeesElement { iconName: 'lucide:pencil', type: ['inRow', 'contextmenu'] as any, actionFunc: async (actionData: any) => { - const profile = actionData.item as interfaces.data.ISecurityProfile; + const profile = actionData.item as interfaces.data.ISourceProfile; await this.showEditProfileDialog(profile); }, }, @@ -116,7 +116,7 @@ export class OpsViewSecurityProfiles extends DeesElement { iconName: 'lucide:trash2', type: ['inRow', 'contextmenu'] as any, actionFunc: async (actionData: any) => { - const profile = actionData.item as interfaces.data.ISecurityProfile; + const profile = actionData.item as interfaces.data.ISourceProfile; await this.deleteProfile(profile); }, }, @@ -129,7 +129,7 @@ export class OpsViewSecurityProfiles extends DeesElement { private async showCreateProfileDialog() { const { DeesModal } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ - heading: 'Create Security Profile', + heading: 'Create Source Profile', content: html` @@ -167,7 +167,7 @@ export class OpsViewSecurityProfiles extends DeesElement { }); } - private async showEditProfileDialog(profile: interfaces.data.ISecurityProfile) { + private async showEditProfileDialog(profile: interfaces.data.ISourceProfile) { const { DeesModal } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ heading: `Edit Profile: ${profile.name}`, @@ -209,7 +209,7 @@ export class OpsViewSecurityProfiles extends DeesElement { }); } - private async deleteProfile(profile: interfaces.data.ISecurityProfile) { + private async deleteProfile(profile: interfaces.data.ISourceProfile) { await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, { id: profile.id, force: false, diff --git a/ts_web/elements/ops-view-targetprofiles.ts b/ts_web/elements/ops-view-targetprofiles.ts new file mode 100644 index 0000000..63f6d4e --- /dev/null +++ b/ts_web/elements/ops-view-targetprofiles.ts @@ -0,0 +1,379 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as plugins from '../plugins.js'; +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-targetprofiles': OpsViewTargetProfiles; + } +} + +@customElement('ops-view-targetprofiles') +export class OpsViewTargetProfiles extends DeesElement { + @state() + accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.targetProfilesStatePart.select().subscribe((newState) => { + this.targetProfilesState = newState; + }); + this.rxSubscriptions.push(sub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .profilesContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .tagBadge { + display: inline-flex; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + background: ${cssManager.bdTheme('#eff6ff', '#172554')}; + color: ${cssManager.bdTheme('#1e40af', '#60a5fa')}; + margin-right: 4px; + margin-bottom: 2px; + } + `, + ]; + + public render(): TemplateResult { + const profiles = this.targetProfilesState.profiles; + + const statsTiles: IStatsTile[] = [ + { + id: 'totalProfiles', + title: 'Total Profiles', + type: 'number', + value: profiles.length, + icon: 'lucide:target', + description: 'Reusable target profiles', + color: '#8b5cf6', + }, + ]; + + return html` + Target Profiles +
+ + ({ + Name: profile.name, + Description: profile.description || '-', + Domains: profile.domains?.length + ? html`${profile.domains.map(d => html`${d}`)}` + : '-', + Targets: profile.targets?.length + ? html`${profile.targets.map(t => html`${t.host}:${t.port}`)}` + : '-', + 'Route Refs': profile.routeRefs?.length + ? html`${profile.routeRefs.map(r => html`${r}`)}` + : '-', + Created: new Date(profile.createdAt).toLocaleDateString(), + })} + .dataActions=${[ + { + name: 'Create Profile', + iconName: 'lucide:plus', + type: ['header' as const], + actionFunc: async () => { + await this.showCreateProfileDialog(); + }, + }, + { + name: 'Refresh', + iconName: 'lucide:rotateCw', + type: ['header' as const], + actionFunc: async () => { + await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null); + }, + }, + { + name: 'Detail', + iconName: 'lucide:info', + type: ['doubleClick'] as any, + actionFunc: async (actionData: any) => { + const profile = actionData.item as interfaces.data.ITargetProfile; + await this.showDetailDialog(profile); + }, + }, + { + name: 'Edit', + iconName: 'lucide:pencil', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const profile = actionData.item as interfaces.data.ITargetProfile; + await this.showEditProfileDialog(profile); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const profile = actionData.item as interfaces.data.ITargetProfile; + await this.deleteProfile(profile); + }, + }, + ]} + > +
+ `; + } + + private async showCreateProfileDialog() { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Create Target Profile', + content: html` + + + + + + + + `, + menuOptions: [ + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Create', + iconName: 'lucide:plus', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + if (!data.name) return; + + const domains = data.domains + ? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean) + : undefined; + const targets = data.targets + ? String(data.targets).split(',').map((s: string) => { + const trimmed = s.trim(); + const lastColon = trimmed.lastIndexOf(':'); + if (lastColon === -1) return null; + return { + host: trimmed.substring(0, lastColon), + port: parseInt(trimmed.substring(lastColon + 1), 10), + }; + }).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)) + : undefined; + const routeRefs = data.routeRefs + ? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean) + : undefined; + + await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, { + name: String(data.name), + description: data.description ? String(data.description) : undefined, + domains, + targets, + routeRefs, + }); + modalArg.destroy(); + }, + }, + ], + }); + } + + private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) { + const currentDomains = profile.domains?.join(', ') ?? ''; + const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? ''; + const currentRouteRefs = profile.routeRefs?.join(', ') ?? ''; + + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: `Edit Profile: ${profile.name}`, + content: html` + + + + + + + + `, + menuOptions: [ + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Save', + iconName: 'lucide:check', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + + const domains = data.domains + ? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean) + : []; + const targets = data.targets + ? String(data.targets).split(',').map((s: string) => { + const trimmed = s.trim(); + if (!trimmed) return null; + const lastColon = trimmed.lastIndexOf(':'); + if (lastColon === -1) return null; + return { + host: trimmed.substring(0, lastColon), + port: parseInt(trimmed.substring(lastColon + 1), 10), + }; + }).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)) + : []; + const routeRefs = data.routeRefs + ? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean) + : []; + + await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, { + id: profile.id, + name: String(data.name), + description: data.description ? String(data.description) : undefined, + domains, + targets, + routeRefs, + }); + modalArg.destroy(); + }, + }, + ], + }); + } + + private async showDetailDialog(profile: interfaces.data.ITargetProfile) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + + // Fetch usage (which VPN clients reference this profile) + let usageHtml = html`

Loading usage...

`; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetTargetProfileUsage + >('/typedrequest', 'getTargetProfileUsage'); + const response = await request.fire({ + identity: appstate.loginStatePart.getState()!.identity!, + id: profile.id, + }); + if (response.clients.length > 0) { + usageHtml = html` +
+ ${response.clients.map(c => html` +
+ ${c.clientId}${c.description ? html` - ${c.description}` : ''} +
+ `)} +
+ `; + } else { + usageHtml = html`

No VPN clients reference this profile.

`; + } + } catch { + usageHtml = html`

Usage data unavailable.

`; + } + + DeesModal.createAndShow({ + heading: `Target Profile: ${profile.name}`, + content: html` +
+
+
Description
+
${profile.description || '-'}
+
+
+
Domains
+
+ ${profile.domains?.length + ? profile.domains.map(d => html`${d}`) + : '-'} +
+
+
+
Targets
+
+ ${profile.targets?.length + ? profile.targets.map(t => html`${t.host}:${t.port}`) + : '-'} +
+
+
+
Route Refs
+
+ ${profile.routeRefs?.length + ? profile.routeRefs.map(r => html`${r}`) + : '-'} +
+
+
+
Created
+
${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}
+
+
+
Updated
+
${new Date(profile.updatedAt).toLocaleString()}
+
+
+
VPN Clients Using This Profile
+ ${usageHtml} +
+
+ `, + menuOptions: [ + { name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() }, + ], + }); + } + + private async deleteProfile(profile: interfaces.data.ITargetProfile) { + await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, { + id: profile.id, + force: false, + }); + + const currentState = appstate.targetProfilesStatePart.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', + iconName: 'lucide:trash2', + action: async (modalArg: any) => { + await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, { + id: profile.id, + force: true, + }); + modalArg.destroy(); + }, + }, + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() }, + ], + }); + } + } +} diff --git a/ts_web/elements/ops-view-vpn.ts b/ts_web/elements/ops-view-vpn.ts index 0fae272..8eb5e1e 100644 --- a/ts_web/elements/ops-view-vpn.ts +++ b/ts_web/elements/ops-view-vpn.ts @@ -327,8 +327,8 @@ export class OpsViewVpn extends DeesElement { 'Status': statusHtml, 'Routing': routingHtml, 'VPN IP': client.assignedIp || '-', - 'Tags': client.serverDefinedClientTags?.length - ? html`${client.serverDefinedClientTags.map(t => html`${t}`)}` + 'Target Profiles': client.targetProfileIds?.length + ? html`${client.targetProfileIds.map(t => html`${t}`)}` : '-', 'Description': client.description || '-', 'Created': new Date(client.createdAt).toLocaleDateString(), @@ -347,7 +347,7 @@ export class OpsViewVpn extends DeesElement { - +