fix(vpn): harden VPN route access and wireguard client configuration handling

This commit is contained in:
2026-05-13 13:42:12 +00:00
parent 67b9fb536c
commit 47a1f5d7db
9 changed files with 195 additions and 58 deletions
+32 -10
View File
@@ -23,11 +23,14 @@
"outputMode": "bundle", "outputMode": "bundle",
"bundler": "esbuild", "bundler": "esbuild",
"production": true, "production": true,
"includeFiles": ["./html/**/*.html"] "includeFiles": [
"./html/**/*.html"
]
} }
] ]
}, },
"@git.zone/cli": { "@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service", "projectType": "service",
"module": { "module": {
"githost": "code.foss.global", "githost": "code.foss.global",
@@ -60,18 +63,37 @@
] ]
}, },
"release": { "release": {
"registries": [ "targets": {
"https://verdaccio.lossless.digital", "git": {
"https://registry.npmjs.org" "enabled": true,
], "remote": "origin"
"accessLevel": "public" },
"npm": {
"enabled": false,
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"docker": {
"enabled": false,
"engine": "tsdocker"
}
}
} }
}, },
"@git.zone/tsdocker": { "@git.zone/tsdocker": {
"registries": ["code.foss.global"], "registries": [
"code.foss.global"
],
"registryRepoMap": { "registryRepoMap": {
"code.foss.global": "serve.zone/dcrouter" "code.foss.global": "serve.zone/dcrouter"
}, },
"platforms": ["linux/amd64", "linux/arm64"] "platforms": [
} "linux/amd64",
} "linux/arm64"
]
},
"@ship.zone/szci": {}
}
+11 -1
View File
@@ -1,5 +1,15 @@
# Changelog # 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) ## 2026-05-09 - 13.28.0 - feat(gateway-clients)
add managed gateway client administration and token-bound route ownership 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 - 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. 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.
+1 -1
View File
@@ -61,7 +61,7 @@
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1", "@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.2", "@push.rocks/smartvpn": "1.19.4",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4", "@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.5.0", "@serve.zone/interfaces": "^5.5.0",
+6 -14
View File
@@ -99,8 +99,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.19.2 specifier: 1.19.4
version: 1.19.2 version: 1.19.4
'@push.rocks/taskbuffer': '@push.rocks/taskbuffer':
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
@@ -1272,9 +1272,6 @@ packages:
'@push.rocks/smartnetwork@4.7.1': '@push.rocks/smartnetwork@4.7.1':
resolution: {integrity: sha512-x9SolGn8lU3oh+fKL26dR5dIhsus5f0p/Xiaut2pK5Wamgwrvt5y5To8F+pzF1pQr6yA0XwWZ0Dgoppp2E+ziQ==} resolution: {integrity: sha512-x9SolGn8lU3oh+fKL26dR5dIhsus5f0p/Xiaut2pK5Wamgwrvt5y5To8F+pzF1pQr6yA0XwWZ0Dgoppp2E+ziQ==}
'@push.rocks/smartnftables@1.1.0':
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
'@push.rocks/smartnftables@1.2.0': '@push.rocks/smartnftables@1.2.0':
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==} resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
@@ -1359,8 +1356,8 @@ packages:
'@push.rocks/smartversion@3.1.0': '@push.rocks/smartversion@3.1.0':
resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==} resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==}
'@push.rocks/smartvpn@1.19.2': '@push.rocks/smartvpn@1.19.4':
resolution: {integrity: sha512-ygy7jnd4lfXmsHpdL0jS2k6bQAicSSoYcz7OzRpD0jQ970ghAnq2TgC3ccDl23YT9pt0QJPQLkGbVXN5+adQVg==} resolution: {integrity: sha512-Cp6yyzRcZlqQMEWAQ/CG2tvUxSR4eSmzMTDQFVJsPtV+CbhXpulbqqz0penU6drVMiRGzXhwoQZtGYynigIXwA==}
'@push.rocks/smartwatch@6.4.0': '@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -6385,11 +6382,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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': '@push.rocks/smartnftables@1.2.0':
dependencies: dependencies:
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
@@ -6613,9 +6605,9 @@ snapshots:
'@types/semver': 7.7.1 '@types/semver': 7.7.1
semver: 7.7.4 semver: 7.7.4
'@push.rocks/smartvpn@1.19.2': '@push.rocks/smartvpn@1.19.4':
dependencies: dependencies:
'@push.rocks/smartnftables': 1.1.0 '@push.rocks/smartnftables': 1.2.0
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.4.0 '@push.rocks/smartrust': 1.4.0
+67
View File
@@ -1,6 +1,7 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js'; import { DcRouter } from '../ts/classes.dcrouter.js';
import { VpnManager } from '../ts/vpn/classes.vpn-manager.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 () => { tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
const manager = new VpnManager({ forwardingMode: 'socket' }); 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(); 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() export default tap.start()
+12 -1
View File
@@ -740,10 +740,14 @@ export class DcRouter {
// VPN Server: optional, depends on SmartProxy // VPN Server: optional, depends on SmartProxy
if (this.options.vpnConfig?.enabled) { if (this.options.vpnConfig?.enabled) {
const vpnServiceDeps = ['SmartProxy'];
if (this.options.dbConfig?.enabled !== false) {
vpnServiceDeps.push('ConfigManagers');
}
this.serviceManager.addService( this.serviceManager.addService(
new plugins.taskbuffer.Service('VpnServer') new plugins.taskbuffer.Service('VpnServer')
.optional() .optional()
.dependsOn('SmartProxy') .dependsOn(...vpnServiceDeps)
.withStart(async () => { .withStart(async () => {
await this.setupVpnServer(); await this.setupVpnServer();
}) })
@@ -2418,6 +2422,13 @@ export class DcRouter {
return; 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...'); logger.log('info', 'Setting up VPN server...');
this.vpnManager = new VpnManager({ this.vpnManager = new VpnManager({
+8 -6
View File
@@ -607,19 +607,21 @@ export class RouteConfigManager {
route: plugins.smartproxy.IRouteConfig, route: plugins.smartproxy.IRouteConfig,
routeId?: string, routeId?: string,
): plugins.smartproxy.IRouteConfig { ): plugins.smartproxy.IRouteConfig {
const vpnCallback = this.getVpnClientIpsForRoute;
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig; const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route; if (!dcRoute.vpnOnly) return route;
const vpnEntries = vpnCallback(dcRoute, routeId); const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
const existingEntries = route.security?.ipAllowList || []; const existingBlockList = route.security?.ipBlockList || [];
const ipBlockList = vpnEntries.length
? existingBlockList
: [...new Set([...existingBlockList, '*'])];
return { return {
...route, ...route,
security: { security: {
...route.security, ...route.security,
ipAllowList: [...existingEntries, ...vpnEntries], ipAllowList: vpnEntries,
ipBlockList,
}, },
}; };
} }
@@ -88,6 +88,8 @@ export class TargetProfileHandler {
routeRefs: dataArg.routeRefs, routeRefs: dataArg.routeRefs,
createdBy: userId, createdBy: userId,
}); });
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
return { success: true, id }; return { success: true, id };
}, },
), ),
+56 -25
View File
@@ -111,6 +111,7 @@ export class VpnManager {
const subnet = this.getSubnet(); const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820; const wgListenPort = this.config.wgListenPort ?? 51820;
const serverEndpoint = this.getWireGuardServerEndpoint();
const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp); const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') { if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
@@ -133,21 +134,19 @@ export class VpnManager {
: { default: 'forceTarget' as const, target: '127.0.0.1' }; : { default: 'forceTarget' as const, target: '127.0.0.1' };
const serverConfig: plugins.smartvpn.IVpnServerConfig = { 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, privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey, publicKey: this.serverKeys.noisePublicKey,
subnet, subnet,
dns: this.config.dns, dns: this.config.dns,
forwardingMode: forwardingMode as any, forwardingMode: forwardingMode as any,
transportMode: 'all', transportMode: 'wireguard',
wgPrivateKey: this.serverKeys.wgPrivateKey, wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort, wgListenPort,
clients: clientEntries, clients: clientEntries,
socketForwardProxyProtocol: !isBridge, socketForwardProxyProtocol: !isBridge,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy), destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint: this.config.serverEndpoint serverEndpoint,
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
clientAllowedIPs: [subnet], clientAllowedIPs: [subnet],
// Bridge-specific config // Bridge-specific config
...(isBridge ? { ...(isBridge ? {
@@ -187,7 +186,7 @@ export class VpnManager {
} catch { } catch {
// Ignore stop errors // Ignore stop errors
} }
this.vpnServer.stop(); await this.vpnServer.stop();
this.vpnServer = undefined; this.vpnServer = undefined;
} }
this.resolvedForwardingMode = undefined; this.resolvedForwardingMode = undefined;
@@ -244,14 +243,10 @@ export class VpnManager {
vlanId: doc.vlanId, vlanId: doc.vlanId,
}); });
// Override AllowedIPs with per-client values based on target profiles bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) { bundle.wireguardConfig,
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []); doc.targetProfileIds || [],
bundle.wireguardConfig = bundle.wireguardConfig.replace( );
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
// Persist client entry (including WG private key for export/QR) // Persist client entry (including WG private key for export/QR)
doc.clientId = bundle.entry.clientId; doc.clientId = bundle.entry.clientId;
@@ -381,9 +376,13 @@ export class VpnManager {
public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> { public async rotateClientKey(clientId: string): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) throw new Error('VPN server not running'); if (!this.vpnServer) throw new Error('VPN server not running');
const bundle = await this.vpnServer.rotateClientKey(clientId); 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) // Update persisted entry with new keys (including private key for export/QR)
const client = this.clients.get(clientId);
if (client) { if (client) {
client.noisePublicKey = bundle.entry.publicKey; client.noisePublicKey = bundle.entry.publicKey;
client.wgPublicKey = bundle.entry.wgPublicKey || ''; client.wgPublicKey = bundle.entry.wgPublicKey || '';
@@ -414,15 +413,7 @@ export class VpnManager {
); );
} }
// Override AllowedIPs with per-client values based on target profiles config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []);
if (this.config.getClientAllowedIPs) {
const profileIds = persisted?.targetProfileIds || [];
const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
config = config.replace(
/AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`,
);
}
} }
return config; 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<string> {
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 helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> { private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
@@ -532,7 +563,7 @@ export class VpnManager {
const noiseKeys = await tempServer.generateKeypair(); const noiseKeys = await tempServer.generateKeypair();
const wgKeys = await tempServer.generateWgKeypair(); const wgKeys = await tempServer.generateWgKeypair();
tempServer.stop(); await tempServer.stop();
const doc = stored || new VpnServerKeysDoc(); const doc = stored || new VpnServerKeysDoc();
doc.noisePrivateKey = noiseKeys.privateKey; doc.noisePrivateKey = noiseKeys.privateKey;