fix(vpn): resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-31 - 11.21.1 - fix(vpn)
|
||||||
|
resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups
|
||||||
|
|
||||||
|
- Derive WireGuard AllowedIPs from DNS A records of matched vpn.required route domains instead of only configured public proxy IPs.
|
||||||
|
- Cache resolved domain IPs for 5 minutes and fall back to stale results on DNS lookup failures.
|
||||||
|
- Make per-client AllowedIPs generation asynchronous throughout VPN config export and regeneration flows.
|
||||||
|
|
||||||
## 2026-03-31 - 11.21.0 - feat(vpn)
|
## 2026-03-31 - 11.21.0 - feat(vpn)
|
||||||
add tag-aware WireGuard AllowedIPs for VPN-gated routes
|
add tag-aware WireGuard AllowedIPs for VPN-gated routes
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.21.0',
|
version: '11.21.1',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2105,34 +2105,35 @@ export class DcRouter {
|
|||||||
// Re-apply routes so tag-based ipAllowLists get updated
|
// Re-apply routes so tag-based ipAllowLists get updated
|
||||||
this.routeConfigManager?.applyRoutes();
|
this.routeConfigManager?.applyRoutes();
|
||||||
},
|
},
|
||||||
getClientAllowedIPs: (clientTags: string[]) => {
|
getClientAllowedIPs: async (clientTags: string[]) => {
|
||||||
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||||
const ips = new Set<string>([subnet]);
|
const ips = new Set<string>([subnet]);
|
||||||
|
|
||||||
// Determine the server's public-facing IP(s) that VPN-gated domains resolve to
|
// Check routes for VPN-gated tag match and collect domains
|
||||||
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
|
|
||||||
const routes = this.options.smartProxyConfig?.routes || [];
|
const routes = this.options.smartProxyConfig?.routes || [];
|
||||||
|
const domainsToResolve = new Set<string>();
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
|
||||||
if (!dcRoute.vpn?.required) continue;
|
if (!dcRoute.vpn?.required) continue;
|
||||||
|
|
||||||
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags;
|
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags;
|
||||||
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {
|
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {
|
||||||
for (const ip of publicIPs) {
|
// Collect domains from this route
|
||||||
ips.add(`${ip}/32`);
|
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();
|
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.
|
* Inject VPN security into routes that have vpn.required === true.
|
||||||
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
|
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface IVpnManagerConfig {
|
|||||||
/** Compute per-client AllowedIPs based on the client's server-defined tags.
|
/** Compute per-client AllowedIPs based on the client's server-defined tags.
|
||||||
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||||
* When not set, defaults to [subnet]. */
|
* When not set, defaults to [subnet]. */
|
||||||
getClientAllowedIPs?: (clientTags: string[]) => string[];
|
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IPersistedServerKeys {
|
interface IPersistedServerKeys {
|
||||||
@@ -196,7 +196,7 @@ export class VpnManager {
|
|||||||
|
|
||||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||||
const allowedIPs = this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
|
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
|
||||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
/AllowedIPs\s*=\s*.+/,
|
/AllowedIPs\s*=\s*.+/,
|
||||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
@@ -317,7 +317,7 @@ export class VpnManager {
|
|||||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||||
if (this.config.getClientAllowedIPs) {
|
if (this.config.getClientAllowedIPs) {
|
||||||
const clientTags = persisted?.serverDefinedClientTags || [];
|
const clientTags = persisted?.serverDefinedClientTags || [];
|
||||||
const allowedIPs = this.config.getClientAllowedIPs(clientTags);
|
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
|
||||||
config = config.replace(
|
config = config.replace(
|
||||||
/AllowedIPs\s*=\s*.+/,
|
/AllowedIPs\s*=\s*.+/,
|
||||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '11.21.0',
|
version: '11.21.1',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user