import { logger } from '../logger.js'; import type { TVpnClientAllowEntry } from '../config/classes.route-config-manager.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import type { DcRouter } from '../classes.dcrouter.js'; /** * Resolves which VPN clients may access which routes and which IPs belong in * a client's WireGuard AllowedIPs, including cached DNS resolution of * VPN-gated route domains. */ export class VpnAccessResolver { /** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */ private domainIpCache = new Map(); /** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */ private warnedWildcardDomains = new Set(); constructor(private dcRouterRef: DcRouter) {} /** Clear DNS and warning caches, e.g. after a VPN config change. */ public reset(): void { this.domainIpCache.clear(); this.warnedWildcardDomains.clear(); } /** * Build the per-route VPN client allow resolver handed to RouteConfigManager, * or undefined when VPN is disabled. */ public createRouteAllowResolver(): (( route: IDcRouterRouteConfig, routeId?: string, ) => TVpnClientAllowEntry[]) | undefined { if (!this.dcRouterRef.options.vpnConfig?.enabled) { return undefined; } return (route: IDcRouterRouteConfig, routeId?: string) => { if (!this.dcRouterRef.vpnManager || !this.dcRouterRef.targetProfileManager) { // VPN not ready yet — deny all until re-apply after VPN starts. return []; } return this.dcRouterRef.targetProfileManager.getMatchingVpnClients( route, routeId, this.dcRouterRef.vpnManager.listClients(), this.dcRouterRef.routeConfigManager?.getRoutes() || new Map(), ); }; } /** * Compute the WireGuard AllowedIPs for a client from its target profiles: * the VPN subnet, direct target IPs, and DNS-resolved route domains. */ public async getClientAllowedIPs(targetProfileIds: string[]): Promise { const subnet = this.dcRouterRef.options.vpnConfig?.subnet || '10.8.0.0/24'; const ips = new Set([subnet]); const targetProfileManager = this.dcRouterRef.targetProfileManager; if (!targetProfileManager) return [...ips]; const allRoutes = this.dcRouterRef.routeConfigManager?.getRoutes() || new Map(); const { domains, targetIps } = targetProfileManager.getClientAccessSpec( targetProfileIds, allRoutes, ); // 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 domains) { if (this.isWildcardDomain(domain)) { this.logSkippedWildcardAllowedIp(domain); continue; } const resolvedIps = await this.resolveDomainIPs(domain); for (const ip of resolvedIps) { ips.add(`${ip}/32`); } } return [...ips]; } /** * Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache. */ private async resolveDomainIPs(domain: string): Promise { const cached = this.domainIpCache.get(domain); if (cached && cached.expiresAt > Date.now()) { return cached.ips; } try { const { promises: dnsPromises } = await import('dns'); const ips = await dnsPromises.resolve4(domain); this.domainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 }); // Evict oldest entries if cache exceeds 1000 entries if (this.domainIpCache.size > 1000) { const firstKey = this.domainIpCache.keys().next().value; if (firstKey) this.domainIpCache.delete(firstKey); } return ips; } catch (err) { logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`); return cached?.ips || []; // Return stale cache on failure, or empty } } private isWildcardDomain(domain: string): boolean { return domain.includes('*'); } private logSkippedWildcardAllowedIp(domain: string): void { if (this.warnedWildcardDomains.has(domain)) return; this.warnedWildcardDomains.add(domain); logger.log( 'warn', `VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`, ); } }