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
+12 -1
View File
@@ -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({
+8 -6
View File
@@ -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 };
},
),
+56 -25
View File
@@ -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;