feat(vpn): add per-client routing controls and bridge forwarding support for VPN clients

This commit is contained in:
2026-04-01 05:13:01 +00:00
parent 81f8e543e1
commit c1452131fa
13 changed files with 483 additions and 25 deletions

View File

@@ -30,6 +30,17 @@ export interface IVpnManagerConfig {
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
/** LAN subnet CIDR for bridge mode (e.g., '192.168.1.0/24') */
bridgeLanSubnet?: string;
/** Physical network interface for bridge mode (auto-detected if omitted) */
bridgePhysicalInterface?: string;
/** Start of VPN client IP range in LAN subnet (host offset, default: 200) */
bridgeIpRangeStart?: number;
/** End of VPN client IP range in LAN subnet (host offset, default: 250) */
bridgeIpRangeEnd?: number;
}
/**
@@ -69,8 +80,12 @@ export class VpnManager {
// Build client entries for the daemon
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
let anyClientUsesHostIp = false;
for (const client of this.clients.values()) {
clientEntries.push({
if (client.useHostIp) {
anyClientUsesHostIp = true;
}
const entry: plugins.smartvpn.IClientEntry = {
clientId: client.clientId,
publicKey: client.noisePublicKey,
wgPublicKey: client.wgPublicKey,
@@ -79,35 +94,65 @@ export class VpnManager {
description: client.description,
assignedIp: client.assignedIp,
expiresAt: client.expiresAt,
});
security: this.buildClientSecurity(client),
};
// Pass per-client bridge fields if present (for hybrid/bridge mode)
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
clientEntries.push(entry);
}
const subnet = this.getSubnet();
const wgListenPort = this.config.wgListenPort ?? 51820;
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
let configuredMode = this.config.forwardingMode ?? 'socket';
if (anyClientUsesHostIp && configuredMode === 'socket') {
configuredMode = 'hybrid';
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
}
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
const isBridge = forwardingMode === 'bridge';
// Create and start VpnServer
this.vpnServer = new plugins.smartvpn.VpnServer({
transport: { transport: 'stdio' },
});
// Default destination policy: bridge mode allows traffic through directly,
// socket mode forces traffic to SmartProxy on 127.0.0.1
const defaultDestinationPolicy: plugins.smartvpn.IDestinationPolicy = isBridge
? { default: 'allow' as const }
: { 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
privateKey: this.serverKeys.noisePrivateKey,
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: 'socket',
forwardingMode: forwardingMode as any,
transportMode: 'all',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: true,
destinationPolicy: this.config.destinationPolicy
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
socketForwardProxyProtocol: !isBridge,
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
serverEndpoint: this.config.serverEndpoint
? `${this.config.serverEndpoint}:${wgListenPort}`
: undefined,
clientAllowedIPs: [subnet],
// Bridge-specific config
...(isBridge ? {
bridgeLanSubnet: this.config.bridgeLanSubnet,
bridgePhysicalInterface: this.config.bridgePhysicalInterface,
bridgeIpRangeStart: this.config.bridgeIpRangeStart,
bridgeIpRangeEnd: this.config.bridgeIpRangeEnd,
} : {}),
};
await this.vpnServer.start(serverConfig);
@@ -154,6 +199,14 @@ export class VpnManager {
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}): Promise<plugins.smartvpn.IClientConfigBundle> {
if (!this.vpnServer) {
throw new Error('VPN server not running');
@@ -188,9 +241,39 @@ export class VpnManager {
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.expiresAt = bundle.entry.expiresAt;
if (opts.forceDestinationSmartproxy !== undefined) {
doc.forceDestinationSmartproxy = opts.forceDestinationSmartproxy;
}
if (opts.destinationAllowList !== undefined) {
doc.destinationAllowList = opts.destinationAllowList;
}
if (opts.destinationBlockList !== undefined) {
doc.destinationBlockList = opts.destinationBlockList;
}
if (opts.useHostIp !== undefined) {
doc.useHostIp = opts.useHostIp;
}
if (opts.useDhcp !== undefined) {
doc.useDhcp = opts.useDhcp;
}
if (opts.staticIp !== undefined) {
doc.staticIp = opts.staticIp;
}
if (opts.forceVlan !== undefined) {
doc.forceVlan = opts.forceVlan;
}
if (opts.vlanId !== undefined) {
doc.vlanId = opts.vlanId;
}
this.clients.set(doc.clientId, doc);
await this.persistClient(doc);
// Sync per-client security to the running daemon
const security = this.buildClientSecurity(doc);
if (security.destinationPolicy) {
await this.vpnServer!.updateClient(doc.clientId, { security });
}
this.config.onClientChanged?.();
return bundle;
}
@@ -254,13 +337,36 @@ export class VpnManager {
public async updateClient(clientId: string, update: {
description?: string;
serverDefinedClientTags?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}): Promise<void> {
const client = this.clients.get(clientId);
if (!client) throw new Error(`Client not found: ${clientId}`);
if (update.description !== undefined) client.description = update.description;
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
if (update.useDhcp !== undefined) client.useDhcp = update.useDhcp;
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
client.updatedAt = Date.now();
await this.persistClient(client);
// Sync per-client security to the running daemon
if (this.vpnServer) {
const security = this.buildClientSecurity(client);
await this.vpnServer.updateClient(clientId, { security });
}
this.config.onClientChanged?.();
}
@@ -378,6 +484,37 @@ export class VpnManager {
};
}
// ── Per-client security ────────────────────────────────────────────────
/**
* Build per-client security settings for the smartvpn daemon.
* Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists)
* to smartvpn's IClientSecurity with a destinationPolicy.
*/
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
const security: plugins.smartvpn.IClientSecurity = {};
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
if (!forceSmartproxy) {
// Client traffic goes directly — not forced to SmartProxy
security.destinationPolicy = {
default: 'allow' as const,
blockList: client.destinationBlockList,
};
} else if (client.destinationAllowList?.length || client.destinationBlockList?.length) {
// Client is forced to SmartProxy, but with per-client allow/block overrides
security.destinationPolicy = {
default: 'forceTarget' as const,
target: '127.0.0.1',
allowList: client.destinationAllowList,
blockList: client.destinationBlockList,
};
}
// else: no per-client policy, server-wide applies
return security;
}
// ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {