diff --git a/changelog.md b/changelog.md index 4cafa79..1408a0a 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/package.json b/package.json index 2fd47d2..33c0887 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d1e6f0..38bb16c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 23d447d..dbd8e41 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 932ce8d..bdec1ea 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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(); diff --git a/ts/db/documents/classes.vpn-client.doc.ts b/ts/db/documents/classes.vpn-client.doc.ts index 44c9b4c..dcaa265 100644 --- a/ts/db/documents/classes.vpn-client.doc.ts +++ b/ts/db/documents/classes.vpn-client.doc.ts @@ -39,6 +39,30 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc 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) { diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index 694ebc9..17ec664 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -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; + /** 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 { 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 { 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 { diff --git a/ts_interfaces/data/vpn.ts b/ts_interfaces/data/vpn.ts index ee6dcb7..0b8886e 100644 --- a/ts_interfaces/data/vpn.ts +++ b/ts_interfaces/data/vpn.ts @@ -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; } /** diff --git a/ts_interfaces/requests/vpn.ts b/ts_interfaces/requests/vpn.ts index c9cb38a..3a18e40 100644 --- a/ts_interfaces/requests/vpn.ts +++ b/ts_interfaces/requests/vpn.ts @@ -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; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 23d447d..dbd8e41 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index e3213c9..abab24c 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -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 => { 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 => { 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) { diff --git a/ts_web/elements/ops-view-vpn.ts b/ts_web/elements/ops-view-vpn.ts index 1c0f383..0fae272 100644 --- a/ts_web/elements/ops-view-vpn.ts +++ b/ts_web/elements/ops-view-vpn.ts @@ -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`offline`; } + let routingHtml; + if (client.forceDestinationSmartproxy !== false) { + routingHtml = html`SmartProxy`; + } else if (client.useHostIp) { + routingHtml = html`Host IP`; + } else { + routingHtml = html`Direct`; + } return { 'Client ID': client.clientId, 'Status': statusHtml, + 'Routing': routingHtml, 'VPN IP': client.assignedIp || '-', 'Tags': client.serverDefinedClientTags?.length ? html`${client.serverDefinedClientTags.map(t => html`${t}`)}` @@ -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` + + + + `, 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 { ` : ''}
Description${client.description || '-'}
Tags${client.serverDefinedClientTags?.join(', ') || '-'}
+
Routing${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}
+ ${client.useHostIp ? html` +
Host IP${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}
+
VLAN${client.forceVlan && client.vlanId != null ? `VLAN ${client.vlanId}` : 'No VLAN'}
+ ` : ''} +
Allow List${client.destinationAllowList?.length ? client.destinationAllowList.join(', ') : 'None'}
+
Block List${client.destinationBlockList?.length ? client.destinationBlockList.join(', ') : 'None'}
Created${new Date(client.createdAt).toLocaleString()}
Updated${new Date(client.updatedAt).toLocaleString()}
@@ -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` + +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+ + +
`, 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); + } }, }, {