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 {