From aec8b72ca30cae9f5f1bf96e533ecaadd7cd0fc2 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 13 Apr 2026 23:02:42 +0000 Subject: [PATCH] fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior --- changelog.md | 8 + package.json | 2 +- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 32 ++- ts/config/classes.target-profile-manager.ts | 135 +++++++++++- ts/vpn/classes.vpn-manager.ts | 206 +++++++++++++----- ts_interfaces/data/target-profile.ts | 2 +- ts_migrations/index.ts | 48 ++-- ts_web/00_commitinfo_data.ts | 2 +- .../network/ops-view-targetprofiles.ts | 65 +++++- ts_web/elements/network/ops-view-vpn.ts | 75 ++++--- 11 files changed, 446 insertions(+), 131 deletions(-) diff --git a/changelog.md b/changelog.md index b8fbfda..36e93b2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles) +normalize target profile route references and stabilize VPN host-IP client routing behavior + +- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI. +- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution. +- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it. +- Add a repair migration for previously missed TargetProfile target host-to-ip document updates. + ## 2026-04-13 - 13.17.3 - fix(ops-view-routes) sync route filter toggle selection via component changeSubject diff --git a/package.json b/package.json index 6bbd80f..999f510 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@serve.zone/dcrouter", "private": false, - "version": "13.17.3", + "version": "13.17.4", "description": "A multifaceted routing service handling mail and SMS delivery functions.", "type": "module", "exports": { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 527200c..fe88eab 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.17.3', + version: '13.17.5', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index b14ab72..cde4e47 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -547,7 +547,9 @@ export class DcRouter { await this.referenceResolver.initialize(); // Initialize target profile manager - this.targetProfileManager = new TargetProfileManager(); + this.targetProfileManager = new TargetProfileManager( + () => this.routeConfigManager?.getRoutes() || new Map(), + ); await this.targetProfileManager.initialize(); this.routeConfigManager = new RouteConfigManager( @@ -560,7 +562,10 @@ export class DcRouter { return []; } return this.targetProfileManager.getMatchingClientIps( - route, routeId, this.vpnManager.listClients(), + route, + routeId, + this.vpnManager.listClients(), + this.routeConfigManager?.getRoutes() || new Map(), ); } : undefined, @@ -583,6 +588,7 @@ export class DcRouter { this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], ); + await this.targetProfileManager.normalizeAllRouteRefs(); // Seed default profiles/targets if DB is empty and seeding is enabled const seeder = new DbSeeder(this.referenceResolver); @@ -2283,8 +2289,11 @@ export class DcRouter { // Resolve DNS A records for matched domains (with caching) for (const domain of domains) { - const stripped = domain.replace(/^\*\./, ''); - const resolvedIps = await this.resolveVpnDomainIPs(stripped); + if (this.isWildcardVpnDomain(domain)) { + this.logSkippedWildcardAllowedIp(domain); + continue; + } + const resolvedIps = await this.resolveVpnDomainIPs(domain); for (const ip of resolvedIps) { ips.add(`${ip}/32`); } @@ -2303,6 +2312,8 @@ export class DcRouter { /** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */ private vpnDomainIpCache = new Map(); + /** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */ + private warnedWildcardVpnDomains = new Set(); /** * Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache. @@ -2328,6 +2339,19 @@ export class DcRouter { } } + private isWildcardVpnDomain(domain: string): boolean { + return domain.includes('*'); + } + + private logSkippedWildcardAllowedIp(domain: string): void { + if (this.warnedWildcardVpnDomains.has(domain)) return; + this.warnedWildcardVpnDomains.add(domain); + logger.log( + 'warn', + `VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`, + ); + } + // VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes() // via the getVpnAllowList callback — no longer a separate method here. diff --git a/ts/config/classes.target-profile-manager.ts b/ts/config/classes.target-profile-manager.ts index 53e8d5c..db111c5 100644 --- a/ts/config/classes.target-profile-manager.ts +++ b/ts/config/classes.target-profile-manager.ts @@ -13,6 +13,10 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js'; export class TargetProfileManager { private profiles = new Map(); + constructor( + private getAllRoutes?: () => Map, + ) {} + // ========================================================================= // Lifecycle // ========================================================================= @@ -43,13 +47,14 @@ export class TargetProfileManager { const id = plugins.uuid.v4(); const now = Date.now(); + const routeRefs = this.normalizeRouteRefs(data.routeRefs); const profile: ITargetProfile = { id, name: data.name, description: data.description, domains: data.domains, targets: data.targets, - routeRefs: data.routeRefs, + routeRefs, createdAt: now, updatedAt: now, createdBy: data.createdBy, @@ -70,11 +75,19 @@ export class TargetProfileManager { throw new Error(`Target profile '${id}' not found`); } + if (patch.name !== undefined && patch.name !== profile.name) { + for (const existing of this.profiles.values()) { + if (existing.id !== id && existing.name === patch.name) { + throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`); + } + } + } + if (patch.name !== undefined) profile.name = patch.name; if (patch.description !== undefined) profile.description = patch.description; if (patch.domains !== undefined) profile.domains = patch.domains; if (patch.targets !== undefined) profile.targets = patch.targets; - if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs; + if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs); profile.updatedAt = Date.now(); await this.persistProfile(profile); @@ -127,6 +140,29 @@ export class TargetProfileManager { return this.profiles.get(id); } + /** + * Normalize stored route references to route IDs when they can be resolved + * uniquely against the current route registry. + */ + public async normalizeAllRouteRefs(): Promise { + const allRoutes = this.getAllRoutes?.(); + if (!allRoutes?.size) return; + + for (const profile of this.profiles.values()) { + const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes( + profile.routeRefs, + allRoutes, + 'bestEffort', + ); + if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue; + + profile.routeRefs = normalizedRouteRefs; + profile.updatedAt = Date.now(); + await this.persistProfile(profile); + logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`); + } + } + public listProfiles(): ITargetProfile[] { return [...this.profiles.values()]; } @@ -178,9 +214,11 @@ export class TargetProfileManager { route: IDcRouterRouteConfig, routeId: string | undefined, clients: VpnClientDoc[], + allRoutes: Map = new Map(), ): Array { const entries: Array = []; const routeDomains: string[] = (route.match as any)?.domains || []; + const routeNameIndex = this.buildRouteNameIndex(allRoutes); for (const client of clients) { if (!client.enabled || !client.assignedIp) continue; @@ -194,7 +232,13 @@ export class TargetProfileManager { const profile = this.profiles.get(profileId); if (!profile) continue; - const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains); + const matchResult = this.routeMatchesProfileDetailed( + route, + routeId, + profile, + routeDomains, + routeNameIndex, + ); if (matchResult === 'full') { fullAccess = true; break; // No need to check more profiles @@ -224,6 +268,7 @@ export class TargetProfileManager { ): { domains: string[]; targetIps: string[] } { const domains = new Set(); const targetIps = new Set(); + const routeNameIndex = this.buildRouteNameIndex(allRoutes); // Collect all access specifiers from assigned profiles for (const profileId of targetProfileIds) { @@ -247,7 +292,12 @@ export class TargetProfileManager { // Route references: scan all routes for (const [routeId, route] of allRoutes) { if (!route.enabled) continue; - if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) { + if (this.routeMatchesProfile( + route.route as IDcRouterRouteConfig, + routeId, + profile, + routeNameIndex, + )) { const routeDomains = (route.route.match as any)?.domains; if (Array.isArray(routeDomains)) { for (const d of routeDomains) { @@ -275,9 +325,16 @@ export class TargetProfileManager { route: IDcRouterRouteConfig, routeId: string | undefined, profile: ITargetProfile, + routeNameIndex: Map, ): boolean { const routeDomains: string[] = (route.match as any)?.domains || []; - const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains); + const result = this.routeMatchesProfileDetailed( + route, + routeId, + profile, + routeDomains, + routeNameIndex, + ); return result !== 'none'; } @@ -294,11 +351,17 @@ export class TargetProfileManager { routeId: string | undefined, profile: ITargetProfile, routeDomains: string[], + routeNameIndex: Map, ): 'full' | { type: 'scoped'; domains: string[] } | 'none' { // 1. Route reference match → full access if (profile.routeRefs?.length) { if (routeId && profile.routeRefs.includes(routeId)) return 'full'; - if (route.name && profile.routeRefs.includes(route.name)) return 'full'; + if (routeId && route.name && profile.routeRefs.includes(route.name)) { + const matchingRouteIds = routeNameIndex.get(route.name) || []; + if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) { + return 'full'; + } + } } // 2. Domain match @@ -362,6 +425,66 @@ export class TargetProfileManager { return false; } + private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined { + const allRoutes = this.getAllRoutes?.() || new Map(); + return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict'); + } + + private normalizeRouteRefsAgainstRoutes( + routeRefs: string[] | undefined, + allRoutes: Map, + mode: 'strict' | 'bestEffort', + ): string[] | undefined { + if (!routeRefs?.length) return undefined; + if (!allRoutes.size) return [...new Set(routeRefs)]; + + const routeNameIndex = this.buildRouteNameIndex(allRoutes); + const normalizedRefs = new Set(); + + for (const routeRef of routeRefs) { + if (allRoutes.has(routeRef)) { + normalizedRefs.add(routeRef); + continue; + } + + const matchingRouteIds = routeNameIndex.get(routeRef) || []; + if (matchingRouteIds.length === 1) { + normalizedRefs.add(matchingRouteIds[0]); + continue; + } + + if (mode === 'bestEffort') { + normalizedRefs.add(routeRef); + continue; + } + + if (matchingRouteIds.length > 1) { + throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`); + } + throw new Error(`Route reference '${routeRef}' not found`); + } + + return [...normalizedRefs]; + } + + private buildRouteNameIndex(allRoutes: Map): Map { + const routeNameIndex = new Map(); + for (const [routeId, route] of allRoutes) { + const routeName = route.route.name; + if (!routeName) continue; + const matchingRouteIds = routeNameIndex.get(routeName) || []; + matchingRouteIds.push(routeId); + routeNameIndex.set(routeName, matchingRouteIds); + } + return routeNameIndex; + } + + private sameStringArray(left?: string[], right?: string[]): boolean { + if (!left?.length && !right?.length) return true; + if (!left || !right || left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); + } + // ========================================================================= // Private: persistence // ========================================================================= diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index 52acbb5..a490cb9 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -55,6 +55,8 @@ export class VpnManager { private vpnServer?: plugins.smartvpn.VpnServer; private clients: Map = new Map(); private serverKeys?: VpnServerKeysDoc; + private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid'; + private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid'; constructor(config: IVpnManagerConfig) { this.config = config; @@ -88,6 +90,7 @@ export class VpnManager { if (client.useHostIp) { anyClientUsesHostIp = true; } + this.normalizeClientRoutingSettings(client); const entry: plugins.smartvpn.IClientEntry = { clientId: client.clientId, publicKey: client.noisePublicKey, @@ -97,13 +100,12 @@ export class VpnManager { assignedIp: client.assignedIp, expiresAt: client.expiresAt, security: this.buildClientSecurity(client), + useHostIp: client.useHostIp, + useDhcp: client.useDhcp, + staticIp: client.staticIp, + forceVlan: client.forceVlan, + vlanId: client.vlanId, }; - // 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); } @@ -112,13 +114,15 @@ export class VpnManager { // 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'; + 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)'); } const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode; const isBridge = forwardingMode === 'bridge'; + this.resolvedForwardingMode = forwardingMode; + this.forwardingModeOverride = undefined; // Create and start VpnServer this.vpnServer = new plugins.smartvpn.VpnServer({ @@ -143,7 +147,7 @@ export class VpnManager { wgListenPort, clients: clientEntries, socketForwardProxyProtocol: !isBridge, - destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy, + destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy), serverEndpoint: this.config.serverEndpoint ? `${this.config.serverEndpoint}:${wgListenPort}` : undefined, @@ -189,6 +193,7 @@ export class VpnManager { this.vpnServer.stop(); this.vpnServer = undefined; } + this.resolvedForwardingMode = undefined; logger.log('info', 'VPN server stopped'); } @@ -213,14 +218,38 @@ export class VpnManager { throw new Error('VPN server not running'); } + await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true); + + const doc = new VpnClientDoc(); + doc.clientId = opts.clientId; + doc.enabled = true; + doc.targetProfileIds = opts.targetProfileIds; + doc.description = opts.description; + doc.destinationAllowList = opts.destinationAllowList; + doc.destinationBlockList = opts.destinationBlockList; + doc.useHostIp = opts.useHostIp; + doc.useDhcp = opts.useDhcp; + doc.staticIp = opts.staticIp; + doc.forceVlan = opts.forceVlan; + doc.vlanId = opts.vlanId; + doc.createdAt = Date.now(); + doc.updatedAt = Date.now(); + this.normalizeClientRoutingSettings(doc); + const bundle = await this.vpnServer.createClient({ - clientId: opts.clientId, - description: opts.description, + clientId: doc.clientId, + description: doc.description, + security: this.buildClientSecurity(doc), + useHostIp: doc.useHostIp, + useDhcp: doc.useDhcp, + staticIp: doc.staticIp, + forceVlan: doc.forceVlan, + vlanId: doc.vlanId, }); // Override AllowedIPs with per-client values based on target profiles if (this.config.getClientAllowedIPs && bundle.wireguardConfig) { - const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []); + const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []); bundle.wireguardConfig = bundle.wireguardConfig.replace( /AllowedIPs\s*=\s*.+/, `AllowedIPs = ${allowedIPs.join(', ')}`, @@ -228,40 +257,16 @@ export class VpnManager { } // Persist client entry (including WG private key for export/QR) - const doc = new VpnClientDoc(); doc.clientId = bundle.entry.clientId; doc.enabled = bundle.entry.enabled ?? true; - doc.targetProfileIds = opts.targetProfileIds; doc.description = bundle.entry.description; doc.assignedIp = bundle.entry.assignedIp; doc.noisePublicKey = bundle.entry.publicKey; doc.wgPublicKey = bundle.entry.wgPublicKey || ''; doc.wgPrivateKey = bundle.secrets?.wgPrivateKey || bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(); - doc.createdAt = Date.now(); doc.updatedAt = Date.now(); doc.expiresAt = bundle.entry.expiresAt; - 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); try { await this.persistClient(doc); @@ -276,12 +281,6 @@ export class VpnManager { throw err; } - // 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; } @@ -364,13 +363,13 @@ export class VpnManager { if (update.staticIp !== undefined) client.staticIp = update.staticIp; if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan; if (update.vlanId !== undefined) client.vlanId = update.vlanId; + this.normalizeClientRoutingSettings(client); 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 }); + await this.ensureForwardingModeForHostIpClient(client.useHostIp === true); + await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client)); } this.config.onClientChanged?.(); @@ -478,26 +477,28 @@ export class VpnManager { /** * Build per-client security settings for the smartvpn daemon. - * All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1). - * TargetProfile direct IP:port targets bypass SmartProxy via allowList. + * TargetProfile direct IP:port targets extend the effective allow-list. */ private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity { const security: plugins.smartvpn.IClientSecurity = {}; + const basePolicy = this.getBaseDestinationPolicy(client); - // Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs) const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || []; - - // Merge with per-client explicit allow list - const mergedAllowList = [ - ...(client.destinationAllowList || []), - ...profileDirectTargets, - ]; + const mergedAllowList = this.mergeDestinationLists( + basePolicy.allowList, + client.destinationAllowList, + profileDirectTargets, + ); + const mergedBlockList = this.mergeDestinationLists( + basePolicy.blockList, + client.destinationBlockList, + ); security.destinationPolicy = { - default: 'forceTarget' as const, - target: '127.0.0.1', + default: basePolicy.default, + target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined, allowList: mergedAllowList.length ? mergedAllowList : undefined, - blockList: client.destinationBlockList, + blockList: mergedBlockList.length ? mergedBlockList : undefined, }; return security; @@ -510,10 +511,7 @@ export class VpnManager { public async refreshAllClientSecurity(): Promise { if (!this.vpnServer) return; for (const client of this.clients.values()) { - const security = this.buildClientSecurity(client); - if (security.destinationPolicy) { - await this.vpnServer.updateClient(client.clientId, { security }); - } + await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client)); } } @@ -550,6 +548,7 @@ export class VpnManager { private async loadPersistedClients(): Promise { const docs = await VpnClientDoc.findAll(); for (const doc of docs) { + this.normalizeClientRoutingSettings(doc); this.clients.set(doc.clientId, doc); } if (this.clients.size > 0) { @@ -557,6 +556,93 @@ export class VpnManager { } } + private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' { + return this.resolvedForwardingMode + ?? this.forwardingModeOverride + ?? this.config.forwardingMode + ?? 'socket'; + } + + private getDefaultDestinationPolicy( + forwardingMode: 'socket' | 'bridge' | 'hybrid', + useHostIp = false, + ): plugins.smartvpn.IDestinationPolicy { + if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) { + return { default: 'allow' }; + } + return { default: 'forceTarget', target: '127.0.0.1' }; + } + + private getServerDestinationPolicy( + forwardingMode: 'socket' | 'bridge' | 'hybrid', + fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode), + ): plugins.smartvpn.IDestinationPolicy { + return this.config.destinationPolicy ?? fallbackPolicy; + } + + private getBaseDestinationPolicy(client: Pick): plugins.smartvpn.IDestinationPolicy { + if (this.config.destinationPolicy) { + return { ...this.config.destinationPolicy }; + } + return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true); + } + + private mergeDestinationLists(...lists: Array): string[] { + const merged = new Set(); + for (const list of lists) { + for (const entry of list || []) { + merged.add(entry); + } + } + return [...merged]; + } + + private normalizeClientRoutingSettings( + client: Pick, + ): void { + client.useHostIp = client.useHostIp === true; + + if (!client.useHostIp) { + client.useDhcp = false; + client.staticIp = undefined; + client.forceVlan = false; + client.vlanId = undefined; + return; + } + + client.useDhcp = client.useDhcp === true; + if (client.useDhcp) { + client.staticIp = undefined; + } + + client.forceVlan = client.forceVlan === true; + if (!client.forceVlan) { + client.vlanId = undefined; + } + } + + private buildClientRuntimeUpdate(client: VpnClientDoc): Partial { + return { + description: client.description, + security: this.buildClientSecurity(client), + useHostIp: client.useHostIp, + useDhcp: client.useDhcp, + staticIp: client.staticIp, + forceVlan: client.forceVlan, + vlanId: client.vlanId, + }; + } + + 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'; + await this.stop(); + await this.start(); + } + private async persistClient(client: VpnClientDoc): Promise { await client.save(); } diff --git a/ts_interfaces/data/target-profile.ts b/ts_interfaces/data/target-profile.ts index e1a50d5..962524b 100644 --- a/ts_interfaces/data/target-profile.ts +++ b/ts_interfaces/data/target-profile.ts @@ -21,7 +21,7 @@ export interface ITargetProfile { domains?: string[]; /** Specific IP:port targets this profile grants access to */ targets?: ITargetProfileTarget[]; - /** Route references by stored route ID or route name */ + /** Route references by stored route ID. Legacy route names are normalized when unique. */ routeRefs?: string[]; createdAt: number; updatedAt: number; diff --git a/ts_migrations/index.ts b/ts_migrations/index.ts index d7a3105..d04649c 100644 --- a/ts_migrations/index.ts +++ b/ts_migrations/index.ts @@ -21,6 +21,30 @@ export interface IMigrationRunner { run(): Promise; } +async function migrateTargetProfileTargetHosts(ctx: { + mongo?: { collection: (name: string) => any }; + log: { log: (level: 'info', message: string) => void }; +}): Promise { + const collection = ctx.mongo!.collection('TargetProfileDoc'); + const cursor = collection.find({ 'targets.host': { $exists: true } }); + let migrated = 0; + + for await (const doc of cursor) { + const targets = ((doc as any).targets || []).map((target: any) => { + if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) { + const { host, ...rest } = target; + return { ...rest, ip: host }; + } + return target; + }); + + await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } }); + migrated++; + } + + ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`); +} + /** * Create a configured SmartMigration runner with all dcrouter migration steps registered. * @@ -48,23 +72,7 @@ export async function createMigrationRunner( .step('rename-target-profile-host-to-ip') .from('13.0.11').to('13.1.0') .description('Rename ITargetProfileTarget.host → ip on all target profiles') - .up(async (ctx) => { - const collection = ctx.mongo!.collection('targetprofiledoc'); - const cursor = collection.find({ 'targets.host': { $exists: true } }); - let migrated = 0; - for await (const doc of cursor) { - const targets = ((doc as any).targets || []).map((t: any) => { - if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) { - const { host, ...rest } = t; - return { ...rest, ip: host }; - } - return t; - }); - await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } }); - migrated++; - } - ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`); - }) + .up(async (ctx) => migrateTargetProfileTargetHosts(ctx)) .step('rename-domain-source-manual-to-dcrouter') .from('13.1.0').to('13.8.1') .description('Rename DomainDoc.source value from "manual" to "dcrouter"') @@ -120,6 +128,12 @@ export async function createMigrationRunner( await db.collection('RouteOverrideDoc').drop(); ctx.log.log('info', 'Dropped RouteOverrideDoc collection'); } + }) + .step('repair-target-profile-ip-migration') + .from('13.16.0').to('13.17.4') + .description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs') + .up(async (ctx) => { + await migrateTargetProfileTargetHosts(ctx); }); return migration; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 527200c..fe88eab 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.17.3', + version: '13.17.5', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/network/ops-view-targetprofiles.ts b/ts_web/elements/network/ops-view-targetprofiles.ts index cd8ab9b..b2d3c71 100644 --- a/ts_web/elements/network/ops-view-targetprofiles.ts +++ b/ts_web/elements/network/ops-view-targetprofiles.ts @@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement { ? html`${profile.targets.map(t => html`${t.ip}:${t.port}`)}` : '-', 'Route Refs': profile.routeRefs?.length - ? html`${profile.routeRefs.map(r => html`${r}`)}` + ? html`${profile.routeRefs.map(r => html`${this.formatRouteRef(r)}`)}` : '-', Created: new Date(profile.createdAt).toLocaleDateString(), })} @@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement { `; } - private getRouteCandidates() { + private getRouteChoices() { const routeState = appstate.routeManagementStatePart.getState(); const routes = routeState?.mergedRoutes || []; return routes - .filter((mr) => mr.route.name) - .map((mr) => ({ viewKey: mr.route.name! })); + .filter((mr) => mr.route.name && mr.id) + .map((mr) => ({ + routeId: mr.id!, + routeName: mr.route.name!, + label: `${mr.route.name} (${mr.id})`, + })); + } + + private getRouteCandidates() { + return this.getRouteChoices().map((route) => ({ viewKey: route.label })); + } + + private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined { + if (!routeRefs?.length) return undefined; + + const routeChoices = this.getRouteChoices(); + const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label])); + const routeByName = new Map(); + + for (const route of routeChoices) { + const labels = routeByName.get(route.routeName) || []; + labels.push(route.label); + routeByName.set(route.routeName, labels); + } + + return routeRefs.map((routeRef) => { + const routeLabel = routeById.get(routeRef); + if (routeLabel) return routeLabel; + + const labelsForName = routeByName.get(routeRef) || []; + if (labelsForName.length === 1) return labelsForName[0]; + + return routeRef; + }); + } + + private resolveRouteLabelsToRefs(routeRefs: string[]): string[] { + if (!routeRefs.length) return []; + + const labelToId = new Map( + this.getRouteChoices().map((route) => [route.label, route.routeId]), + ); + return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef); + } + + private formatRouteRef(routeRef: string): string { + return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef; } private async ensureRoutesLoaded() { @@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement { }; }) .filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port)); - const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : []; + const routeRefs = this.resolveRouteLabelsToRefs( + Array.isArray(data.routeRefs) ? data.routeRefs : [], + ); await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, { name: String(data.name), @@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement { private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) { const currentDomains = profile.domains || []; const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || []; - const currentRouteRefs = profile.routeRefs || []; + const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || []; const { DeesModal } = await import('@design.estate/dees-catalog'); await this.ensureRoutesLoaded(); @@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement { }; }) .filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port)); - const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : []; + const routeRefs = this.resolveRouteLabelsToRefs( + Array.isArray(data.routeRefs) ? data.routeRefs : [], + ); await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, { id: profile.id, @@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
Route Refs
${profile.routeRefs?.length - ? profile.routeRefs.map(r => html`${r}`) + ? profile.routeRefs.map(r => html`${this.formatRouteRef(r)}`) : '-'}
diff --git a/ts_web/elements/network/ops-view-vpn.ts b/ts_web/elements/network/ops-view-vpn.ts index ee3b64b..2fe8a73 100644 --- a/ts_web/elements/network/ops-view-vpn.ts +++ b/ts_web/elements/network/ops-view-vpn.ts @@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) { 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 = show; // always show (forceTarget is always on) + if (hostIpGroup) hostIpGroup.style.display = 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'; @@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement { if (!form) return; const data = await form.collectFormData(); if (!data.clientId) return; - const targetProfileIds = this.resolveProfileNamesToIds( + const targetProfileIds = this.resolveProfileLabelsToIds( Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [], ); @@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement { description: data.description || undefined, targetProfileIds, - useHostIp: useHostIp || undefined, - useDhcp: useDhcp || undefined, + useHostIp, + useDhcp, staticIp, - forceVlan: forceVlan || undefined, + forceVlan, vlanId, destinationAllowList, destinationBlockList, @@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
Transport${conn.transport}
` : ''}
Description${client.description || '-'}
-
Target Profiles${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}
+
Target Profiles${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}
Routing${client.useHostIp ? 'Host IP' : 'SmartProxy'}
${client.useHostIp ? html`
Host IP${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement { const client = actionData.item as interfaces.data.IVpnClient; const { DeesModal } = await import('@design.estate/dees-catalog'); const currentDescription = client.description ?? ''; - const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || []; + const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || []; const profileCandidates = this.getTargetProfileCandidates(); const currentUseHostIp = client.useHostIp ?? false; const currentUseDhcp = client.useDhcp ?? false; @@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement { const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); if (!form) return; const data = await form.collectFormData(); - const targetProfileIds = this.resolveProfileNamesToIds( + const targetProfileIds = this.resolveProfileLabelsToIds( Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [], ); @@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement { description: data.description || undefined, targetProfileIds, - useHostIp: useHostIp || undefined, - useDhcp: useDhcp || undefined, + useHostIp, + useDhcp, staticIp, - forceVlan: forceVlan || undefined, + forceVlan, vlanId, destinationAllowList, destinationBlockList, @@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement { } /** - * Build autocomplete candidates from loaded target profiles. - * viewKey = profile name (displayed), payload = { id } (carried for resolution). + * Build stable profile labels for list inputs. */ - private getTargetProfileCandidates() { + private getTargetProfileChoices() { const profileState = appstate.targetProfilesStatePart.getState(); const profiles = profileState?.profiles || []; - return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } })); + const nameCounts = new Map(); + + for (const profile of profiles) { + nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1); + } + + return profiles.map((profile) => ({ + id: profile.id, + label: (nameCounts.get(profile.name) || 0) > 1 + ? `${profile.name} (${profile.id})` + : profile.name, + })); + } + + private getTargetProfileCandidates() { + return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label })); } /** - * Convert profile IDs to profile names (for populating edit form values). + * Convert profile IDs to form labels (for populating edit form values). */ - private resolveProfileIdsToNames(ids?: string[]): string[] | undefined { + private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined { if (!ids?.length) return undefined; - const profileState = appstate.targetProfilesStatePart.getState(); - const profiles = profileState?.profiles || []; + const choices = this.getTargetProfileChoices(); + const labelsById = new Map(choices.map((profile) => [profile.id, profile.label])); return ids.map((id) => { - const profile = profiles.find((p) => p.id === id); - return profile?.name || id; + return labelsById.get(id) || id; }); } /** - * Convert profile names back to IDs (for saving form data). - * Uses the dees-input-list candidates' payload when available. + * Convert profile form labels back to IDs. */ - private resolveProfileNamesToIds(names: string[]): string[] | undefined { - if (!names.length) return undefined; - const profileState = appstate.targetProfilesStatePart.getState(); - const profiles = profileState?.profiles || []; - return names - .map((name) => { - const profile = profiles.find((p) => p.name === name); - return profile?.id; - }) + private resolveProfileLabelsToIds(labels: string[]): string[] { + if (!labels.length) return []; + + const labelsToIds = new Map( + this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]), + ); + return labels + .map((label) => labelsToIds.get(label)) .filter((id): id is string => !!id); } }