fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates

This commit is contained in:
2026-04-17 14:28:19 +00:00
parent e26ea9e114
commit a466b88408
8 changed files with 292 additions and 45 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # 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) ## 2026-04-17 - 13.20.1 - fix(docs)
refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance refresh package readmes with clearer runtime, API client, interfaces, migrations, and dashboard guidance

View File

@@ -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<unknown> = [];
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()

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.20.1', version: '13.20.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -26,6 +26,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/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 { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js'; import { DnsManager } from './dns/manager.dns.js';
@@ -565,20 +566,7 @@ export class DcRouter {
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.options.vpnConfig?.enabled this.createVpnRouteAllowListResolver(),
? (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.referenceResolver, this.referenceResolver,
// Sync routes to RemoteIngressManager whenever routes change, // Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary // 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. * 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<void> { private async setupVpnServer(): Promise<void> {
if (!this.options.vpnConfig?.enabled) { if (!this.options.vpnConfig?.enabled) {
return; return;
@@ -2441,6 +2455,29 @@ export class DcRouter {
logger.log('info', 'RADIUS configuration updated'); logger.log('info', 'RADIUS configuration updated');
} }
/**
* Update VPN configuration at runtime.
*/
public async updateVpnConfig(config: IDcRouterOptions['vpnConfig']): Promise<void> {
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 // Re-export email server types for convenience

View File

@@ -73,6 +73,12 @@ export class RouteConfigManager {
return this.routes.get(id); 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, * Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy. * compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.

View File

@@ -112,14 +112,11 @@ export class VpnManager {
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 const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
if (anyClientUsesHostIp && configuredMode === 'socket') {
configuredMode = 'hybrid';
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)'); 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'; const isBridge = forwardingMode === 'bridge';
this.resolvedForwardingMode = forwardingMode; this.resolvedForwardingMode = forwardingMode;
this.forwardingModeOverride = undefined; this.forwardingModeOverride = undefined;
@@ -218,7 +215,7 @@ export class VpnManager {
throw new Error('VPN server not running'); throw new Error('VPN server not running');
} }
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true); await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
const doc = new VpnClientDoc(); const doc = new VpnClientDoc();
doc.clientId = opts.clientId; doc.clientId = opts.clientId;
@@ -298,6 +295,7 @@ export class VpnManager {
if (doc) { if (doc) {
await doc.delete(); await doc.delete();
} }
await this.reconcileForwardingMode();
this.config.onClientChanged?.(); this.config.onClientChanged?.();
} }
@@ -368,8 +366,10 @@ export class VpnManager {
await this.persistClient(client); await this.persistClient(client);
if (this.vpnServer) { if (this.vpnServer) {
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true); const restarted = await this.reconcileForwardingMode();
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client)); if (!restarted) {
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
}
} }
this.config.onClientChanged?.(); this.config.onClientChanged?.();
@@ -563,6 +563,28 @@ export class VpnManager {
?? 'socket'; ?? '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( private getDefaultDestinationPolicy(
forwardingMode: 'socket' | 'bridge' | 'hybrid', forwardingMode: 'socket' | 'bridge' | 'hybrid',
useHostIp = false, useHostIp = false,
@@ -633,16 +655,45 @@ export class VpnManager {
}; };
} }
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> { private async restartWithForwardingMode(
if (!useHostIp || !this.vpnServer) return; forwardingMode: 'socket' | 'bridge' | 'hybrid',
if (this.getResolvedForwardingMode() !== 'socket') return; reason: string,
): Promise<void> {
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client'); logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`);
this.forwardingModeOverride = 'hybrid'; this.forwardingModeOverride = forwardingMode;
await this.stop(); await this.stop();
await this.start(); await this.start();
} }
private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise<void> {
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<boolean> {
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<void> { private async persistClient(client: VpnClientDoc): Promise<void> {
await client.save(); await client.save();
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.20.1', version: '13.20.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement {
@state() @state()
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!; accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
@state()
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
constructor() { constructor() {
super(); super();
const sub = appstate.vpnStatePart.select().subscribe((newState) => { const sub = appstate.vpnStatePart.select().subscribe((newState) => {
this.vpnState = newState; this.vpnState = newState;
}); });
this.rxSubscriptions.push(sub); this.rxSubscriptions.push(sub);
const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
this.targetProfilesState = newState;
});
this.rxSubscriptions.push(targetProfilesSub);
} }
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null); await Promise.all([
// Ensure target profiles are loaded for autocomplete candidates appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null),
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null); appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null),
]);
} }
public static styles = [ public static styles = [
@@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml, 'Status': statusHtml,
'Routing': routingHtml, 'Routing': routingHtml,
'VPN IP': client.assignedIp || '-', 'VPN IP': client.assignedIp || '-',
'Target Profiles': client.targetProfileIds?.length 'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
? html`${client.targetProfileIds.map(id => {
const profileState = appstate.targetProfilesStatePart.getState();
const profile = profileState?.profiles.find(p => p.id === id);
return html`<span class="tagBadge">${profile?.name || id}</span>`;
})}`
: '-',
'Description': client.description || '-', 'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(), 'Created': new Date(client.createdAt).toLocaleDateString(),
}; };
@@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement {
iconName: 'lucide:plus', iconName: 'lucide:plus',
type: ['header'], type: ['header'],
actionFunc: async () => { actionFunc: async () => {
await this.ensureTargetProfilesLoaded();
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const profileCandidates = this.getTargetProfileCandidates(); const profileCandidates = this.getTargetProfileCandidates();
const createModal = await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
@@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement {
type: ['contextmenu', 'inRow'], type: ['contextmenu', 'inRow'],
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient; const client = actionData.item as interfaces.data.IVpnClient;
await this.ensureTargetProfilesLoaded();
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 currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || []; const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
@@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement {
`; `;
} }
private async ensureTargetProfilesLoaded(): Promise<void> {
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`<span class="tagBadge">${label}</span>`)}`;
}
/** /**
* Build stable profile labels for list inputs. * Build stable profile labels for list inputs.
*/ */
private getTargetProfileChoices() { private getTargetProfileChoices() {
const profileState = appstate.targetProfilesStatePart.getState(); const profiles = this.targetProfilesState.profiles || [];
const profiles = profileState?.profiles || [];
const nameCounts = new Map<string, number>(); const nameCounts = new Map<string, number>();
for (const profile of profiles) { 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). * 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; if (!ids?.length) return undefined;
const choices = this.getTargetProfileChoices(); const choices = this.getTargetProfileChoices();
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label])); const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
return ids.map((id) => { 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;
}); });
} }