diff --git a/changelog.md b/changelog.md index d0f7a18..dcf4db5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 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 diff --git a/test_watch/devserver.ts b/test_watch/devserver.ts index f4d0cfa..e96e069 100644 --- a/test_watch/devserver.ts +++ b/test_watch/devserver.ts @@ -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: { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 524186f..723193a 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '11.20.1', + version: '11.21.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 1d8b6e7..5123a76 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -2105,6 +2105,39 @@ export class DcRouter { // Re-apply routes so tag-based ipAllowLists get updated this.routeConfigManager?.applyRoutes(); }, + getClientAllowedIPs: (clientTags: string[]) => { + const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24'; + const ips = new Set([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 + const routes = this.options.smartProxyConfig?.routes || []; + 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`); + } + break; // All routes resolve to the same server IPs + } + } + + return [...ips]; + }, }); await this.vpnManager.start(); diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index f7837fd..2864f02 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -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[]) => 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 = 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 { 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 = this.config.getClientAllowedIPs(clientTags); + config = config.replace( + /AllowedIPs\s*=\s*.+/, + `AllowedIPs = ${allowedIPs.join(', ')}`, + ); + } } return config; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 524186f..723193a 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '11.20.1', + version: '11.21.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' }