diff --git a/changelog.md b/changelog.md index b218b22..91c5c84 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-04-06 - 13.0.11 - fix(routing) +serialize route updates and correct VPN-gated route application + +- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites. +- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely. +- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently. +- Reference resolution now expands network targets with multiple hosts into multiple route targets. +- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI. + ## 2026-04-06 - 13.0.10 - fix(repo) no changes to commit diff --git a/package.json b/package.json index 6f8b77e..fd5cd6b 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.3.0", "@push.rocks/smartunique": "^3.0.9", - "@push.rocks/smartvpn": "1.19.1", + "@push.rocks/smartvpn": "1.19.2", "@push.rocks/taskbuffer": "^8.0.2", "@serve.zone/catalog": "^2.11.2", "@serve.zone/interfaces": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3829e34..c80ff60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^3.0.9 version: 3.0.9 '@push.rocks/smartvpn': - specifier: 1.19.1 - version: 1.19.1 + specifier: 1.19.2 + version: 1.19.2 '@push.rocks/taskbuffer': specifier: ^8.0.2 version: 8.0.2 @@ -1345,8 +1345,8 @@ packages: '@push.rocks/smartversion@3.0.5': resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==} - '@push.rocks/smartvpn@1.19.1': - resolution: {integrity: sha512-zvC/rrba1tZcXzzzrhX97BEUN6smo1KcqcULu6ZAGpDNhR7c5PU8oWwFxIy33UdDf5NLActkS0L3dq42sGB8nw==} + '@push.rocks/smartvpn@1.19.2': + resolution: {integrity: sha512-ygy7jnd4lfXmsHpdL0jS2k6bQAicSSoYcz7OzRpD0jQ970ghAnq2TgC3ccDl23YT9pt0QJPQLkGbVXN5+adQVg==} '@push.rocks/smartwatch@6.4.0': resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} @@ -6723,7 +6723,7 @@ snapshots: '@types/semver': 7.7.1 semver: 7.7.4 - '@push.rocks/smartvpn@1.19.1': + '@push.rocks/smartvpn@1.19.2': dependencies: '@push.rocks/smartnftables': 1.1.0 '@push.rocks/smartpath': 6.0.0 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3a429d6..81fbbbd 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.0.10', + version: '13.0.11', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 295d2ce..48c2305 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -431,7 +431,15 @@ export class DcRouter { // failed silently (SmartProxy doesn't emit certificate-failed for this path). // Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally, // which calls certProvisionFunction again — now with smartAcmeReady === true. - if (this.smartProxy) { + if (this.routeConfigManager) { + // Go through RouteConfigManager to get the full merged route set + // and serialize via the route-update mutex (prevents stale overwrites) + logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager'); + this.routeConfigManager.applyRoutes().catch((err: any) => { + logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`); + }); + } else if (this.smartProxy) { + // No RouteConfigManager (DB disabled) — re-send current routes to trigger cert provisioning if (this.certProvisionScheduler) { this.certProvisionScheduler.clear(); } @@ -477,7 +485,8 @@ export class DcRouter { this.options.vpnConfig?.enabled ? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => { if (!this.vpnManager || !this.targetProfileManager) { - return [this.options.vpnConfig?.subnet || '10.8.0.0/24']; + // VPN not ready yet — deny all until re-apply after VPN starts + return []; } return this.targetProfileManager.getMatchingClientIps( route, routeId, this.vpnManager.listClients(), @@ -2149,7 +2158,10 @@ export class DcRouter { bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd, onClientChanged: () => { // Re-apply routes so profile-based ipAllowLists get updated - this.routeConfigManager?.applyRoutes(); + // (serialized by RouteConfigManager's mutex — safe as fire-and-forget) + this.routeConfigManager?.applyRoutes().catch((err) => { + logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`); + }); }, getClientDirectTargets: (targetProfileIds: string[]) => { if (!this.targetProfileManager) return []; @@ -2191,7 +2203,7 @@ export class DcRouter { // Re-apply routes now that VPN clients are loaded — ensures hardcoded routes // get correct profile-based ipAllowLists (not possible during setupSmartProxy since // VPN server wasn't ready yet) - this.routeConfigManager?.applyRoutes(); + await this.routeConfigManager?.applyRoutes(); } /** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */ @@ -2209,6 +2221,11 @@ export class DcRouter { const { promises: dnsPromises } = await import('dns'); const ips = await dnsPromises.resolve4(domain); this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 }); + // Evict oldest entries if cache exceeds 1000 entries + if (this.vpnDomainIpCache.size > 1000) { + const firstKey = this.vpnDomainIpCache.keys().next().value; + if (firstKey) this.vpnDomainIpCache.delete(firstKey); + } return ips; } catch (err) { logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`); diff --git a/ts/config/classes.reference-resolver.ts b/ts/config/classes.reference-resolver.ts index 1b06554..941c904 100644 --- a/ts/config/classes.reference-resolver.ts +++ b/ts/config/classes.reference-resolver.ts @@ -308,14 +308,15 @@ export class ReferenceResolver { if (resolvedMetadata.networkTargetRef) { const target = this.targets.get(resolvedMetadata.networkTargetRef); if (target) { + const hosts = Array.isArray(target.host) ? target.host : [target.host]; route = { ...route, action: { ...route.action, - targets: [{ - host: target.host as string, + targets: hosts.map((h) => ({ + host: h, port: target.port, - }], + })), }, }; resolvedMetadata.networkTargetName = target.name; diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 4de6eef..c948985 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -12,10 +12,41 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js'; import type { ReferenceResolver } from './classes.reference-resolver.js'; +/** + * Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine + * never receives rapid overlapping route updates that can churn UDP/QUIC listeners. + */ +class RouteUpdateMutex { + private locked = false; + private queue: Array<() => void> = []; + + async runExclusive(fn: () => Promise): Promise { + await new Promise((resolve) => { + if (!this.locked) { + this.locked = true; + resolve(); + } else { + this.queue.push(resolve); + } + }); + try { + return await fn(); + } finally { + this.locked = false; + const next = this.queue.shift(); + if (next) { + this.locked = true; + next(); + } + } + } +} + export class RouteConfigManager { private storedRoutes = new Map(); private overrides = new Map(); private warnings: IRouteWarning[] = []; + private routeUpdateMutex = new RouteUpdateMutex(); constructor( private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], @@ -357,57 +388,60 @@ export class RouteConfigManager { // ========================================================================= public async applyRoutes(): Promise { - const smartProxy = this.getSmartProxy(); - if (!smartProxy) return; + await this.routeUpdateMutex.runExclusive(async () => { + const smartProxy = this.getSmartProxy(); + if (!smartProxy) return; - const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; + const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; - const http3Config = this.getHttp3Config?.(); - const vpnCallback = this.getVpnClientIpsForRoute; + const http3Config = this.getHttp3Config?.(); + const vpnCallback = this.getVpnClientIpsForRoute; - // Helper: inject VPN security into a vpnOnly route - const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => { - if (!vpnCallback) return route; - const dcRoute = route as IDcRouterRouteConfig; - if (!dcRoute.vpnOnly) return route; - const allowList = vpnCallback(dcRoute, routeId); - return { - ...route, - security: { - ...route.security, - ipAllowList: allowList, - }, + // Helper: inject VPN security into a vpnOnly route + const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => { + if (!vpnCallback) return route; + const dcRoute = route as IDcRouterRouteConfig; + if (!dcRoute.vpnOnly) return route; + const vpnIps = vpnCallback(dcRoute, routeId); + const existingIps = route.security?.ipAllowList || []; + return { + ...route, + security: { + ...route.security, + ipAllowList: [...existingIps, ...vpnIps], + }, + }; }; - }; - // Add enabled hardcoded routes (respecting overrides, with fresh VPN injection) - for (const route of this.getHardcodedRoutes()) { - const name = route.name || ''; - const override = this.overrides.get(name); - if (override && !override.enabled) { - continue; // Skip disabled hardcoded route - } - enabledRoutes.push(injectVpn(route)); - } - - // Add enabled programmatic routes (with HTTP/3 and VPN augmentation) - for (const stored of this.storedRoutes.values()) { - if (stored.enabled) { - let route = stored.route; - if (http3Config?.enabled !== false) { - route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); + // Add enabled hardcoded routes (respecting overrides, with fresh VPN injection) + for (const route of this.getHardcodedRoutes()) { + const name = route.name || ''; + const override = this.overrides.get(name); + if (override && !override.enabled) { + continue; // Skip disabled hardcoded route } - enabledRoutes.push(injectVpn(route, stored.id)); + enabledRoutes.push(injectVpn(route)); } - } - await smartProxy.updateRoutes(enabledRoutes); + // Add enabled programmatic routes (with HTTP/3 and VPN augmentation) + for (const stored of this.storedRoutes.values()) { + if (stored.enabled) { + let route = stored.route; + if (http3Config?.enabled !== false) { + route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); + } + enabledRoutes.push(injectVpn(route, stored.id)); + } + } - // Notify listeners (e.g. RemoteIngressManager) of the merged route set - if (this.onRoutesApplied) { - this.onRoutesApplied(enabledRoutes); - } + await smartProxy.updateRoutes(enabledRoutes); - logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); + // Notify listeners (e.g. RemoteIngressManager) of the merged route set + if (this.onRoutesApplied) { + this.onRoutesApplied(enabledRoutes); + } + + logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); + }); } } diff --git a/ts/config/classes.target-profile-manager.ts b/ts/config/classes.target-profile-manager.ts index 3989afe..8a56e7c 100644 --- a/ts/config/classes.target-profile-manager.ts +++ b/ts/config/classes.target-profile-manager.ts @@ -33,6 +33,13 @@ export class TargetProfileManager { routeRefs?: string[]; createdBy: string; }): Promise { + // Enforce unique profile names + for (const existing of this.profiles.values()) { + if (existing.name === data.name) { + throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`); + } + } + const id = plugins.uuid.v4(); const now = Date.now(); diff --git a/ts/db/documents/classes.source-profile.doc.ts b/ts/db/documents/classes.source-profile.doc.ts index 4d38d37..976ddd7 100644 --- a/ts/db/documents/classes.source-profile.doc.ts +++ b/ts/db/documents/classes.source-profile.doc.ts @@ -39,10 +39,6 @@ export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc { - return await SourceProfileDoc.getInstance({ name }); - } - public static async findAll(): Promise { return await SourceProfileDoc.getInstances({}); } diff --git a/ts/db/documents/classes.target-profile.doc.ts b/ts/db/documents/classes.target-profile.doc.ts index b23cc34..b8647ae 100644 --- a/ts/db/documents/classes.target-profile.doc.ts +++ b/ts/db/documents/classes.target-profile.doc.ts @@ -42,10 +42,6 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc { - return await TargetProfileDoc.getInstance({ name }); - } - public static async findAll(): Promise { return await TargetProfileDoc.getInstances({}); } diff --git a/ts/db/documents/classes.vpn-client.doc.ts b/ts/db/documents/classes.vpn-client.doc.ts index f957548..bb62644 100644 --- a/ts/db/documents/classes.vpn-client.doc.ts +++ b/ts/db/documents/classes.vpn-client.doc.ts @@ -67,15 +67,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc { - return await VpnClientDoc.getInstance({ clientId }); - } - public static async findAll(): Promise { return await VpnClientDoc.getInstances({}); } - - public static async findEnabled(): Promise { - return await VpnClientDoc.getInstances({ enabled: true }); - } } diff --git a/ts/opsserver/handlers/target-profile.handler.ts b/ts/opsserver/handlers/target-profile.handler.ts index c1dce0b..10b38af 100644 --- a/ts/opsserver/handlers/target-profile.handler.ts +++ b/ts/opsserver/handlers/target-profile.handler.ts @@ -111,8 +111,8 @@ export class TargetProfileHandler { routeRefs: dataArg.routeRefs, }); // Re-apply routes and refresh VPN client security to update access - this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); - this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity(); + await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); + await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity(); return { success: true }; }, ), @@ -131,8 +131,8 @@ export class TargetProfileHandler { const result = await manager.deleteProfile(dataArg.id, dataArg.force); if (result.success) { // Re-apply routes and refresh VPN client security to update access - this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); - this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity(); + await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); + await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity(); } return result; }, diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index 7737d8c..c14eb7c 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -267,7 +267,18 @@ export class VpnManager { doc.vlanId = opts.vlanId; } this.clients.set(doc.clientId, doc); - await this.persistClient(doc); + try { + await this.persistClient(doc); + } catch (err) { + // Rollback: remove from in-memory map and daemon to stay consistent with DB + this.clients.delete(doc.clientId); + try { + await this.vpnServer!.removeClient(doc.clientId); + } catch { + // best-effort daemon cleanup + } + throw err; + } // Sync per-client security to the running daemon const security = this.buildClientSecurity(doc); diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 3a429d6..81fbbbd 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.0.10', + version: '13.0.11', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/ops-view-sourceprofiles.ts b/ts_web/elements/ops-view-sourceprofiles.ts index 0f87c2a..cd20c80 100644 --- a/ts_web/elements/ops-view-sourceprofiles.ts +++ b/ts_web/elements/ops-view-sourceprofiles.ts @@ -149,7 +149,8 @@ export class OpsViewSourceProfiles extends DeesElement { const data = await form.collectFormData(); const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : []; const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : []; - const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined; + const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN; + const maxConnections = Number.isNaN(parsed) ? undefined : parsed; await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, { name: String(data.name), @@ -190,7 +191,8 @@ export class OpsViewSourceProfiles extends DeesElement { const data = await form.collectFormData(); const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : []; const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : []; - const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined; + const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN; + const maxConnections = Number.isNaN(parsed) ? undefined : parsed; await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, { id: profile.id,