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

@@ -1,5 +1,13 @@
# Changelog
## 2026-04-01 - 12.1.0 - feat(vpn)
add per-client routing controls and bridge forwarding support for VPN clients
- adds persisted per-client VPN settings for SmartProxy enforcement, destination allow/block lists, host IP assignment, DHCP/static IP selection, and VLAN options
- passes new VPN routing and bridge configuration through request handlers, app state, and the ops UI for creating, editing, and viewing clients
- supports bridge and hybrid forwarding modes in the VPN manager, including auto-upgrading to hybrid when clients request host IP access
- updates smartvpn and dees-catalog dependencies to support the new VPN forwarding capabilities
## 2026-03-31 - 12.0.0 - BREAKING CHANGE(db)
replace StorageManager and CacheDb with a unified smartdata-backed database layer

View File

@@ -35,7 +35,7 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.49.0",
"@design.estate/dees-catalog": "^3.49.1",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",
@@ -59,7 +59,7 @@
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.17.1",
"@push.rocks/smartvpn": "1.19.1",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0",

55
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
specifier: ^3.49.0
version: 3.49.0(@tiptap/pm@2.27.2)
specifier: ^3.49.1
version: 3.49.1(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -96,8 +96,8 @@ importers:
specifier: ^3.0.9
version: 3.0.9
'@push.rocks/smartvpn':
specifier: 1.17.1
version: 1.17.1
specifier: 1.19.1
version: 1.19.1
'@push.rocks/taskbuffer':
specifier: ^8.0.2
version: 8.0.2
@@ -350,8 +350,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.49.0':
resolution: {integrity: sha512-ZtHroyBZekv+jVSDmtGOzoGVI+EA55kd5EcSsNmUByxN3UMcFFeg62QRNzm3RHpz01u1Zfynm0bN9E44pk6FDQ==}
'@design.estate/dees-catalog@3.49.1':
resolution: {integrity: sha512-YyaRu6uep5wiqx2wnQeeWXstNRkkEfTAH7uA9XiWwM+TwbWH83esu5PR8L+J4akz3VsSW26JlfRI+7GoWTs2mw==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -359,6 +359,9 @@ packages:
'@design.estate/dees-domtools@2.5.3':
resolution: {integrity: sha512-E30vu4Cl49nSQAFlazT2Eo9VVR3VG3RGc2NLmVe7i8NMC/Sm2HQisXlpKMZYBOoY8YwdG8W2MiXaD0lbGyibCw==}
'@design.estate/dees-domtools@2.5.4':
resolution: {integrity: sha512-IGyVKl1XMXHVCpPQXX6wSnGbD4S2Q1XkJCuuXZotu4Q86HTiALyfyZi0RouCKv3zxCSMvZHpFWVoh2DgF/3R3g==}
'@design.estate/dees-element@2.2.4':
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
@@ -1339,8 +1342,8 @@ packages:
'@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartvpn@1.17.1':
resolution: {integrity: sha512-oTOxNUrh+doL9AocgPnMbcYZKrWJhCeuqNotu1RfiteIV9DDdznvA+cl3nOgxD/ImUYrFPz6PUp5BEMogWcS8Q==}
'@push.rocks/smartvpn@1.19.1':
resolution: {integrity: sha512-zvC/rrba1tZcXzzzrhX97BEUN6smo1KcqcULu6ZAGpDNhR7c5PU8oWwFxIy33UdDf5NLActkS0L3dq42sGB8nw==}
'@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -4336,7 +4339,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260317.1
'@design.estate/dees-catalog': 3.49.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.49.1(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
@@ -4865,9 +4868,9 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.49.0(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.49.1(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.3
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
'@fortawesome/fontawesome-svg-core': 7.2.0
@@ -4933,6 +4936,32 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-domtools@2.5.4':
dependencies:
'@api.global/typedrequest': 3.3.0
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartjson': 6.0.0
'@push.rocks/smartmarkdown': 3.0.3
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrouter': 1.3.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstate': 2.3.0
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarturl': 3.1.0
'@push.rocks/webrequest': 4.0.5
'@push.rocks/websetup': 3.0.19
'@push.rocks/webstore': 2.0.21
'@tempfix/lenis': 1.3.20
lit: 3.3.2
sweet-scroll: 4.0.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@design.estate/dees-element@2.2.4':
dependencies:
'@design.estate/dees-domtools': 2.5.3
@@ -6622,7 +6651,7 @@ snapshots:
'@types/semver': 7.7.1
semver: 7.7.4
'@push.rocks/smartvpn@1.17.1':
'@push.rocks/smartvpn@1.19.1':
dependencies:
'@push.rocks/smartnftables': 1.1.0
'@push.rocks/smartpath': 6.0.0
@@ -6867,7 +6896,7 @@ snapshots:
'@serve.zone/catalog@2.9.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.49.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.49.1(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.3
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0

View File

@@ -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.'
}

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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) {

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> {

View File

@@ -10,6 +10,14 @@ export interface IVpnClient {
createdAt: number;
updatedAt: number;
expiresAt?: string;
forceDestinationSmartproxy: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}
/**

View File

@@ -51,6 +51,14 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
};
response: {
success: boolean;
@@ -74,6 +82,14 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
clientId: string;
description?: string;
serverDefinedClientTags?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
};
response: {
success: boolean;

View File

@@ -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.'
}

View File

@@ -984,6 +984,14 @@ export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
@@ -998,6 +1006,14 @@ export const createVpnClientAction = vpnStatePart.createAction<{
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,
});
if (!response.success) {
@@ -1066,6 +1082,14 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
clientId: string;
description?: string;
serverDefinedClientTags?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
useDhcp?: boolean;
staticIp?: string;
forceVlan?: boolean;
vlanId?: number;
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
@@ -1080,6 +1104,14 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
clientId: 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,
});
if (!response.success) {

View File

@@ -13,6 +13,31 @@ import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
/**
* Toggle form field visibility based on checkbox states.
* Used in Create and Edit VPN client dialogs.
*/
function setupFormVisibility(formEl: any) {
const show = 'flex'; // match dees-form's flex layout
const updateVisibility = async () => {
const data = await formEl.collectFormData();
const contentEl = formEl.closest('.content') || formEl.parentElement;
if (!contentEl) return;
const hostIpGroup = contentEl.querySelector('.hostIpGroup') as HTMLElement;
const hostIpDetails = contentEl.querySelector('.hostIpDetails') as HTMLElement;
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
if (hostIpGroup) hostIpGroup.style.display = data.forceDestinationSmartproxy ? 'none' : show;
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
if (aclGroup) aclGroup.style.display = data.allowAdditionalAcls ? show : 'none';
};
formEl.changeSubject.subscribe(() => updateVisibility());
updateVisibility();
}
declare global {
interface HTMLElementTagNameMap {
'ops-view-vpn': OpsViewVpn;
@@ -289,9 +314,18 @@ export class OpsViewVpn extends DeesElement {
} else {
statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
}
let routingHtml;
if (client.forceDestinationSmartproxy !== false) {
routingHtml = html`<span class="statusBadge enabled">SmartProxy</span>`;
} else if (client.useHostIp) {
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#f3e8ff', '#3b0764')}; color: ${cssManager.bdTheme('#7c3aed', '#c084fc')};">Host IP</span>`;
} else {
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">Direct</span>`;
}
return {
'Client ID': client.clientId,
'Status': statusHtml,
'Routing': routingHtml,
'VPN IP': client.assignedIp || '-',
'Tags': client.serverDefinedClientTags?.length
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
@@ -307,13 +341,32 @@ export class OpsViewVpn extends DeesElement {
type: ['header'],
actionFunc: async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
const createModal = await DeesModal.createAndShow({
heading: 'Create VPN Client',
content: html`
<dees-form>
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox>
<div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
<div class="hostIpDetails" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${false}></dees-input-checkbox>
<div class="staticIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'staticIp'} .label=${'Static IP'}></dees-input-text>
</div>
<dees-input-checkbox .key=${'forceVlan'} .label=${'Force VLAN'} .value=${false}></dees-input-checkbox>
<div class="vlanIdGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'vlanId'} .label=${'VLAN ID'}></dees-input-text>
</div>
</div>
</div>
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${false}></dees-input-checkbox>
<div class="aclGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'}></dees-input-text>
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'}></dees-input-text>
</div>
</dees-form>
`,
menuOptions: [
@@ -333,16 +386,47 @@ export class OpsViewVpn extends DeesElement {
const serverDefinedClientTags = data.tags
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
// Apply conditional logic based on checkbox states
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false);
const vlanId = forceVlan && data.vlanId ? parseInt(data.vlanId, 10) : undefined;
const allowAcls = data.allowAdditionalAcls ?? false;
const destinationAllowList = allowAcls && data.destinationAllowList
? data.destinationAllowList.split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
const destinationBlockList = allowAcls && data.destinationBlockList
? data.destinationBlockList.split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId,
description: data.description || undefined,
serverDefinedClientTags,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
staticIp,
forceVlan: forceVlan || undefined,
vlanId,
destinationAllowList,
destinationBlockList,
});
await modalArg.destroy();
},
},
],
});
// Setup conditional form visibility after modal renders
const createForm = createModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
if (createForm) {
await createForm.updateComplete;
setupFormVisibility(createForm);
}
},
},
{
@@ -396,6 +480,13 @@ export class OpsViewVpn extends DeesElement {
` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</span></div>
${client.useHostIp ? html`
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
<div class="infoItem"><span class="infoLabel">VLAN</span><span class="infoValue">${client.forceVlan && client.vlanId != null ? `VLAN ${client.vlanId}` : 'No VLAN'}</span></div>
` : ''}
<div class="infoItem"><span class="infoLabel">Allow List</span><span class="infoValue">${client.destinationAllowList?.length ? client.destinationAllowList.join(', ') : 'None'}</span></div>
<div class="infoItem"><span class="infoLabel">Block List</span><span class="infoValue">${client.destinationBlockList?.length ? client.destinationBlockList.join(', ') : 'None'}</span></div>
<div class="infoItem"><span class="infoLabel">Created</span><span class="infoValue">${new Date(client.createdAt).toLocaleString()}</span></div>
<div class="infoItem"><span class="infoLabel">Updated</span><span class="infoValue">${new Date(client.updatedAt).toLocaleString()}</span></div>
</div>
@@ -553,12 +644,41 @@ export class OpsViewVpn extends DeesElement {
const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? '';
const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
DeesModal.createAndShow({
const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
const currentUseHostIp = client.useHostIp ?? false;
const currentUseDhcp = client.useDhcp ?? false;
const currentStaticIp = client.staticIp ?? '';
const currentForceVlan = client.forceVlan ?? false;
const currentVlanId = client.vlanId != null ? String(client.vlanId) : '';
const currentAllowList = client.destinationAllowList?.join(', ') ?? '';
const currentBlockList = client.destinationBlockList?.join(', ') ?? '';
const currentAllowAcls = (client.destinationAllowList?.length ?? 0) > 0
|| (client.destinationBlockList?.length ?? 0) > 0;
const editModal = await DeesModal.createAndShow({
heading: `Edit: ${client.clientId}`,
content: html`
<dees-form>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text>
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox>
<div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
<div class="hostIpDetails" style="display: ${currentUseHostIp ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${currentUseDhcp}></dees-input-checkbox>
<div class="staticIpGroup" style="display: ${currentUseDhcp ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'staticIp'} .label=${'Static IP'} .value=${currentStaticIp}></dees-input-text>
</div>
<dees-input-checkbox .key=${'forceVlan'} .label=${'Force VLAN'} .value=${currentForceVlan}></dees-input-checkbox>
<div class="vlanIdGroup" style="display: ${currentForceVlan ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'vlanId'} .label=${'VLAN ID'} .value=${currentVlanId}></dees-input-text>
</div>
</div>
</div>
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${currentAllowAcls}></dees-input-checkbox>
<div class="aclGroup" style="display: ${currentAllowAcls ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'} .value=${currentAllowList}></dees-input-text>
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'} .value=${currentBlockList}></dees-input-text>
</div>
</dees-form>
`,
menuOptions: [
@@ -573,16 +693,47 @@ export class OpsViewVpn extends DeesElement {
const serverDefinedClientTags = data.tags
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
// Apply conditional logic based on checkbox states
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false);
const vlanId = forceVlan && data.vlanId ? parseInt(data.vlanId, 10) : undefined;
const allowAcls = data.allowAdditionalAcls ?? false;
const destinationAllowList = allowAcls && data.destinationAllowList
? data.destinationAllowList.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const destinationBlockList = allowAcls && data.destinationBlockList
? data.destinationBlockList.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
clientId: client.clientId,
description: data.description || undefined,
serverDefinedClientTags,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
staticIp,
forceVlan: forceVlan || undefined,
vlanId,
destinationAllowList,
destinationBlockList,
});
await modalArg.destroy();
},
},
],
});
// Setup conditional form visibility for edit dialog
const editForm = editModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
if (editForm) {
await editForm.updateComplete;
setupFormVisibility(editForm);
}
},
},
{