feat(vpn): add per-client routing controls and bridge forwarding support for VPN clients
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '12.0.0',
|
||||
version: '12.1.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -205,6 +205,17 @@ export interface IDcRouterOptions {
|
||||
allowList?: string[];
|
||||
blockList?: 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2085,6 +2096,11 @@ export class DcRouter {
|
||||
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
||||
initialClients: this.options.vpnConfig.clients,
|
||||
destinationPolicy: this.options.vpnConfig.destinationPolicy,
|
||||
forwardingMode: this.options.vpnConfig.forwardingMode,
|
||||
bridgeLanSubnet: this.options.vpnConfig.bridgeLanSubnet,
|
||||
bridgePhysicalInterface: this.options.vpnConfig.bridgePhysicalInterface,
|
||||
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so tag-based ipAllowLists get updated
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
|
||||
@@ -39,6 +39,30 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public forceDestinationSmartproxy: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationAllowList?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationBlockList?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useHostIp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useDhcp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public staticIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public forceVlan?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public vlanId?: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ export class VpnHandler {
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
forceDestinationSmartproxy: c.forceDestinationSmartproxy ?? true,
|
||||
destinationAllowList: c.destinationAllowList,
|
||||
destinationBlockList: c.destinationBlockList,
|
||||
useHostIp: c.useHostIp,
|
||||
useDhcp: c.useDhcp,
|
||||
staticIp: c.staticIp,
|
||||
forceVlan: c.forceVlan,
|
||||
vlanId: c.vlanId,
|
||||
}));
|
||||
return { clients };
|
||||
},
|
||||
@@ -114,8 +122,21 @@ export class VpnHandler {
|
||||
clientId: dataArg.clientId,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
description: dataArg.description,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
useDhcp: dataArg.useDhcp,
|
||||
staticIp: dataArg.staticIp,
|
||||
forceVlan: dataArg.forceVlan,
|
||||
vlanId: dataArg.vlanId,
|
||||
});
|
||||
|
||||
// Retrieve the persisted doc to get dcrouter-level fields
|
||||
const persistedClient = manager.listClients().find(
|
||||
(c) => c.clientId === bundle.entry.clientId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
client: {
|
||||
@@ -127,6 +148,14 @@ export class VpnHandler {
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: bundle.entry.expiresAt,
|
||||
forceDestinationSmartproxy: persistedClient?.forceDestinationSmartproxy ?? true,
|
||||
destinationAllowList: persistedClient?.destinationAllowList,
|
||||
destinationBlockList: persistedClient?.destinationBlockList,
|
||||
useHostIp: persistedClient?.useHostIp,
|
||||
useDhcp: persistedClient?.useDhcp,
|
||||
staticIp: persistedClient?.staticIp,
|
||||
forceVlan: persistedClient?.forceVlan,
|
||||
vlanId: persistedClient?.vlanId,
|
||||
},
|
||||
wireguardConfig: bundle.wireguardConfig,
|
||||
};
|
||||
@@ -151,6 +180,14 @@ export class VpnHandler {
|
||||
await manager.updateClient(dataArg.clientId, {
|
||||
description: dataArg.description,
|
||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
|
||||
destinationAllowList: dataArg.destinationAllowList,
|
||||
destinationBlockList: dataArg.destinationBlockList,
|
||||
useHostIp: dataArg.useHostIp,
|
||||
useDhcp: dataArg.useDhcp,
|
||||
staticIp: dataArg.staticIp,
|
||||
forceVlan: dataArg.forceVlan,
|
||||
vlanId: dataArg.vlanId,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user