From a466b884081f0894eb662b360544d87e79e2cb91 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 17 Apr 2026 14:28:19 +0000 Subject: [PATCH] fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates --- changelog.md | 7 ++ test/test.vpn-runtime.node.ts | 110 ++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 65 ++++++++++--- ts/config/classes.route-config-manager.ts | 6 ++ ts/vpn/classes.vpn-manager.ts | 81 +++++++++++++--- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/network/ops-view-vpn.ts | 64 ++++++++++--- 8 files changed, 292 insertions(+), 45 deletions(-) create mode 100644 test/test.vpn-runtime.node.ts diff --git a/changelog.md b/changelog.md index 61322e7..be69f6c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-17 - 13.20.2 - fix(vpn) +handle VPN forwarding mode downgrades and support runtime VPN config updates + +- restart the VPN server back to socket mode when host-IP clients are removed while preserving explicit hybrid mode +- allow DcRouter to update VPN configuration at runtime and refresh route allow-list resolution without recreating the router +- improve VPN operations UI target profile rendering and loading behavior for create and edit flows + ## 2026-04-17 - 13.20.1 - fix(docs) refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance diff --git a/test/test.vpn-runtime.node.ts b/test/test.vpn-runtime.node.ts new file mode 100644 index 0000000..b864496 --- /dev/null +++ b/test/test.vpn-runtime.node.ts @@ -0,0 +1,110 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { DcRouter } from '../ts/classes.dcrouter.js'; +import { VpnManager } from '../ts/vpn/classes.vpn-manager.js'; + +tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => { + const manager = new VpnManager({ forwardingMode: 'socket' }); + + let stopCalls = 0; + let startCalls = 0; + + (manager as any).vpnServer = { running: true }; + (manager as any).resolvedForwardingMode = 'hybrid'; + (manager as any).clients = new Map([ + ['client-1', { useHostIp: false }], + ]); + (manager as any).stop = async () => { + stopCalls++; + }; + (manager as any).start = async () => { + startCalls++; + (manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket'; + (manager as any).forwardingModeOverride = undefined; + (manager as any).vpnServer = { running: true }; + }; + + const restarted = await (manager as any).reconcileForwardingMode(); + + expect(restarted).toEqual(true); + expect(stopCalls).toEqual(1); + expect(startCalls).toEqual(1); + expect((manager as any).resolvedForwardingMode).toEqual('socket'); +}); + +tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => { + const manager = new VpnManager({ forwardingMode: 'hybrid' }); + + let stopCalls = 0; + let startCalls = 0; + + (manager as any).vpnServer = { running: true }; + (manager as any).resolvedForwardingMode = 'hybrid'; + (manager as any).clients = new Map([ + ['client-1', { useHostIp: false }], + ]); + (manager as any).stop = async () => { + stopCalls++; + }; + (manager as any).start = async () => { + startCalls++; + }; + + const restarted = await (manager as any).reconcileForwardingMode(); + + expect(restarted).toEqual(false); + expect(stopCalls).toEqual(0); + expect(startCalls).toEqual(0); + expect((manager as any).resolvedForwardingMode).toEqual('hybrid'); +}); + +tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => { + const dcRouter = new DcRouter({ + smartProxyConfig: { routes: [] }, + dbConfig: { enabled: false }, + vpnConfig: { enabled: false }, + }); + + let stopCalls = 0; + let setupCalls = 0; + let applyCalls = 0; + const resolverValues: Array = []; + + dcRouter.vpnManager = { + stop: async () => { + stopCalls++; + }, + } as any; + (dcRouter as any).routeConfigManager = { + setVpnClientIpsResolver: (resolver: unknown) => { + resolverValues.push(resolver); + }, + applyRoutes: async () => { + applyCalls++; + }, + }; + (dcRouter as any).setupVpnServer = async () => { + setupCalls++; + dcRouter.vpnManager = { + stop: async () => { + stopCalls++; + }, + } as any; + }; + + await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' }); + + expect(stopCalls).toEqual(1); + expect(setupCalls).toEqual(1); + expect(applyCalls).toEqual(0); + expect(typeof resolverValues.at(-1)).toEqual('function'); + + await dcRouter.updateVpnConfig({ enabled: false }); + + expect(stopCalls).toEqual(2); + expect(setupCalls).toEqual(1); + expect(applyCalls).toEqual(1); + expect(resolverValues.at(-1)).toBeUndefined(); + expect(dcRouter.vpnManager).toBeUndefined(); +}); + +export default tap.start() diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c870465..9d8a67c 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: '13.20.1', + version: '13.20.2', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 14466f3..59a0896 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -26,6 +26,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; +import type { TIpAllowEntry } from './config/classes.route-config-manager.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { DnsManager } from './dns/manager.dns.js'; @@ -565,20 +566,7 @@ export class DcRouter { this.routeConfigManager = new RouteConfigManager( () => this.smartProxy, () => this.options.http3, - this.options.vpnConfig?.enabled - ? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => { - if (!this.vpnManager || !this.targetProfileManager) { - // VPN not ready yet — deny all until re-apply after VPN starts - return []; - } - return this.targetProfileManager.getMatchingClientIps( - route, - routeId, - this.vpnManager.listClients(), - this.routeConfigManager?.getRoutes() || new Map(), - ); - } - : undefined, + this.createVpnRouteAllowListResolver(), this.referenceResolver, // Sync routes to RemoteIngressManager whenever routes change, // then push updated derived ports to the Rust hub binary @@ -2292,6 +2280,32 @@ export class DcRouter { /** * Set up VPN server for VPN-based route access control. */ + private createVpnRouteAllowListResolver(): (( + route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, + routeId?: string, + ) => TIpAllowEntry[]) | undefined { + if (!this.options.vpnConfig?.enabled) { + return undefined; + } + + return ( + route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, + routeId?: string, + ) => { + if (!this.vpnManager || !this.targetProfileManager) { + // VPN not ready yet — deny all until re-apply after VPN starts. + return []; + } + + return this.targetProfileManager.getMatchingClientIps( + route, + routeId, + this.vpnManager.listClients(), + this.routeConfigManager?.getRoutes() || new Map(), + ); + }; + } + private async setupVpnServer(): Promise { if (!this.options.vpnConfig?.enabled) { return; @@ -2441,6 +2455,29 @@ export class DcRouter { logger.log('info', 'RADIUS configuration updated'); } + + /** + * Update VPN configuration at runtime. + */ + public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise { + if (this.vpnManager) { + await this.vpnManager.stop(); + this.vpnManager = undefined; + } + + this.options.vpnConfig = config; + this.vpnDomainIpCache.clear(); + this.warnedWildcardVpnDomains.clear(); + this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver()); + + if (this.options.vpnConfig?.enabled) { + await this.setupVpnServer(); + } else { + await this.routeConfigManager?.applyRoutes(); + } + + logger.log('info', 'VPN configuration updated'); + } } // Re-export email server types for convenience diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 16cb587..504a4b5 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -73,6 +73,12 @@ export class RouteConfigManager { return this.routes.get(id); } + public setVpnClientIpsResolver( + resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], + ): void { + this.getVpnClientIpsForRoute = resolver; + } + /** * Load persisted routes, seed serializable config/email/dns routes, * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy. diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index a490cb9..c6c8933 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -112,14 +112,11 @@ export class VpnManager { 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.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket'; - if (anyClientUsesHostIp && configuredMode === 'socket') { - configuredMode = 'hybrid'; + const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp); + if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') { logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)'); } - const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode; + const forwardingMode = desiredForwardingMode; const isBridge = forwardingMode === 'bridge'; this.resolvedForwardingMode = forwardingMode; this.forwardingModeOverride = undefined; @@ -218,7 +215,7 @@ export class VpnManager { throw new Error('VPN server not running'); } - await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true); + await this.ensureForwardingModeForNextClient(opts.useHostIp === true); const doc = new VpnClientDoc(); doc.clientId = opts.clientId; @@ -298,6 +295,7 @@ export class VpnManager { if (doc) { await doc.delete(); } + await this.reconcileForwardingMode(); this.config.onClientChanged?.(); } @@ -368,8 +366,10 @@ export class VpnManager { await this.persistClient(client); if (this.vpnServer) { - await this.ensureForwardingModeForHostIpClient(client.useHostIp === true); - await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client)); + const restarted = await this.reconcileForwardingMode(); + if (!restarted) { + await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client)); + } } this.config.onClientChanged?.(); @@ -563,6 +563,28 @@ export class VpnManager { ?? 'socket'; } + private hasHostIpClients(extraHostIpClient = false): boolean { + if (extraHostIpClient) { + return true; + } + + for (const client of this.clients.values()) { + if (client.useHostIp) { + return true; + } + } + + return false; + } + + private getDesiredForwardingMode(hasHostIpClients = this.hasHostIpClients()): 'socket' | 'bridge' | 'hybrid' { + const configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket'; + if (configuredMode !== 'socket') { + return configuredMode; + } + return hasHostIpClients ? 'hybrid' : 'socket'; + } + private getDefaultDestinationPolicy( forwardingMode: 'socket' | 'bridge' | 'hybrid', useHostIp = false, @@ -633,16 +655,45 @@ export class VpnManager { }; } - private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise { - if (!useHostIp || !this.vpnServer) return; - if (this.getResolvedForwardingMode() !== 'socket') return; - - logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client'); - this.forwardingModeOverride = 'hybrid'; + private async restartWithForwardingMode( + forwardingMode: 'socket' | 'bridge' | 'hybrid', + reason: string, + ): Promise { + logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`); + this.forwardingModeOverride = forwardingMode; await this.stop(); await this.start(); } + private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise { + if (!this.vpnServer) return; + + const desiredForwardingMode = this.getDesiredForwardingMode(this.hasHostIpClients(useHostIp)); + if (desiredForwardingMode === this.getResolvedForwardingMode()) { + return; + } + + await this.restartWithForwardingMode(desiredForwardingMode, 'to support a host-IP client'); + } + + private async reconcileForwardingMode(): Promise { + if (!this.vpnServer) { + return false; + } + + const desiredForwardingMode = this.getDesiredForwardingMode(); + const currentForwardingMode = this.getResolvedForwardingMode(); + if (desiredForwardingMode === currentForwardingMode) { + return false; + } + + const reason = desiredForwardingMode === 'socket' + ? 'because no host-IP clients remain' + : 'to support host-IP clients'; + await this.restartWithForwardingMode(desiredForwardingMode, reason); + return true; + } + private async persistClient(client: VpnClientDoc): Promise { await client.save(); } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index c870465..9d8a67c 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: '13.20.1', + version: '13.20.2', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/network/ops-view-vpn.ts b/ts_web/elements/network/ops-view-vpn.ts index 2fe8a73..db4b536 100644 --- a/ts_web/elements/network/ops-view-vpn.ts +++ b/ts_web/elements/network/ops-view-vpn.ts @@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement { @state() accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!; + @state() + accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!; + constructor() { super(); const sub = appstate.vpnStatePart.select().subscribe((newState) => { this.vpnState = newState; }); this.rxSubscriptions.push(sub); + + const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => { + this.targetProfilesState = newState; + }); + this.rxSubscriptions.push(targetProfilesSub); } async connectedCallback() { await super.connectedCallback(); - await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null); - // Ensure target profiles are loaded for autocomplete candidates - await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null); + await Promise.all([ + appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null), + appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null), + ]); } public static styles = [ @@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement { 'Status': statusHtml, 'Routing': routingHtml, 'VPN IP': client.assignedIp || '-', - 'Target Profiles': client.targetProfileIds?.length - ? html`${client.targetProfileIds.map(id => { - const profileState = appstate.targetProfilesStatePart.getState(); - const profile = profileState?.profiles.find(p => p.id === id); - return html`${profile?.name || id}`; - })}` - : '-', + 'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds), 'Description': client.description || '-', 'Created': new Date(client.createdAt).toLocaleDateString(), }; @@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement { iconName: 'lucide:plus', type: ['header'], actionFunc: async () => { + await this.ensureTargetProfilesLoaded(); const { DeesModal } = await import('@design.estate/dees-catalog'); const profileCandidates = this.getTargetProfileCandidates(); const createModal = await DeesModal.createAndShow({ @@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement { type: ['contextmenu', 'inRow'], actionFunc: async (actionData: any) => { const client = actionData.item as interfaces.data.IVpnClient; + await this.ensureTargetProfilesLoaded(); const { DeesModal } = await import('@design.estate/dees-catalog'); const currentDescription = client.description ?? ''; const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || []; @@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement { `; } + private async ensureTargetProfilesLoaded(): Promise { + await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null); + } + + private renderTargetProfileBadges(ids?: string[]): TemplateResult | string { + const labels = this.resolveProfileIdsToLabels(ids, { + pendingLabel: 'Loading profile...', + missingLabel: (id) => `Unknown profile (${id})`, + }); + + if (!labels?.length) { + return '-'; + } + + return html`${labels.map((label) => html`${label}`)}`; + } + /** * Build stable profile labels for list inputs. */ private getTargetProfileChoices() { - const profileState = appstate.targetProfilesStatePart.getState(); - const profiles = profileState?.profiles || []; + const profiles = this.targetProfilesState.profiles || []; const nameCounts = new Map(); for (const profile of profiles) { @@ -837,12 +858,27 @@ export class OpsViewVpn extends DeesElement { /** * Convert profile IDs to form labels (for populating edit form values). */ - private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined { + private resolveProfileIdsToLabels( + ids?: string[], + options: { + pendingLabel?: string; + missingLabel?: (id: string) => string; + } = {}, + ): string[] | undefined { if (!ids?.length) return undefined; const choices = this.getTargetProfileChoices(); const labelsById = new Map(choices.map((profile) => [profile.id, profile.label])); return ids.map((id) => { - return labelsById.get(id) || id; + const label = labelsById.get(id); + if (label) { + return label; + } + + if (this.targetProfilesState.lastUpdated === 0 && !this.targetProfilesState.error) { + return options.pendingLabel || 'Loading profile...'; + } + + return options.missingLabel?.(id) || id; }); }