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

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 = {
name: '@serve.zone/dcrouter',
version: '13.20.1',
version: '13.20.2',
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 { 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<void> {
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<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

View File

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

View File

@@ -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<void> {
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<void> {
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<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> {
await client.save();
}

View File

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

View File

@@ -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`<span class="tagBadge">${profile?.name || id}</span>`;
})}`
: '-',
'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<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.
*/
private getTargetProfileChoices() {
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
const profiles = this.targetProfilesState.profiles || [];
const nameCounts = new Map<string, number>();
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;
});
}