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