Compare commits

...

10 Commits

Author SHA1 Message Date
cfb727b86d v11.21.5
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 04:15:51 +00:00
1e4b9997f4 fix(routing): apply VPN route allowlists dynamically after VPN clients load 2026-03-31 04:15:51 +00:00
bb32f23d77 v11.21.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 03:36:36 +00:00
1aa6451dba fix(deps): bump @push.rocks/smartvpn to 1.16.4 2026-03-31 03:36:36 +00:00
eb0408c036 v11.21.3
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 03:21:41 +00:00
098a2567fa fix(deps): bump @push.rocks/smartvpn to 1.16.3 2026-03-31 03:21:41 +00:00
c6534df362 v11.21.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 02:12:18 +00:00
2e4b375ad5 fix(deps): bump @push.rocks/smartvpn to 1.16.2 2026-03-31 02:12:18 +00:00
802bcf1c3d v11.21.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-03-31 01:10:19 +00:00
bad0bd9053 fix(vpn): resolve VPN-gated route domains into per-client AllowedIPs with cached DNS lookups 2026-03-31 01:10:19 +00:00
8 changed files with 107 additions and 93 deletions

View File

@@ -1,5 +1,34 @@
# Changelog # Changelog
## 2026-03-31 - 11.21.5 - fix(routing)
apply VPN route allowlists dynamically after VPN clients load
- Moves VPN security injection for hardcoded and programmatic routes into RouteConfigManager.applyRoutes() so allowlists are generated from current VPN client state.
- Re-applies routes after starting the VPN manager to ensure tag-based ipAllowLists are available once VPN clients are loaded.
- Avoids caching constructor routes with stale VPN security baked in while preserving HTTP/3 route augmentation.
## 2026-03-31 - 11.21.4 - fix(deps)
bump @push.rocks/smartvpn to 1.16.4
- Updates the @push.rocks/smartvpn dependency from 1.16.3 to 1.16.4 in package.json.
## 2026-03-31 - 11.21.3 - fix(deps)
bump @push.rocks/smartvpn to 1.16.3
- Updates the @push.rocks/smartvpn dependency from 1.16.2 to 1.16.3.
## 2026-03-31 - 11.21.2 - fix(deps)
bump @push.rocks/smartvpn to 1.16.2
- Updates the @push.rocks/smartvpn dependency from 1.16.1 to 1.16.2 in package.json.
## 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

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "11.21.0", "version": "11.21.5",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -59,7 +59,7 @@
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0", "@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.16.1", "@push.rocks/smartvpn": "1.16.4",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0", "@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",

10
pnpm-lock.yaml generated
View File

@@ -96,8 +96,8 @@ importers:
specifier: ^3.0.9 specifier: ^3.0.9
version: 3.0.9 version: 3.0.9
'@push.rocks/smartvpn': '@push.rocks/smartvpn':
specifier: 1.16.1 specifier: 1.16.4
version: 1.16.1 version: 1.16.4
'@push.rocks/taskbuffer': '@push.rocks/taskbuffer':
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
@@ -1339,8 +1339,8 @@ packages:
'@push.rocks/smartversion@3.0.5': '@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==} resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartvpn@1.16.1': '@push.rocks/smartvpn@1.16.4':
resolution: {integrity: sha512-LQzt3ajMKIs3anYki/3drt7XcCuekoKvApCltLEjsoGEEX5JkXGSZFB+UFvqEhG8NcEuHw574rU3tB2orHzKTQ==} resolution: {integrity: sha512-ps7NcdBzaaGQFjHcXUN8JC623xZbLNyIYfICxDLJb2BxzzuZa667fW0KxQQCwLtZaB2txN5sMlaOKFi27tXTBA==}
'@push.rocks/smartwatch@6.4.0': '@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -6622,7 +6622,7 @@ snapshots:
'@types/semver': 7.7.1 '@types/semver': 7.7.1
semver: 7.7.4 semver: 7.7.4
'@push.rocks/smartvpn@1.16.1': '@push.rocks/smartvpn@1.16.4':
dependencies: dependencies:
'@push.rocks/smartnftables': 1.1.0 '@push.rocks/smartnftables': 1.1.0
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0

View File

@@ -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.5',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -813,12 +813,8 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
} }
// VPN route security injection: restrict vpn.required routes to VPN subnet // Cache constructor routes for RouteConfigManager (without VPN security baked in —
if (this.options.vpnConfig?.enabled) { // applyRoutes() injects VPN security dynamically so it stays current with client changes)
routes = this.injectVpnSecurity(routes);
}
// Cache constructor routes for RouteConfigManager
this.constructorRoutes = [...routes]; this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it // If we have routes or need a basic SmartProxy instance, create it
@@ -2105,34 +2101,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`);
} }
} }
@@ -2141,51 +2138,38 @@ export class DcRouter {
}); });
await this.vpnManager.start(); await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
// get correct tag-based ipAllowLists (not possible during setupSmartProxy since
// VPN server wasn't ready yet)
this.routeConfigManager?.applyRoutes();
} }
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
/** /**
* Inject VPN security into routes that have vpn.required === true. * Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
* Adds the VPN subnet to security.ipAllowList so only VPN clients can access them.
*/ */
private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] { private async resolveVpnDomainIPs(domain: string): Promise<string[]> {
const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24'; const cached = this.vpnDomainIpCache.get(domain);
let injectedCount = 0; if (cached && cached.expiresAt > Date.now()) {
return cached.ips;
const result = routes.map((route) => { }
const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig; try {
if (dcrouterRoute.vpn?.required) { const { promises: dnsPromises } = await import('dns');
injectedCount++; const ips = await dnsPromises.resolve4(domain);
const existing = route.security?.ipAllowList || []; this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
return ips;
let vpnAllowList: string[]; } catch (err) {
if (dcrouterRoute.vpn.allowedServerDefinedClientTags?.length && this.vpnManager) { logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
// Tag-based: only specific client IPs return cached?.ips || []; // Return stale cache on failure, or empty
vpnAllowList = this.vpnManager.getClientIpsForServerDefinedTags(
dcrouterRoute.vpn.allowedServerDefinedClientTags,
);
} else {
// No tags specified: entire VPN subnet
vpnAllowList = [vpnSubnet];
}
return {
...route,
security: {
...route.security,
ipAllowList: [...existing, ...vpnAllowList],
},
};
}
return route;
});
if (injectedCount > 0) {
logger.log('info', `VPN: Injected ipAllowList into ${injectedCount} VPN-protected route(s)`);
} }
return result;
} }
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
// via the getVpnAllowList callback — no longer a separate method here.
/** /**
* Set up RADIUS server for network authentication * Set up RADIUS server for network authentication
*/ */

View File

@@ -252,41 +252,42 @@ export class RouteConfigManager {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add enabled hardcoded routes (respecting overrides) const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList;
// Helper: inject VPN security into a route if vpn.required is set
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
if (!vpnAllowList) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpn?.required) return route;
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
return {
...route,
security: {
...route.security,
ipAllowList: [...(route.security?.ipAllowList || []), ...allowList],
},
};
};
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
for (const route of this.getHardcodedRoutes()) { for (const route of this.getHardcodedRoutes()) {
const name = route.name || ''; const name = route.name || '';
const override = this.overrides.get(name); const override = this.overrides.get(name);
if (override && !override.enabled) { if (override && !override.enabled) {
continue; // Skip disabled hardcoded route continue; // Skip disabled hardcoded route
} }
enabledRoutes.push(route); enabledRoutes.push(injectVpn(route));
} }
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation) // Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList;
for (const stored of this.storedRoutes.values()) { for (const stored of this.storedRoutes.values()) {
if (stored.enabled) { if (stored.enabled) {
let route = stored.route; let route = stored.route;
if (http3Config && http3Config.enabled !== false) { if (http3Config && http3Config.enabled !== false) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
} }
// Inject VPN security for programmatic routes with vpn.required enabledRoutes.push(injectVpn(route));
if (vpnAllowList) {
const dcRoute = route as IDcRouterRouteConfig;
if (dcRoute.vpn?.required) {
const existing = route.security?.ipAllowList || [];
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
route = {
...route,
security: {
...route.security,
ipAllowList: [...existing, ...allowList],
},
};
}
}
enabledRoutes.push(route);
} }
} }

View File

@@ -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(', ')}`,

View File

@@ -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.5',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }