feat(vpn): add per-client routing controls and bridge forwarding support for VPN clients
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-03-31 - 12.0.0 - BREAKING CHANGE(db)
|
||||||
replace StorageManager and CacheDb with a unified smartdata-backed database layer
|
replace StorageManager and CacheDb with a unified smartdata-backed database layer
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"@api.global/typedserver": "^8.4.6",
|
"@api.global/typedserver": "^8.4.6",
|
||||||
"@api.global/typedsocket": "^4.1.2",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@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",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.4.0",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.3.0",
|
"@push.rocks/smartstate": "^2.3.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartvpn": "1.17.1",
|
"@push.rocks/smartvpn": "1.19.1",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.9.0",
|
"@serve.zone/catalog": "^2.9.0",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
|
|||||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
|||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.1.0
|
version: 7.1.0
|
||||||
'@design.estate/dees-catalog':
|
'@design.estate/dees-catalog':
|
||||||
specifier: ^3.49.0
|
specifier: ^3.49.1
|
||||||
version: 3.49.0(@tiptap/pm@2.27.2)
|
version: 3.49.1(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-element':
|
'@design.estate/dees-element':
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
@@ -96,8 +96,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.17.1
|
specifier: 1.19.1
|
||||||
version: 1.17.1
|
version: 1.19.1
|
||||||
'@push.rocks/taskbuffer':
|
'@push.rocks/taskbuffer':
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
@@ -350,8 +350,8 @@ packages:
|
|||||||
'@configvault.io/interfaces@1.0.17':
|
'@configvault.io/interfaces@1.0.17':
|
||||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||||
|
|
||||||
'@design.estate/dees-catalog@3.49.0':
|
'@design.estate/dees-catalog@3.49.1':
|
||||||
resolution: {integrity: sha512-ZtHroyBZekv+jVSDmtGOzoGVI+EA55kd5EcSsNmUByxN3UMcFFeg62QRNzm3RHpz01u1Zfynm0bN9E44pk6FDQ==}
|
resolution: {integrity: sha512-YyaRu6uep5wiqx2wnQeeWXstNRkkEfTAH7uA9XiWwM+TwbWH83esu5PR8L+J4akz3VsSW26JlfRI+7GoWTs2mw==}
|
||||||
|
|
||||||
'@design.estate/dees-comms@1.0.30':
|
'@design.estate/dees-comms@1.0.30':
|
||||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||||
@@ -359,6 +359,9 @@ packages:
|
|||||||
'@design.estate/dees-domtools@2.5.3':
|
'@design.estate/dees-domtools@2.5.3':
|
||||||
resolution: {integrity: sha512-E30vu4Cl49nSQAFlazT2Eo9VVR3VG3RGc2NLmVe7i8NMC/Sm2HQisXlpKMZYBOoY8YwdG8W2MiXaD0lbGyibCw==}
|
resolution: {integrity: sha512-E30vu4Cl49nSQAFlazT2Eo9VVR3VG3RGc2NLmVe7i8NMC/Sm2HQisXlpKMZYBOoY8YwdG8W2MiXaD0lbGyibCw==}
|
||||||
|
|
||||||
|
'@design.estate/dees-domtools@2.5.4':
|
||||||
|
resolution: {integrity: sha512-IGyVKl1XMXHVCpPQXX6wSnGbD4S2Q1XkJCuuXZotu4Q86HTiALyfyZi0RouCKv3zxCSMvZHpFWVoh2DgF/3R3g==}
|
||||||
|
|
||||||
'@design.estate/dees-element@2.2.4':
|
'@design.estate/dees-element@2.2.4':
|
||||||
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
||||||
|
|
||||||
@@ -1339,8 +1342,8 @@ packages:
|
|||||||
'@push.rocks/smartversion@3.0.5':
|
'@push.rocks/smartversion@3.0.5':
|
||||||
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
||||||
|
|
||||||
'@push.rocks/smartvpn@1.17.1':
|
'@push.rocks/smartvpn@1.19.1':
|
||||||
resolution: {integrity: sha512-oTOxNUrh+doL9AocgPnMbcYZKrWJhCeuqNotu1RfiteIV9DDdznvA+cl3nOgxD/ImUYrFPz6PUp5BEMogWcS8Q==}
|
resolution: {integrity: sha512-zvC/rrba1tZcXzzzrhX97BEUN6smo1KcqcULu6ZAGpDNhR7c5PU8oWwFxIy33UdDf5NLActkS0L3dq42sGB8nw==}
|
||||||
|
|
||||||
'@push.rocks/smartwatch@6.4.0':
|
'@push.rocks/smartwatch@6.4.0':
|
||||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||||
@@ -4336,7 +4339,7 @@ snapshots:
|
|||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||||
'@cloudflare/workers-types': 4.20260317.1
|
'@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
|
'@design.estate/dees-comms': 1.0.30
|
||||||
'@push.rocks/lik': 6.4.0
|
'@push.rocks/lik': 6.4.0
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
@@ -4865,9 +4868,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@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:
|
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-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.0
|
'@design.estate/dees-wcctools': 3.8.0
|
||||||
'@fortawesome/fontawesome-svg-core': 7.2.0
|
'@fortawesome/fontawesome-svg-core': 7.2.0
|
||||||
@@ -4933,6 +4936,32 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- 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':
|
'@design.estate/dees-element@2.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.5.3
|
'@design.estate/dees-domtools': 2.5.3
|
||||||
@@ -6622,7 +6651,7 @@ snapshots:
|
|||||||
'@types/semver': 7.7.1
|
'@types/semver': 7.7.1
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
|
|
||||||
'@push.rocks/smartvpn@1.17.1':
|
'@push.rocks/smartvpn@1.19.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartnftables': 1.1.0
|
'@push.rocks/smartnftables': 1.1.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
@@ -6867,7 +6896,7 @@ snapshots:
|
|||||||
|
|
||||||
'@serve.zone/catalog@2.9.0(@tiptap/pm@2.27.2)':
|
'@serve.zone/catalog@2.9.0(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
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-domtools': 2.5.3
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.0
|
'@design.estate/dees-wcctools': 3.8.0
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.0.0',
|
version: '12.1.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,17 @@ export interface IDcRouterOptions {
|
|||||||
allowList?: string[];
|
allowList?: string[];
|
||||||
blockList?: 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,
|
serverEndpoint: this.options.vpnConfig.serverEndpoint,
|
||||||
initialClients: this.options.vpnConfig.clients,
|
initialClients: this.options.vpnConfig.clients,
|
||||||
destinationPolicy: this.options.vpnConfig.destinationPolicy,
|
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: () => {
|
onClientChanged: () => {
|
||||||
// Re-apply routes so tag-based ipAllowLists get updated
|
// Re-apply routes so tag-based ipAllowLists get updated
|
||||||
this.routeConfigManager?.applyRoutes();
|
this.routeConfigManager?.applyRoutes();
|
||||||
|
|||||||
@@ -39,6 +39,30 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
|||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public expiresAt?: string;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ export class VpnHandler {
|
|||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
updatedAt: c.updatedAt,
|
updatedAt: c.updatedAt,
|
||||||
expiresAt: c.expiresAt,
|
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 };
|
return { clients };
|
||||||
},
|
},
|
||||||
@@ -114,8 +122,21 @@ export class VpnHandler {
|
|||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
description: dataArg.description,
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
client: {
|
client: {
|
||||||
@@ -127,6 +148,14 @@ export class VpnHandler {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
expiresAt: bundle.entry.expiresAt,
|
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,
|
wireguardConfig: bundle.wireguardConfig,
|
||||||
};
|
};
|
||||||
@@ -151,6 +180,14 @@ export class VpnHandler {
|
|||||||
await manager.updateClient(dataArg.clientId, {
|
await manager.updateClient(dataArg.clientId, {
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
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 };
|
return { success: true };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ export interface IVpnManagerConfig {
|
|||||||
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||||
* When not set, defaults to [subnet]. */
|
* When not set, defaults to [subnet]. */
|
||||||
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>;
|
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
|
// Build client entries for the daemon
|
||||||
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
|
const clientEntries: plugins.smartvpn.IClientEntry[] = [];
|
||||||
|
let anyClientUsesHostIp = false;
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
clientEntries.push({
|
if (client.useHostIp) {
|
||||||
|
anyClientUsesHostIp = true;
|
||||||
|
}
|
||||||
|
const entry: plugins.smartvpn.IClientEntry = {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
publicKey: client.noisePublicKey,
|
publicKey: client.noisePublicKey,
|
||||||
wgPublicKey: client.wgPublicKey,
|
wgPublicKey: client.wgPublicKey,
|
||||||
@@ -79,35 +94,65 @@ export class VpnManager {
|
|||||||
description: client.description,
|
description: client.description,
|
||||||
assignedIp: client.assignedIp,
|
assignedIp: client.assignedIp,
|
||||||
expiresAt: client.expiresAt,
|
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 subnet = this.getSubnet();
|
||||||
const wgListenPort = this.config.wgListenPort ?? 51820;
|
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
|
// Create and start VpnServer
|
||||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||||
transport: { transport: 'stdio' },
|
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 = {
|
const serverConfig: plugins.smartvpn.IVpnServerConfig = {
|
||||||
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field
|
||||||
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: 'socket',
|
forwardingMode: forwardingMode as any,
|
||||||
transportMode: 'all',
|
transportMode: 'all',
|
||||||
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
wgPrivateKey: this.serverKeys.wgPrivateKey,
|
||||||
wgListenPort,
|
wgListenPort,
|
||||||
clients: clientEntries,
|
clients: clientEntries,
|
||||||
socketForwardProxyProtocol: true,
|
socketForwardProxyProtocol: !isBridge,
|
||||||
destinationPolicy: this.config.destinationPolicy
|
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||||
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
|
|
||||||
serverEndpoint: this.config.serverEndpoint
|
serverEndpoint: this.config.serverEndpoint
|
||||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||||
: undefined,
|
: undefined,
|
||||||
clientAllowedIPs: [subnet],
|
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);
|
await this.vpnServer.start(serverConfig);
|
||||||
@@ -154,6 +199,14 @@ export class VpnManager {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
forceDestinationSmartproxy?: boolean;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
}): Promise<plugins.smartvpn.IClientConfigBundle> {
|
||||||
if (!this.vpnServer) {
|
if (!this.vpnServer) {
|
||||||
throw new Error('VPN server not running');
|
throw new Error('VPN server not running');
|
||||||
@@ -188,9 +241,39 @@ export class VpnManager {
|
|||||||
doc.createdAt = Date.now();
|
doc.createdAt = Date.now();
|
||||||
doc.updatedAt = Date.now();
|
doc.updatedAt = Date.now();
|
||||||
doc.expiresAt = bundle.entry.expiresAt;
|
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);
|
this.clients.set(doc.clientId, doc);
|
||||||
await this.persistClient(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?.();
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
@@ -254,13 +337,36 @@ export class VpnManager {
|
|||||||
public async updateClient(clientId: string, update: {
|
public async updateClient(clientId: string, update: {
|
||||||
description?: string;
|
description?: string;
|
||||||
serverDefinedClientTags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
|
forceDestinationSmartproxy?: boolean;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const client = this.clients.get(clientId);
|
const client = this.clients.get(clientId);
|
||||||
if (!client) throw new Error(`Client not found: ${clientId}`);
|
if (!client) throw new Error(`Client not found: ${clientId}`);
|
||||||
if (update.description !== undefined) client.description = update.description;
|
if (update.description !== undefined) client.description = update.description;
|
||||||
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags;
|
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();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
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?.();
|
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 helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export interface IVpnClient {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
forceDestinationSmartproxy: boolean;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
forceDestinationSmartproxy?: boolean;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -74,6 +82,14 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
serverDefinedClientTags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
|
forceDestinationSmartproxy?: boolean;
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
useHostIp?: boolean;
|
||||||
|
useDhcp?: boolean;
|
||||||
|
staticIp?: string;
|
||||||
|
forceVlan?: boolean;
|
||||||
|
vlanId?: number;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.0.0',
|
version: '12.1.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -984,6 +984,14 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
serverDefinedClientTags?: string[];
|
serverDefinedClientTags?: string[];
|
||||||
description?: 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> => {
|
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState()!;
|
const currentState = statePartArg.getState()!;
|
||||||
@@ -998,6 +1006,14 @@ export const createVpnClientAction = vpnStatePart.createAction<{
|
|||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
||||||
description: dataArg.description,
|
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) {
|
if (!response.success) {
|
||||||
@@ -1066,6 +1082,14 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
serverDefinedClientTags?: 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> => {
|
}>(async (statePartArg, dataArg, actionContext): Promise<IVpnState> => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState()!;
|
const currentState = statePartArg.getState()!;
|
||||||
@@ -1080,6 +1104,14 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
|
|||||||
clientId: dataArg.clientId,
|
clientId: dataArg.clientId,
|
||||||
description: dataArg.description,
|
description: dataArg.description,
|
||||||
serverDefinedClientTags: dataArg.serverDefinedClientTags,
|
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) {
|
if (!response.success) {
|
||||||
|
|||||||
@@ -13,6 +13,31 @@ import * as interfaces from '../../dist_ts_interfaces/index.js';
|
|||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from './shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
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 {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'ops-view-vpn': OpsViewVpn;
|
'ops-view-vpn': OpsViewVpn;
|
||||||
@@ -289,9 +314,18 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
} else {
|
} else {
|
||||||
statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
|
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 {
|
return {
|
||||||
'Client ID': client.clientId,
|
'Client ID': client.clientId,
|
||||||
'Status': statusHtml,
|
'Status': statusHtml,
|
||||||
|
'Routing': routingHtml,
|
||||||
'VPN IP': client.assignedIp || '-',
|
'VPN IP': client.assignedIp || '-',
|
||||||
'Tags': client.serverDefinedClientTags?.length
|
'Tags': client.serverDefinedClientTags?.length
|
||||||
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||||
@@ -307,13 +341,32 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
type: ['header'],
|
type: ['header'],
|
||||||
actionFunc: async () => {
|
actionFunc: async () => {
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
await DeesModal.createAndShow({
|
const createModal = await DeesModal.createAndShow({
|
||||||
heading: 'Create VPN Client',
|
heading: 'Create VPN Client',
|
||||||
content: html`
|
content: html`
|
||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
|
<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=${'description'} .label=${'Description'}></dees-input-text>
|
||||||
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></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>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
@@ -333,16 +386,47 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const serverDefinedClientTags = data.tags
|
const serverDefinedClientTags = data.tags
|
||||||
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||||
: undefined;
|
: 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, {
|
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
|
||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
serverDefinedClientTags,
|
serverDefinedClientTags,
|
||||||
|
forceDestinationSmartproxy: forceSmartproxy,
|
||||||
|
useHostIp: useHostIp || undefined,
|
||||||
|
useDhcp: useDhcp || undefined,
|
||||||
|
staticIp,
|
||||||
|
forceVlan: forceVlan || undefined,
|
||||||
|
vlanId,
|
||||||
|
destinationAllowList,
|
||||||
|
destinationBlockList,
|
||||||
});
|
});
|
||||||
await modalArg.destroy();
|
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">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">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">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 class="infoItem"><span class="infoLabel">Updated</span><span class="infoValue">${new Date(client.updatedAt).toLocaleString()}</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,12 +644,41 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
const currentDescription = client.description ?? '';
|
const currentDescription = client.description ?? '';
|
||||||
const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
|
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}`,
|
heading: `Edit: ${client.clientId}`,
|
||||||
content: html`
|
content: html`
|
||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
|
<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-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>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
@@ -573,16 +693,47 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const serverDefinedClientTags = data.tags
|
const serverDefinedClientTags = data.tags
|
||||||
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
? 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, {
|
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
serverDefinedClientTags,
|
serverDefinedClientTags,
|
||||||
|
forceDestinationSmartproxy: forceSmartproxy,
|
||||||
|
useHostIp: useHostIp || undefined,
|
||||||
|
useDhcp: useDhcp || undefined,
|
||||||
|
staticIp,
|
||||||
|
forceVlan: forceVlan || undefined,
|
||||||
|
vlanId,
|
||||||
|
destinationAllowList,
|
||||||
|
destinationBlockList,
|
||||||
});
|
});
|
||||||
await modalArg.destroy();
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user