Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 |
14
changelog.md
14
changelog.md
@@ -1,5 +1,19 @@
|
||||
# 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)
|
||||
add tag-aware WireGuard AllowedIPs for VPN-gated routes
|
||||
|
||||
- compute per-client WireGuard AllowedIPs from server-defined client tags and VPN-required proxy routes
|
||||
- include the server public IP in AllowedIPs when a client can access VPN-gated domains so routed traffic reaches the proxy
|
||||
- preserve and inject WireGuard private keys in generated and exported client configs for valid exports
|
||||
|
||||
## 2026-03-31 - 11.20.1 - fix(vpn-manager)
|
||||
persist WireGuard private keys for valid client exports and QR codes
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "11.20.1",
|
||||
"version": "11.21.1",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Server public IP (used for VPN AllowedIPs)
|
||||
publicIp: '203.0.113.1',
|
||||
// SmartProxy routes for development/demo
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
@@ -23,7 +25,19 @@ const devRouter = new DcRouter({
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
name: 'vpn-internal-app',
|
||||
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||
vpn: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'vpn-eng-dashboard',
|
||||
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] },
|
||||
},
|
||||
] as any[],
|
||||
},
|
||||
// VPN with pre-defined clients
|
||||
vpnConfig: {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.20.1',
|
||||
version: '11.21.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -2105,11 +2105,67 @@ export class DcRouter {
|
||||
// Re-apply routes so tag-based ipAllowLists get updated
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
},
|
||||
getClientAllowedIPs: async (clientTags: string[]) => {
|
||||
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||
const ips = new Set<string>([subnet]);
|
||||
|
||||
// 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))) {
|
||||
// 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(/^\*\./, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ips];
|
||||
},
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface IVpnManagerConfig {
|
||||
allowList?: string[];
|
||||
blockList?: string[];
|
||||
};
|
||||
/** Compute per-client AllowedIPs based on the client's server-defined tags.
|
||||
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||
* When not set, defaults to [subnet]. */
|
||||
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
|
||||
}
|
||||
|
||||
interface IPersistedServerKeys {
|
||||
@@ -190,6 +194,15 @@ export class VpnManager {
|
||||
description: opts.description,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
const persisted: IPersistedClient = {
|
||||
clientId: bundle.entry.clientId,
|
||||
@@ -199,7 +212,8 @@ export class VpnManager {
|
||||
assignedIp: bundle.entry.assignedIp,
|
||||
noisePublicKey: bundle.entry.publicKey,
|
||||
wgPublicKey: bundle.entry.wgPublicKey || '',
|
||||
wgPrivateKey: bundle.secrets?.wgPrivateKey,
|
||||
wgPrivateKey: bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: bundle.entry.expiresAt,
|
||||
@@ -273,7 +287,8 @@ export class VpnManager {
|
||||
if (client) {
|
||||
client.noisePublicKey = bundle.entry.publicKey;
|
||||
client.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
client.wgPrivateKey = bundle.secrets?.wgPrivateKey;
|
||||
client.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
}
|
||||
@@ -282,21 +297,32 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a client config. Injects the stored WG private key for complete configs.
|
||||
* Export a client config. Injects stored WG private key and per-client AllowedIPs.
|
||||
*/
|
||||
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
||||
if (!this.vpnServer) throw new Error('VPN server not running');
|
||||
let config = await this.vpnServer.exportClientConfig(clientId, format);
|
||||
|
||||
// Inject stored WG private key so exports produce valid, scannable configs
|
||||
if (format === 'wireguard') {
|
||||
const persisted = this.clients.get(clientId);
|
||||
|
||||
// Inject stored WG private key so exports produce valid, scannable configs
|
||||
if (persisted?.wgPrivateKey) {
|
||||
config = config.replace(
|
||||
'[Interface]\n',
|
||||
`[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Override AllowedIPs with per-client values based on tag-matched routes
|
||||
if (this.config.getClientAllowedIPs) {
|
||||
const clientTags = persisted?.serverDefinedClientTags || [];
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(clientTags);
|
||||
config = config.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '11.20.1',
|
||||
version: '11.21.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user