fix(routes): preserve inline target ports when clearing network target references

This commit is contained in:
2026-04-16 22:21:07 +00:00
parent 8bbaf26813
commit d780e02928
6 changed files with 226 additions and 26 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-04-16 - 13.19.1 - fix(routes)
preserve inline target ports when clearing network target references
- Normalize route metadata so empty reference fields are removed instead of persisted.
- Allow the routes UI to clear source profile and network target references explicitly during edits.
- Disable inline target host and port inputs when a network target is selected and validate target ports when using manual targets.
- Add runtime route tests covering removal of a network target reference while keeping the edited inline target port.
## 2026-04-15 - 13.19.0 - feat(routes,email) ## 2026-04-15 - 13.19.0 - feat(routes,email)
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers persist system DNS routes with runtime hydration and add reusable email ops DNS helpers

View File

@@ -1,6 +1,6 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js'; import { DcRouter } from '../ts/classes.dcrouter.js';
import { RouteConfigManager } from '../ts/config/index.js'; import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js'; import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js'; import { DnsManager } from '../ts/dns/manager.dns.js';
import { logger } from '../ts/logger.js'; import { logger } from '../ts/logger.js';
@@ -204,6 +204,81 @@ tap.test('RouteConfigManager only allows toggling system routes', async () => {
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false); expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
}); });
tap.test('RouteConfigManager clears a network target ref and keeps the edited inline target port', async () => {
await testDbPromise;
await clearTestState();
const appliedRoutes: any[][] = [];
const smartProxy = {
updateRoutes: async (routes: any[]) => {
appliedRoutes.push(routes);
},
};
const resolver = new ReferenceResolver();
(resolver as any).targets.set('target-1', {
id: 'target-1',
name: 'SSH TARGET',
host: '10.0.0.5',
port: 443,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: 'test',
});
const routeManager = new RouteConfigManager(
() => smartProxy as any,
undefined,
undefined,
resolver,
);
await routeManager.initialize([], [], []);
const routeId = await routeManager.createRoute(
{
name: 'ssh-route',
match: { ports: [22] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 22 }],
},
} as any,
'test-user',
true,
{ networkTargetRef: 'target-1' },
);
expect((await RouteDoc.findById(routeId))?.route.action.targets?.[0].port).toEqual(443);
expect((await RouteDoc.findById(routeId))?.metadata?.networkTargetRef).toEqual('target-1');
const updateResult = await routeManager.updateRoute(routeId, {
route: {
action: {
targets: [{ host: '127.0.0.1', port: 29424 }],
},
} as any,
metadata: {
networkTargetRef: '',
networkTargetName: '',
} as any,
});
expect(updateResult.success).toEqual(true);
const storedRoute = await RouteDoc.findById(routeId);
expect(storedRoute?.route.action.targets?.[0].host).toEqual('127.0.0.1');
expect(storedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(storedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(storedRoute?.metadata?.networkTargetName).toBeUndefined();
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
expect(mergedRoute?.route.action.targets?.[0].port).toEqual(29424);
expect(mergedRoute?.metadata?.networkTargetRef).toBeUndefined();
expect(mergedRoute?.metadata?.networkTargetName).toBeUndefined();
expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424);
});
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();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.19.0', version: '13.19.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -133,11 +133,11 @@ export class RouteConfigManager {
} }
// Resolve references if metadata has refs and resolver is available // Resolve references if metadata has refs and resolver is available
let resolvedMetadata = metadata; let resolvedMetadata = this.normalizeRouteMetadata(metadata);
if (metadata && this.referenceResolver) { if (resolvedMetadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, metadata); const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
route = resolved.route; route = resolved.route;
resolvedMetadata = resolved.metadata; resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
} }
const stored: IRoute = { const stored: IRoute = {
@@ -198,14 +198,17 @@ export class RouteConfigManager {
stored.enabled = patch.enabled; stored.enabled = patch.enabled;
} }
if (patch.metadata !== undefined) { if (patch.metadata !== undefined) {
stored.metadata = { ...stored.metadata, ...patch.metadata }; stored.metadata = this.normalizeRouteMetadata({
...stored.metadata,
...patch.metadata,
});
} }
// Re-resolve if metadata refs exist and resolver is available // Re-resolve if metadata refs exist and resolver is available
if (stored.metadata && this.referenceResolver) { if (stored.metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route; stored.route = resolved.route;
stored.metadata = resolved.metadata; stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
} }
stored.updatedAt = Date.now(); stored.updatedAt = Date.now();
@@ -368,7 +371,7 @@ export class RouteConfigManager {
createdBy: doc.createdBy, createdBy: doc.createdBy,
origin: doc.origin || 'api', origin: doc.origin || 'api',
systemKey: doc.systemKey, systemKey: doc.systemKey,
metadata: doc.metadata, metadata: this.normalizeRouteMetadata(doc.metadata),
}; };
this.routes.set(doc.id, storedRoute); this.routes.set(doc.id, storedRoute);
@@ -404,6 +407,46 @@ export class RouteConfigManager {
} }
} }
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
if (!metadata) {
return undefined;
}
const normalizeString = (value?: string): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
networkTargetRef: normalizeString(metadata.networkTargetRef),
sourceProfileName: normalizeString(metadata.sourceProfileName),
networkTargetName: normalizeString(metadata.networkTargetName),
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
? metadata.lastResolvedAt
: undefined,
};
if (!normalized.sourceProfileRef) {
normalized.sourceProfileName = undefined;
}
if (!normalized.networkTargetRef) {
normalized.networkTargetName = undefined;
}
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (Object.values(normalized).every((value) => value === undefined)) {
return undefined;
}
return normalized;
}
// ========================================================================= // =========================================================================
// Private: warnings // Private: warnings
// ========================================================================= // =========================================================================
@@ -446,7 +489,7 @@ export class RouteConfigManager {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route; stored.route = resolved.route;
stored.metadata = resolved.metadata; stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
stored.updatedAt = Date.now(); stored.updatedAt = Date.now();
await this.persistRoute(stored); await this.persistRoute(stored);
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.19.0', version: '13.19.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -15,16 +15,70 @@ import {
// TLS dropdown options shared by create and edit dialogs // TLS dropdown options shared by create and edit dialogs
const tlsModeOptions = [ const tlsModeOptions = [
{ key: 'none', option: '(none — no TLS)' }, { key: 'none', option: '(none — plain TCP/HTTP, use for SSH)' },
{ key: 'passthrough', option: 'Passthrough' }, { key: 'passthrough', option: 'Passthrough (TLS only)' },
{ key: 'terminate', option: 'Terminate' }, { key: 'terminate', option: 'Terminate TLS' },
{ key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt' }, { key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt TLS' },
]; ];
const tlsCertOptions = [ const tlsCertOptions = [
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' }, { key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
{ key: 'custom', option: 'Custom certificate' }, { key: 'custom', option: 'Custom certificate' },
]; ];
function getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
function parseTargetPort(value: any): number | undefined {
const parsed = typeof value === 'number'
? value
: typeof value === 'string'
? parseInt(value.trim(), 10)
: Number.NaN;
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
return undefined;
}
return parsed;
}
function getRouteTargetInputs(formEl: any) {
const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[];
return {
hostInput: textInputs.find((input) => input.key === 'targetHost'),
portInput: textInputs.find((input) => input.key === 'targetPort'),
};
}
function setupTargetInputState(formEl: any) {
const updateState = async () => {
const data = await formEl.collectFormData();
const usesNetworkTarget = !!getDropdownKey(data.networkTargetRef);
const { hostInput, portInput } = 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'
: 'Used when no network target is selected';
if (hostInput) {
hostInput.disabled = usesNetworkTarget;
hostInput.required = !usesNetworkTarget;
hostInput.description = hostDescription;
}
if (portInput) {
portInput.disabled = usesNetworkTarget;
portInput.required = !usesNetworkTarget;
portInput.description = portDescription;
}
await formEl.updateRequiredStatus?.();
};
formEl.changeSubject.subscribe(() => updateState());
updateState();
}
/** /**
* Toggle TLS form field visibility based on selected TLS mode and certificate type. * Toggle TLS form field visibility based on selected TLS mode and certificate type.
*/ */
@@ -470,6 +524,16 @@ export class OpsViewRoutes extends DeesElement {
: []; : [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined; const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const targetKey = getDropdownKey(formData.networkTargetRef);
const targetPort = 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 updatedRoute: any = { const updatedRoute: any = {
name: formData.name, name: formData.name,
match: { match: {
@@ -480,8 +544,8 @@ export class OpsViewRoutes extends DeesElement {
type: 'forward', type: 'forward',
targets: [ targets: [
{ {
host: formData.targetHost || 'localhost', host: formData.targetHost || currentTargetHost || 'localhost',
port: parseInt(formData.targetPort, 10) || 443, port: targetPort,
}, },
], ],
}, },
@@ -508,15 +572,17 @@ export class OpsViewRoutes extends DeesElement {
} }
const metadata: any = {}; const metadata: any = {};
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) { if (profileKey) {
metadata.sourceProfileRef = profileKey; metadata.sourceProfileRef = profileKey;
} else if (merged.metadata?.sourceProfileRef) {
metadata.sourceProfileRef = '';
metadata.sourceProfileName = '';
} }
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
if (targetKey) { if (targetKey) {
metadata.networkTargetRef = targetKey; metadata.networkTargetRef = targetKey;
} else if (merged.metadata?.networkTargetRef) {
metadata.networkTargetRef = '';
metadata.networkTargetName = '';
} }
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
@@ -537,6 +603,7 @@ export class OpsViewRoutes extends DeesElement {
if (editForm) { if (editForm) {
await editForm.updateComplete; await editForm.updateComplete;
setupTlsVisibility(editForm); setupTlsVisibility(editForm);
setupTargetInputState(editForm);
} }
} }
@@ -604,6 +671,16 @@ export class OpsViewRoutes extends DeesElement {
: []; : [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined; const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const targetKey = getDropdownKey(formData.networkTargetRef);
const targetPort = parseTargetPort(formData.targetPort)
?? (targetKey ? ports[0] : undefined);
if (targetPort === undefined) {
alert('Target Port must be a valid port number when no network target is selected.');
return;
}
const route: any = { const route: any = {
name: formData.name, name: formData.name,
match: { match: {
@@ -615,7 +692,7 @@ export class OpsViewRoutes extends DeesElement {
targets: [ targets: [
{ {
host: formData.targetHost || 'localhost', host: formData.targetHost || 'localhost',
port: parseInt(formData.targetPort, 10) || 443, port: targetPort,
}, },
], ],
}, },
@@ -641,13 +718,9 @@ export class OpsViewRoutes extends DeesElement {
// Build metadata if profile/target selected // Build metadata if profile/target selected
const metadata: any = {}; const metadata: any = {};
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) { if (profileKey) {
metadata.sourceProfileRef = profileKey; metadata.sourceProfileRef = profileKey;
} }
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
if (targetKey) { if (targetKey) {
metadata.networkTargetRef = targetKey; metadata.networkTargetRef = targetKey;
} }
@@ -669,6 +742,7 @@ export class OpsViewRoutes extends DeesElement {
if (createForm) { if (createForm) {
await createForm.updateComplete; await createForm.updateComplete;
setupTlsVisibility(createForm); setupTlsVisibility(createForm);
setupTargetInputState(createForm);
} }
} }