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 { // Enforce unique profile names for (const existing of this.profiles.values()) { if (existing.name === data.name) { throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`); } } 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 })); } // ========================================================================= // Direct target IPs (bypass SmartProxy) // ========================================================================= /** * For a set of target profile IDs, collect all explicit target IPs. * These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can * connect to them directly through the tunnel. */ public getDirectTargetIps(targetProfileIds: string[]): string[] { const ips = new Set(); for (const profileId of targetProfileIds) { const profile = this.profiles.get(profileId); if (!profile?.targets?.length) continue; for (const t of profile.targets) { ips.add(t.ip); } } return [...ips]; } // ========================================================================= // Core matching: route → client IPs // ========================================================================= /** * For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile * matches the route. Returns IP allow entries for injection into ipAllowList. * * Entries are domain-scoped when a profile matches via specific domains that are * a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches * or when profile domains exactly equal the route's domains. */ public getMatchingClientIps( route: IDcRouterRouteConfig, routeId: string | undefined, clients: VpnClientDoc[], ): Array { const entries: Array = []; const routeDomains: string[] = (route.match as any)?.domains || []; for (const client of clients) { if (!client.enabled || !client.assignedIp) continue; if (!client.targetProfileIds?.length) continue; // Collect scoped domains from all matching profiles for this client let fullAccess = false; const scopedDomains = new Set(); for (const profileId of client.targetProfileIds) { const profile = this.profiles.get(profileId); if (!profile) continue; const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains); if (matchResult === 'full') { fullAccess = true; break; // No need to check more profiles } if (matchResult !== 'none') { for (const d of matchResult.domains) scopedDomains.add(d); } } if (fullAccess) { entries.push(client.assignedIp); } else if (scopedDomains.size > 0) { entries.push({ ip: client.assignedIp, domains: [...scopedDomains] }); } } return entries; } /** * 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.ip); } } // 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 (boolean convenience wrapper). */ private routeMatchesProfile( route: IDcRouterRouteConfig, routeId: string | undefined, profile: ITargetProfile, ): boolean { const routeDomains: string[] = (route.match as any)?.domains || []; const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains); return result !== 'none'; } /** * Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited), * or 'none' (no match). * * - routeRefs / target matches → 'full' (explicit reference = full access) * - domain match where profile domains are a subset of route wildcard → 'scoped' * - domain match where domains are identical or profile is a wildcard → 'full' */ private routeMatchesProfileDetailed( route: IDcRouterRouteConfig, routeId: string | undefined, profile: ITargetProfile, routeDomains: string[], ): 'full' | { type: 'scoped'; domains: string[] } | 'none' { // 1. Route reference match → full access if (profile.routeRefs?.length) { if (routeId && profile.routeRefs.includes(routeId)) return 'full'; if (route.name && profile.routeRefs.includes(route.name)) return 'full'; } // 2. Domain match if (profile.domains?.length && routeDomains.length) { const matchedProfileDomains: string[] = []; for (const profileDomain of profile.domains) { for (const routeDomain of routeDomains) { if (this.domainMatchesPattern(routeDomain, profileDomain) || this.domainMatchesPattern(profileDomain, routeDomain)) { matchedProfileDomains.push(profileDomain); break; // This profileDomain matched, move to the next } } } if (matchedProfileDomains.length > 0) { // Check if profile domains cover the route entirely (same wildcards = full access) const isFullCoverage = routeDomains.every((rd) => matchedProfileDomains.some((pd) => rd === pd || this.domainMatchesPattern(rd, pd), ), ); if (isFullCoverage) return 'full'; // Profile domains are a subset → scoped access to those specific domains return { type: 'scoped', domains: matchedProfileDomains }; } } // 3. Target match (host + port) → full access (precise by nature) 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.ip && routePort === profileTarget.port) { return 'full'; } } } } } return 'none'; } /** * 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(); } } }