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(); } } }