From 2891e5d3ee0fbc4b34b1d0dc5ef78f4745b18499 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 17 Apr 2026 06:17:49 +0000 Subject: [PATCH] feat(routes): add remote ingress controls and preserve-port targeting for route configuration --- changelog.md | 8 +++ test/test.dns-runtime-routes.node.ts | 49 ++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/config/classes.route-config-manager.ts | 11 ++- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/network/ops-view-routes.ts | 79 +++++++++++++++++++--- 6 files changed, 140 insertions(+), 11 deletions(-) diff --git a/changelog.md b/changelog.md index c12c257..7af1b15 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-17 - 13.20.0 - feat(routes) +add remote ingress controls and preserve-port targeting for route configuration + +- Allow route updates to remove optional top-level properties by treating null values like remoteIngress as explicit clears. +- Add route form support for preserving the matched incoming port when forwarding to backend targets. +- Add remote ingress enablement and edge filter controls to route create/edit views. +- Cover remoteIngress removal behavior with a runtime route manager test. + ## 2026-04-16 - 13.19.1 - fix(routes) preserve inline target ports when clearing network target references diff --git a/test/test.dns-runtime-routes.node.ts b/test/test.dns-runtime-routes.node.ts index a1e7348..a4f9640 100644 --- a/test/test.dns-runtime-routes.node.ts +++ b/test/test.dns-runtime-routes.node.ts @@ -279,6 +279,55 @@ tap.test('RouteConfigManager clears a network target ref and keeps the edited in expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424); }); +tap.test('RouteConfigManager clears remote ingress config when route patch sets it to null', async () => { + await testDbPromise; + await clearTestState(); + + const appliedRoutes: any[][] = []; + const smartProxy = { + updateRoutes: async (routes: any[]) => { + appliedRoutes.push(routes); + }, + }; + + const routeManager = new RouteConfigManager( + () => smartProxy as any, + ); + await routeManager.initialize([], [], []); + + const routeId = await routeManager.createRoute( + { + name: 'remote-ingress-route', + match: { ports: [443], domains: ['app.example.com'] }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: 8443 }], + }, + remoteIngress: { + enabled: true, + edgeFilter: ['edge-a', 'blue'], + }, + } as any, + 'test-user', + ); + + const updateResult = await routeManager.updateRoute(routeId, { + route: { + remoteIngress: null, + } as any, + }); + + expect(updateResult.success).toEqual(true); + + const storedRoute = await RouteDoc.findById(routeId); + expect(storedRoute?.route.remoteIngress).toBeUndefined(); + + const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId); + expect(mergedRoute?.route.remoteIngress).toBeUndefined(); + + expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined(); +}); + tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => { await testDbPromise; await clearTestState(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ea8f790..22d0181 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.19.1', + version: '13.20.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 928287d..16cb587 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -192,7 +192,16 @@ export class RouteConfigManager { } } } - stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig; + const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig; + + // Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null) + for (const [key, val] of Object.entries(patch.route)) { + if (val === null && key !== 'action' && key !== 'match') { + delete (mergedRoute as any)[key]; + } + } + + stored.route = mergedRoute; } if (patch.enabled !== undefined) { stored.enabled = patch.enabled; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index ea8f790..22d0181 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.19.1', + version: '13.20.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/network/ops-view-routes.ts b/ts_web/elements/network/ops-view-routes.ts index e689b22..a83b3f5 100644 --- a/ts_web/elements/network/ops-view-routes.ts +++ b/ts_web/elements/network/ops-view-routes.ts @@ -43,22 +43,28 @@ function parseTargetPort(value: any): number | undefined { function getRouteTargetInputs(formEl: any) { const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[]; + const checkboxInputs = Array.from(formEl.querySelectorAll('dees-input-checkbox')) as any[]; return { hostInput: textInputs.find((input) => input.key === 'targetHost'), portInput: textInputs.find((input) => input.key === 'targetPort'), + preservePortInput: checkboxInputs.find((input) => input.key === 'preserveMatchPort'), }; } function setupTargetInputState(formEl: any) { const updateState = async () => { const data = await formEl.collectFormData(); + const contentEl = formEl.closest('.content') || formEl.parentElement; const usesNetworkTarget = !!getDropdownKey(data.networkTargetRef); - const { hostInput, portInput } = getRouteTargetInputs(formEl); + const preserveMatchPort = !usesNetworkTarget && Boolean(data.preserveMatchPort); + const { hostInput, portInput, preservePortInput } = getRouteTargetInputs(formEl); const hostDescription = usesNetworkTarget ? 'Controlled by the selected network target' : 'Used when no network target is selected'; const portDescription = usesNetworkTarget ? 'Controlled by the selected network target' + : preserveMatchPort + ? 'Forwarded to the backend on the same port the client matched' : 'Used when no network target is selected'; if (hostInput) { @@ -67,10 +73,24 @@ function setupTargetInputState(formEl: any) { hostInput.description = hostDescription; } if (portInput) { - portInput.disabled = usesNetworkTarget; - portInput.required = !usesNetworkTarget; + portInput.disabled = usesNetworkTarget || preserveMatchPort; + portInput.required = !usesNetworkTarget && !preserveMatchPort; portInput.description = portDescription; } + if (preservePortInput) { + preservePortInput.disabled = usesNetworkTarget; + preservePortInput.description = usesNetworkTarget + ? 'Unavailable when a network target is selected' + : 'Forward to the backend using the same port that matched this route'; + if (usesNetworkTarget) { + preservePortInput.value = false; + } + } + + const remoteIngressGroup = contentEl?.querySelector('.remoteIngressGroup') as HTMLElement | null; + if (remoteIngressGroup) { + remoteIngressGroup.style.display = Boolean(data.remoteIngressEnabled) ? 'flex' : 'none'; + } await formEl.updateRequiredStatus?.(); }; @@ -465,10 +485,13 @@ export class OpsViewRoutes extends DeesElement { ? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]) : []; const firstTarget = route.action.targets?.[0]; + const currentPreserveMatchPort = firstTarget?.port === 'preserve'; const currentTargetHost = firstTarget ? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host) : ''; - const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : ''; + const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : ''; + const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true; + const currentEdgeFilter = route.remoteIngress?.edgeFilter || []; // Compute current TLS state for pre-population const currentTls = (route.action as any).tls; @@ -493,6 +516,11 @@ export class OpsViewRoutes extends DeesElement { o.key === (merged.metadata?.networkTargetRef || '')) || null}> + + +
+ +
o.key === currentTlsMode) || tlsModeOptions[0]}>
o.key === currentTlsCert) || tlsCertOptions[0]}> @@ -526,14 +554,22 @@ export class OpsViewRoutes extends DeesElement { const profileKey = getDropdownKey(formData.sourceProfileRef); const targetKey = getDropdownKey(formData.networkTargetRef); - const targetPort = parseTargetPort(formData.targetPort) - ?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined); + const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort); + const targetPort = preserveMatchPort + ? 'preserve' + : parseTargetPort(formData.targetPort) + ?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined); if (targetPort === undefined) { alert('Target Port must be a valid port number when no network target is selected.'); return; } + const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled); + const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter) + ? formData.remoteIngressEdgeFilter.filter(Boolean) + : []; + const updatedRoute: any = { name: formData.name, match: { @@ -549,6 +585,12 @@ export class OpsViewRoutes extends DeesElement { }, ], }, + remoteIngress: remoteIngressEnabled + ? { + enabled: true, + ...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}), + } + : null, ...(priority != null && !isNaN(priority) ? { priority } : {}), }; @@ -640,6 +682,11 @@ export class OpsViewRoutes extends DeesElement { + + +