diff --git a/changelog.md b/changelog.md index 4ebce0d..c12c257 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # 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) persist system DNS routes with runtime hydration and add reusable email ops DNS helpers diff --git a/test/test.dns-runtime-routes.node.ts b/test/test.dns-runtime-routes.node.ts index 87d99de..a1e7348 100644 --- a/test/test.dns-runtime-routes.node.ts +++ b/test/test.dns-runtime-routes.node.ts @@ -1,6 +1,6 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; 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 { DnsManager } from '../ts/dns/manager.dns.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); }); +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 () => { await testDbPromise; await clearTestState(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 74c6bb3..ea8f790 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.0', + version: '13.19.1', 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 5839572..928287d 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -133,11 +133,11 @@ export class RouteConfigManager { } // Resolve references if metadata has refs and resolver is available - let resolvedMetadata = metadata; - if (metadata && this.referenceResolver) { - const resolved = this.referenceResolver.resolveRoute(route, metadata); + let resolvedMetadata = this.normalizeRouteMetadata(metadata); + if (resolvedMetadata && this.referenceResolver) { + const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata); route = resolved.route; - resolvedMetadata = resolved.metadata; + resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata); } const stored: IRoute = { @@ -198,14 +198,17 @@ export class RouteConfigManager { stored.enabled = patch.enabled; } 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 if (stored.metadata && this.referenceResolver) { const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); stored.route = resolved.route; - stored.metadata = resolved.metadata; + stored.metadata = this.normalizeRouteMetadata(resolved.metadata); } stored.updatedAt = Date.now(); @@ -368,7 +371,7 @@ export class RouteConfigManager { createdBy: doc.createdBy, origin: doc.origin || 'api', systemKey: doc.systemKey, - metadata: doc.metadata, + metadata: this.normalizeRouteMetadata(doc.metadata), }; this.routes.set(doc.id, storedRoute); @@ -404,6 +407,46 @@ export class RouteConfigManager { } } + private normalizeRouteMetadata(metadata?: Partial): 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 // ========================================================================= @@ -446,7 +489,7 @@ export class RouteConfigManager { const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); stored.route = resolved.route; - stored.metadata = resolved.metadata; + stored.metadata = this.normalizeRouteMetadata(resolved.metadata); stored.updatedAt = Date.now(); await this.persistRoute(stored); } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 74c6bb3..ea8f790 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.0', + version: '13.19.1', 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 f4549f2..e689b22 100644 --- a/ts_web/elements/network/ops-view-routes.ts +++ b/ts_web/elements/network/ops-view-routes.ts @@ -15,16 +15,70 @@ import { // TLS dropdown options shared by create and edit dialogs const tlsModeOptions = [ - { key: 'none', option: '(none — no TLS)' }, - { key: 'passthrough', option: 'Passthrough' }, - { key: 'terminate', option: 'Terminate' }, - { key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt' }, + { key: 'none', option: '(none — plain TCP/HTTP, use for SSH)' }, + { key: 'passthrough', option: 'Passthrough (TLS only)' }, + { key: 'terminate', option: 'Terminate TLS' }, + { key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt TLS' }, ]; const tlsCertOptions = [ { key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' }, { 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. */ @@ -470,6 +524,16 @@ export class OpsViewRoutes extends DeesElement { : []; 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 = { name: formData.name, match: { @@ -480,8 +544,8 @@ export class OpsViewRoutes extends DeesElement { type: 'forward', targets: [ { - host: formData.targetHost || 'localhost', - port: parseInt(formData.targetPort, 10) || 443, + host: formData.targetHost || currentTargetHost || 'localhost', + port: targetPort, }, ], }, @@ -508,15 +572,17 @@ export class OpsViewRoutes extends DeesElement { } const metadata: any = {}; - const profileRefValue = formData.sourceProfileRef as any; - const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key; if (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) { metadata.networkTargetRef = targetKey; + } else if (merged.metadata?.networkTargetRef) { + metadata.networkTargetRef = ''; + metadata.networkTargetName = ''; } await appstate.routeManagementStatePart.dispatchAction( @@ -537,6 +603,7 @@ export class OpsViewRoutes extends DeesElement { if (editForm) { await editForm.updateComplete; setupTlsVisibility(editForm); + setupTargetInputState(editForm); } } @@ -604,6 +671,16 @@ export class OpsViewRoutes extends DeesElement { : []; 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 = { name: formData.name, match: { @@ -615,7 +692,7 @@ export class OpsViewRoutes extends DeesElement { targets: [ { 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 const metadata: any = {}; - const profileRefValue = formData.sourceProfileRef as any; - const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key; if (profileKey) { metadata.sourceProfileRef = profileKey; } - const targetRefValue = formData.networkTargetRef as any; - const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key; if (targetKey) { metadata.networkTargetRef = targetKey; } @@ -669,6 +742,7 @@ export class OpsViewRoutes extends DeesElement { if (createForm) { await createForm.updateComplete; setupTlsVisibility(createForm); + setupTargetInputState(createForm); } }