fix(vpn): resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups

This commit is contained in:
2026-03-31 01:10:19 +00:00
parent ca990781b0
commit bad0bd9053
5 changed files with 52 additions and 22 deletions

View File

@@ -2105,34 +2105,35 @@ export class DcRouter {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
getClientAllowedIPs: (clientTags: string[]) => {
getClientAllowedIPs: async (clientTags: string[]) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
// Determine the server's public-facing IP(s) that VPN-gated domains resolve to
const publicIPs: string[] = [];
if (this.options.proxyIps?.length) {
publicIPs.push(...this.options.proxyIps);
}
if (this.options.publicIp) {
publicIPs.push(this.options.publicIp);
} else if (this.detectedPublicIp) {
publicIPs.push(this.detectedPublicIp);
}
if (!publicIPs.length) return [...ips];
// Check routes for VPN-gated tag match
// Check routes for VPN-gated tag match and collect domains
const routes = this.options.smartProxyConfig?.routes || [];
const domainsToResolve = new Set<string>();
for (const route of routes) {
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (!dcRoute.vpn?.required) continue;
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags;
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {
for (const ip of publicIPs) {
ips.add(`${ip}/32`);
// 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(/^\*\./, ''));
}
}
break; // All routes resolve to the same server IPs
}
}
// Resolve DNS A records for matched domains (with caching)
for (const domain of domainsToResolve) {
const resolvedIps = await this.resolveVpnDomainIPs(domain);
for (const ip of resolvedIps) {
ips.add(`${ip}/32`);
}
}
@@ -2143,6 +2144,28 @@ export class DcRouter {
await this.vpnManager.start();
}
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
/**
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
*/
private async resolveVpnDomainIPs(domain: string): Promise<string[]> {
const cached = this.vpnDomainIpCache.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.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
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
}
}
/**
* Inject VPN security into routes that have vpn.required === true.
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.