fix(vpn): harden VPN route access and wireguard client configuration handling
This commit is contained in:
+31
-9
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+6
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+12
-1
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<plugins.smartvpn.IClientConfigBundle> {
|
||||
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<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 async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user