Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f25ca4095 | |||
| 2891e5d3ee |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-16 - 13.19.1 - fix(routes)
|
||||||
preserve inline target ports when clearing network target references
|
preserve inline target ports when clearing network target references
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "13.19.1",
|
"version": "13.20.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -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);
|
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 () => {
|
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
|
||||||
await testDbPromise;
|
await testDbPromise;
|
||||||
await clearTestState();
|
await clearTestState();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.19.1',
|
version: '13.20.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
if (patch.enabled !== undefined) {
|
||||||
stored.enabled = patch.enabled;
|
stored.enabled = patch.enabled;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.19.1',
|
version: '13.20.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,22 +43,28 @@ function parseTargetPort(value: any): number | undefined {
|
|||||||
|
|
||||||
function getRouteTargetInputs(formEl: any) {
|
function getRouteTargetInputs(formEl: any) {
|
||||||
const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[];
|
const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[];
|
||||||
|
const checkboxInputs = Array.from(formEl.querySelectorAll('dees-input-checkbox')) as any[];
|
||||||
return {
|
return {
|
||||||
hostInput: textInputs.find((input) => input.key === 'targetHost'),
|
hostInput: textInputs.find((input) => input.key === 'targetHost'),
|
||||||
portInput: textInputs.find((input) => input.key === 'targetPort'),
|
portInput: textInputs.find((input) => input.key === 'targetPort'),
|
||||||
|
preservePortInput: checkboxInputs.find((input) => input.key === 'preserveMatchPort'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTargetInputState(formEl: any) {
|
function setupTargetInputState(formEl: any) {
|
||||||
const updateState = async () => {
|
const updateState = async () => {
|
||||||
const data = await formEl.collectFormData();
|
const data = await formEl.collectFormData();
|
||||||
|
const contentEl = formEl.closest('.content') || formEl.parentElement;
|
||||||
const usesNetworkTarget = !!getDropdownKey(data.networkTargetRef);
|
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
|
const hostDescription = usesNetworkTarget
|
||||||
? 'Controlled by the selected network target'
|
? 'Controlled by the selected network target'
|
||||||
: 'Used when no network target is selected';
|
: 'Used when no network target is selected';
|
||||||
const portDescription = usesNetworkTarget
|
const portDescription = usesNetworkTarget
|
||||||
? 'Controlled by the selected network target'
|
? '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';
|
: 'Used when no network target is selected';
|
||||||
|
|
||||||
if (hostInput) {
|
if (hostInput) {
|
||||||
@@ -67,10 +73,24 @@ function setupTargetInputState(formEl: any) {
|
|||||||
hostInput.description = hostDescription;
|
hostInput.description = hostDescription;
|
||||||
}
|
}
|
||||||
if (portInput) {
|
if (portInput) {
|
||||||
portInput.disabled = usesNetworkTarget;
|
portInput.disabled = usesNetworkTarget || preserveMatchPort;
|
||||||
portInput.required = !usesNetworkTarget;
|
portInput.required = !usesNetworkTarget && !preserveMatchPort;
|
||||||
portInput.description = portDescription;
|
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?.();
|
await formEl.updateRequiredStatus?.();
|
||||||
};
|
};
|
||||||
@@ -465,10 +485,13 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
|
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
|
||||||
: [];
|
: [];
|
||||||
const firstTarget = route.action.targets?.[0];
|
const firstTarget = route.action.targets?.[0];
|
||||||
|
const currentPreserveMatchPort = firstTarget?.port === 'preserve';
|
||||||
const currentTargetHost = firstTarget
|
const currentTargetHost = firstTarget
|
||||||
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
|
? (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
|
// Compute current TLS state for pre-population
|
||||||
const currentTls = (route.action as any).tls;
|
const currentTls = (route.action as any).tls;
|
||||||
@@ -493,6 +516,11 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
||||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
||||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
||||||
|
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
|
||||||
|
<div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||||
|
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
|
||||||
|
</div>
|
||||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
|
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
|
||||||
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
|
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
|
||||||
@@ -526,14 +554,22 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
|
|
||||||
const profileKey = getDropdownKey(formData.sourceProfileRef);
|
const profileKey = getDropdownKey(formData.sourceProfileRef);
|
||||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||||
const targetPort = parseTargetPort(formData.targetPort)
|
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||||
?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined);
|
const targetPort = preserveMatchPort
|
||||||
|
? 'preserve'
|
||||||
|
: parseTargetPort(formData.targetPort)
|
||||||
|
?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined);
|
||||||
|
|
||||||
if (targetPort === undefined) {
|
if (targetPort === undefined) {
|
||||||
alert('Target Port must be a valid port number when no network target is selected.');
|
alert('Target Port must be a valid port number when no network target is selected.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
|
||||||
|
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||||
|
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
const updatedRoute: any = {
|
const updatedRoute: any = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
match: {
|
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 } : {}),
|
...(priority != null && !isNaN(priority) ? { priority } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -640,6 +682,11 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
||||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
||||||
|
<dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
|
||||||
|
<div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||||
|
<dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
|
||||||
|
</div>
|
||||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
|
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
|
||||||
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
|
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
|
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
|
||||||
@@ -673,14 +720,22 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
|
|
||||||
const profileKey = getDropdownKey(formData.sourceProfileRef);
|
const profileKey = getDropdownKey(formData.sourceProfileRef);
|
||||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||||
const targetPort = parseTargetPort(formData.targetPort)
|
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||||
?? (targetKey ? ports[0] : undefined);
|
const targetPort = preserveMatchPort
|
||||||
|
? 'preserve'
|
||||||
|
: parseTargetPort(formData.targetPort)
|
||||||
|
?? (targetKey ? ports[0] : undefined);
|
||||||
|
|
||||||
if (targetPort === undefined) {
|
if (targetPort === undefined) {
|
||||||
alert('Target Port must be a valid port number when no network target is selected.');
|
alert('Target Port must be a valid port number when no network target is selected.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
|
||||||
|
const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
|
||||||
|
? formData.remoteIngressEdgeFilter.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
const route: any = {
|
const route: any = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
match: {
|
match: {
|
||||||
@@ -696,6 +751,14 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(remoteIngressEnabled
|
||||||
|
? {
|
||||||
|
remoteIngress: {
|
||||||
|
enabled: true,
|
||||||
|
...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(priority != null && !isNaN(priority) ? { priority } : {}),
|
...(priority != null && !isNaN(priority) ? { priority } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user