diff --git a/.smartconfig.json b/.smartconfig.json index eb6cc9f..c5f0795 100644 --- a/.smartconfig.json +++ b/.smartconfig.json @@ -23,11 +23,14 @@ "outputMode": "bundle", "bundler": "esbuild", "production": true, - "includeFiles": ["./html/**/*.html"] + "includeFiles": [ + "./html/**/*.html" + ] } ] }, "@git.zone/cli": { + "schemaVersion": 2, "projectType": "service", "module": { "githost": "code.foss.global", @@ -60,18 +63,37 @@ ] }, "release": { - "registries": [ - "https://verdaccio.lossless.digital", - "https://registry.npmjs.org" - ], - "accessLevel": "public" + "targets": { + "git": { + "enabled": true, + "remote": "origin" + }, + "npm": { + "enabled": false, + "registries": [ + "https://verdaccio.lossless.digital", + "https://registry.npmjs.org" + ], + "accessLevel": "public" + }, + "docker": { + "enabled": false, + "engine": "tsdocker" + } + } } }, "@git.zone/tsdocker": { - "registries": ["code.foss.global"], + "registries": [ + "code.foss.global" + ], "registryRepoMap": { "code.foss.global": "serve.zone/dcrouter" }, - "platforms": ["linux/amd64", "linux/arm64"] - } -} + "platforms": [ + "linux/amd64", + "linux/arm64" + ] + }, + "@ship.zone/szci": {} +} \ No newline at end of file diff --git a/changelog.md b/changelog.md index 9b8da41..1760e50 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## Pending + +### Fixes + +- harden VPN route access and wireguard client configuration handling (vpn) + - Fail closed for vpnOnly routes when no VPN client IPs are available by replacing allow lists and enforcing a block-all fallback + - Refresh route application and VPN client security after target profile creation so profile changes take effect immediately + - Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation + - Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently + ## 2026-05-09 - 13.28.0 - feat(gateway-clients) add managed gateway client administration and token-bound route ownership @@ -2612,4 +2622,4 @@ Applied a core fix. - Fixed core functionality for version 1.0.1 ––––––––––––––––––––––– -Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above. \ No newline at end of file +Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above. diff --git a/package.json b/package.json index fb39046..9b81149 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.3.1", "@push.rocks/smartunique": "^3.0.9", - "@push.rocks/smartvpn": "1.19.2", + "@push.rocks/smartvpn": "1.19.4", "@push.rocks/taskbuffer": "^8.0.2", "@serve.zone/catalog": "^2.12.4", "@serve.zone/interfaces": "^5.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddc3c5a..fe2ebbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,8 +99,8 @@ importers: specifier: ^3.0.9 version: 3.0.9 '@push.rocks/smartvpn': - specifier: 1.19.2 - version: 1.19.2 + specifier: 1.19.4 + version: 1.19.4 '@push.rocks/taskbuffer': specifier: ^8.0.2 version: 8.0.2 @@ -1272,9 +1272,6 @@ packages: '@push.rocks/smartnetwork@4.7.1': resolution: {integrity: sha512-x9SolGn8lU3oh+fKL26dR5dIhsus5f0p/Xiaut2pK5Wamgwrvt5y5To8F+pzF1pQr6yA0XwWZ0Dgoppp2E+ziQ==} - '@push.rocks/smartnftables@1.1.0': - resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==} - '@push.rocks/smartnftables@1.2.0': resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==} @@ -1359,8 +1356,8 @@ packages: '@push.rocks/smartversion@3.1.0': resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==} - '@push.rocks/smartvpn@1.19.2': - resolution: {integrity: sha512-ygy7jnd4lfXmsHpdL0jS2k6bQAicSSoYcz7OzRpD0jQ970ghAnq2TgC3ccDl23YT9pt0QJPQLkGbVXN5+adQVg==} + '@push.rocks/smartvpn@1.19.4': + resolution: {integrity: sha512-Cp6yyzRcZlqQMEWAQ/CG2tvUxSR4eSmzMTDQFVJsPtV+CbhXpulbqqz0penU6drVMiRGzXhwoQZtGYynigIXwA==} '@push.rocks/smartwatch@6.4.0': resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} @@ -6385,11 +6382,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@push.rocks/smartnftables@1.1.0': - dependencies: - '@push.rocks/smartlog': 3.2.2 - '@push.rocks/smartpromise': 4.2.4 - '@push.rocks/smartnftables@1.2.0': dependencies: '@push.rocks/smartlog': 3.2.2 @@ -6613,9 +6605,9 @@ snapshots: '@types/semver': 7.7.1 semver: 7.7.4 - '@push.rocks/smartvpn@1.19.2': + '@push.rocks/smartvpn@1.19.4': dependencies: - '@push.rocks/smartnftables': 1.1.0 + '@push.rocks/smartnftables': 1.2.0 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartrust': 1.4.0 diff --git a/test/test.vpn-runtime.node.ts b/test/test.vpn-runtime.node.ts index b864496..cafac55 100644 --- a/test/test.vpn-runtime.node.ts +++ b/test/test.vpn-runtime.node.ts @@ -1,6 +1,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle'; import { DcRouter } from '../ts/classes.dcrouter.js'; import { VpnManager } from '../ts/vpn/classes.vpn-manager.js'; +import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js'; tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => { const manager = new VpnManager({ forwardingMode: 'socket' }); @@ -107,4 +108,70 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V expect(dcRouter.vpnManager).toBeUndefined(); }); +tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => { + const manager = new RouteConfigManager(() => undefined); + const route = { + name: 'private-route', + vpnOnly: true, + match: { domains: ['private.example.com'] }, + action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] }, + security: { ipAllowList: ['*'] }, + } as any; + + const prepared = (manager as any).injectVpnSecurity(route); + + expect(prepared.security.ipAllowList).toEqual([]); + expect(prepared.security.ipBlockList).toContain('*'); +}); + +tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', async () => { + const manager = new RouteConfigManager( + () => undefined, + undefined, + () => ['10.8.0.2'], + ); + const route = { + name: 'private-route', + vpnOnly: true, + match: { domains: ['private.example.com'] }, + action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] }, + security: { + ipAllowList: ['*', '203.0.113.10'], + ipBlockList: ['198.51.100.5'], + }, + } as any; + + const prepared = (manager as any).injectVpnSecurity(route); + + expect(prepared.security.ipAllowList).toEqual(['10.8.0.2']); + expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']); +}); + +tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => { + const manager = new VpnManager({ + serverEndpoint: 'vpn.example.com', + getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'], + }); + + (manager as any).vpnServer = { + rotateClientKey: async () => ({ + entry: { + clientId: 'client-1', + publicKey: 'noise-public-key', + wgPublicKey: 'wg-public-key', + }, + wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n', + secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' }, + }), + }; + (manager as any).clients = new Map([ + ['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }], + ]); + (manager as any).persistClient = async () => {}; + + const bundle = await manager.rotateClientKey('client-1'); + + expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32'); +}); + export default tap.start() diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 22b64b8..ceefaf1 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -740,10 +740,14 @@ export class DcRouter { // VPN Server: optional, depends on SmartProxy if (this.options.vpnConfig?.enabled) { + const vpnServiceDeps = ['SmartProxy']; + if (this.options.dbConfig?.enabled !== false) { + vpnServiceDeps.push('ConfigManagers'); + } this.serviceManager.addService( new plugins.taskbuffer.Service('VpnServer') .optional() - .dependsOn('SmartProxy') + .dependsOn(...vpnServiceDeps) .withStart(async () => { await this.setupVpnServer(); }) @@ -2418,6 +2422,13 @@ export class DcRouter { return; } + if (this.options.dbConfig?.enabled === false) { + throw new Error('VPN requires dbConfig.enabled because clients, keys, routes, and target profiles are persisted in DcRouterDb'); + } + if (!this.routeConfigManager || !this.targetProfileManager) { + throw new Error('VPN requires initialized route and target profile managers'); + } + logger.log('info', 'Setting up VPN server...'); this.vpnManager = new VpnManager({ diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 720c6c4..ae19742 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -607,19 +607,21 @@ export class RouteConfigManager { route: plugins.smartproxy.IRouteConfig, routeId?: string, ): plugins.smartproxy.IRouteConfig { - const vpnCallback = this.getVpnClientIpsForRoute; - if (!vpnCallback) return route; - const dcRoute = route as IDcRouterRouteConfig; if (!dcRoute.vpnOnly) return route; - const vpnEntries = vpnCallback(dcRoute, routeId); - const existingEntries = route.security?.ipAllowList || []; + const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || []; + const existingBlockList = route.security?.ipBlockList || []; + const ipBlockList = vpnEntries.length + ? existingBlockList + : [...new Set([...existingBlockList, '*'])]; + return { ...route, security: { ...route.security, - ipAllowList: [...existingEntries, ...vpnEntries], + ipAllowList: vpnEntries, + ipBlockList, }, }; } diff --git a/ts/opsserver/handlers/target-profile.handler.ts b/ts/opsserver/handlers/target-profile.handler.ts index 10b38af..3b93b58 100644 --- a/ts/opsserver/handlers/target-profile.handler.ts +++ b/ts/opsserver/handlers/target-profile.handler.ts @@ -88,6 +88,8 @@ export class TargetProfileHandler { routeRefs: dataArg.routeRefs, createdBy: userId, }); + await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); + await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity(); return { success: true, id }; }, ), diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index c6c8933..af214bd 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -111,6 +111,7 @@ export class VpnManager { const subnet = this.getSubnet(); const wgListenPort = this.config.wgListenPort ?? 51820; + const serverEndpoint = this.getWireGuardServerEndpoint(); const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp); if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') { @@ -133,21 +134,19 @@ export class VpnManager { : { default: 'forceTarget' as const, target: '127.0.0.1' }; const serverConfig: plugins.smartvpn.IVpnServerConfig = { - listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field + listenAddr: '127.0.0.1:0', // Required by smartvpn, unused in wireguard-only mode privateKey: this.serverKeys.noisePrivateKey, publicKey: this.serverKeys.noisePublicKey, subnet, dns: this.config.dns, forwardingMode: forwardingMode as any, - transportMode: 'all', + transportMode: 'wireguard', wgPrivateKey: this.serverKeys.wgPrivateKey, wgListenPort, clients: clientEntries, socketForwardProxyProtocol: !isBridge, destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy), - serverEndpoint: this.config.serverEndpoint - ? `${this.config.serverEndpoint}:${wgListenPort}` - : undefined, + serverEndpoint, clientAllowedIPs: [subnet], // Bridge-specific config ...(isBridge ? { @@ -187,7 +186,7 @@ export class VpnManager { } catch { // Ignore stop errors } - this.vpnServer.stop(); + await this.vpnServer.stop(); this.vpnServer = undefined; } this.resolvedForwardingMode = undefined; @@ -244,14 +243,10 @@ export class VpnManager { vlanId: doc.vlanId, }); - // Override AllowedIPs with per-client values based on target profiles - if (this.config.getClientAllowedIPs && bundle.wireguardConfig) { - const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []); - bundle.wireguardConfig = bundle.wireguardConfig.replace( - /AllowedIPs\s*=\s*.+/, - `AllowedIPs = ${allowedIPs.join(', ')}`, - ); - } + bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs( + bundle.wireguardConfig, + doc.targetProfileIds || [], + ); // Persist client entry (including WG private key for export/QR) doc.clientId = bundle.entry.clientId; @@ -381,9 +376,13 @@ export class VpnManager { public async rotateClientKey(clientId: string): Promise { if (!this.vpnServer) throw new Error('VPN server not running'); const bundle = await this.vpnServer.rotateClientKey(clientId); + const client = this.clients.get(clientId); + bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs( + bundle.wireguardConfig, + client?.targetProfileIds || [], + ); // Update persisted entry with new keys (including private key for export/QR) - const client = this.clients.get(clientId); if (client) { client.noisePublicKey = bundle.entry.publicKey; client.wgPublicKey = bundle.entry.wgPublicKey || ''; @@ -414,15 +413,7 @@ export class VpnManager { ); } - // Override AllowedIPs with per-client values based on target profiles - if (this.config.getClientAllowedIPs) { - const profileIds = persisted?.targetProfileIds || []; - const allowedIPs = await this.config.getClientAllowedIPs(profileIds); - config = config.replace( - /AllowedIPs\s*=\s*.+/, - `AllowedIPs = ${allowedIPs.join(', ')}`, - ); - } + config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []); } return config; @@ -515,6 +506,46 @@ export class VpnManager { } } + private getWireGuardServerEndpoint(): string { + const endpoint = this.config.serverEndpoint?.trim(); + if (!endpoint) { + throw new Error('vpnConfig.serverEndpoint is required when VPN is enabled'); + } + if (endpoint.includes('://') || endpoint.includes('/')) { + throw new Error('vpnConfig.serverEndpoint must be a host or host:port, not a URL'); + } + + const host = endpoint.includes(':') ? endpoint.split(':')[0] : endpoint; + const lowerHost = host.toLowerCase(); + if ( + lowerHost === 'localhost' + || lowerHost === '0.0.0.0' + || lowerHost.startsWith('127.') + ) { + throw new Error('vpnConfig.serverEndpoint must be reachable by VPN clients'); + } + + return endpoint.includes(':') + ? endpoint + : `${endpoint}:${this.config.wgListenPort ?? 51820}`; + } + + private async rewriteWireGuardAllowedIPs( + wireguardConfig: string, + targetProfileIds: string[], + ): Promise { + if (!this.config.getClientAllowedIPs) return wireguardConfig; + + const allowedIPs = await this.config.getClientAllowedIPs(targetProfileIds); + const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()]; + const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`; + + if (/^AllowedIPs\s*=.*$/m.test(wireguardConfig)) { + return wireguardConfig.replace(/^AllowedIPs\s*=.*$/m, allowedLine); + } + return `${wireguardConfig.trimEnd()}\n${allowedLine}\n`; + } + // ── Private helpers ──────────────────────────────────────────────────── private async loadOrGenerateServerKeys(): Promise { @@ -532,7 +563,7 @@ export class VpnManager { const noiseKeys = await tempServer.generateKeypair(); const wgKeys = await tempServer.generateWgKeypair(); - tempServer.stop(); + await tempServer.stop(); const doc = stored || new VpnServerKeysDoc(); doc.noisePrivateKey = noiseKeys.privateKey;