From 49606ae0072cea59ef718a6a3b765ea30fa5983a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 17 Feb 2026 14:17:18 +0000 Subject: [PATCH] feat(remote-ingress): support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI --- changelog.md | 9 +++ ts/00_commitinfo_data.ts | 2 +- .../handlers/remoteingress.handler.ts | 31 +++++-- .../classes.remoteingress-manager.ts | 34 ++++++-- ts_interfaces/data/remoteingress.ts | 8 +- ts_interfaces/requests/remoteingress.ts | 2 + ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 36 +++++++++ ts_web/elements/ops-view-remoteingress.ts | 81 +++++++++++++++++-- 9 files changed, 183 insertions(+), 22 deletions(-) diff --git a/changelog.md b/changelog.md index c84f298..bce33f8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-17 - 6.8.0 - feat(remote-ingress) +support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI + +- Add autoDerivePorts flag to IRemoteIngress with default true and migration to set existing stored edges to autoDerivePorts = true +- RemoteIngressManager: getEffectiveListenPorts now returns the union of manual + derived ports when autoDerivePorts is enabled; added getPortBreakdown to return manual vs derived lists +- API handlers updated: create/update requests accept autoDerivePorts; responses now include effectiveListenPorts, manualPorts, and derivedPorts (secrets still masked) +- Web UI updated: create and edit dialogs include an Auto-derive checkbox; port badges now visually distinguish manual vs derived ports; added updateRemoteIngressAction +- Non-breaking change: new field defaults to true so existing behavior is preserved + ## 2026-02-17 - 6.7.0 - feat(remote-ingress) Support auto-derived effective listen ports, make listenPorts optional, add toggle action and refine remote ingress creation/management UI diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index dba153b..62881c7 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: '6.7.0', + version: '6.8.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/opsserver/handlers/remoteingress.handler.ts b/ts/opsserver/handlers/remoteingress.handler.ts index 2ecd1d0..4b15443 100644 --- a/ts/opsserver/handlers/remoteingress.handler.ts +++ b/ts/opsserver/handlers/remoteingress.handler.ts @@ -20,12 +20,17 @@ export class RemoteIngressHandler { if (!manager) { return { edges: [] }; } - // Return edges without secrets, enriched with effective listen ports - const edges = manager.getAllEdges().map((e) => ({ - ...e, - secret: '********', // Never expose secrets via API - effectiveListenPorts: manager.getEffectiveListenPorts(e), - })); + // Return edges without secrets, enriched with effective listen ports and breakdown + const edges = manager.getAllEdges().map((e) => { + const breakdown = manager.getPortBreakdown(e); + return { + ...e, + secret: '********', // Never expose secrets via API + effectiveListenPorts: manager.getEffectiveListenPorts(e), + manualPorts: breakdown.manual, + derivedPorts: breakdown.derived, + }; + }); return { edges }; }, ), @@ -50,6 +55,7 @@ export class RemoteIngressHandler { dataArg.name, dataArg.listenPorts || [], dataArg.tags, + dataArg.autoDerivePorts ?? true, ); // Sync allowed edges with the hub @@ -102,6 +108,7 @@ export class RemoteIngressHandler { const edge = await manager.updateEdge(dataArg.id, { name: dataArg.name, listenPorts: dataArg.listenPorts, + autoDerivePorts: dataArg.autoDerivePorts, enabled: dataArg.enabled, tags: dataArg.tags, }); @@ -115,7 +122,17 @@ export class RemoteIngressHandler { await tunnelManager.syncAllowedEdges(); } - return { success: true, edge: { ...edge, secret: '********' } }; + const breakdown = manager.getPortBreakdown(edge); + return { + success: true, + edge: { + ...edge, + secret: '********', + effectiveListenPorts: manager.getEffectiveListenPorts(edge), + manualPorts: breakdown.manual, + derivedPorts: breakdown.derived, + }, + }; }, ), ); diff --git a/ts/remoteingress/classes.remoteingress-manager.ts b/ts/remoteingress/classes.remoteingress-manager.ts index b453bdd..23064a2 100644 --- a/ts/remoteingress/classes.remoteingress-manager.ts +++ b/ts/remoteingress/classes.remoteingress-manager.ts @@ -47,6 +47,11 @@ export class RemoteIngressManager { for (const key of keys) { const edge = await this.storageManager.getJSON(key); if (edge) { + // Migration: old edges without autoDerivePorts default to true + if ((edge as any).autoDerivePorts === undefined) { + edge.autoDerivePorts = true; + await this.storageManager.setJSON(key, edge); + } this.edges.set(edge.id, edge); } } @@ -91,13 +96,28 @@ export class RemoteIngressManager { /** * Get the effective listen ports for an edge. - * Returns manual listenPorts if non-empty, otherwise derives ports from tagged routes. + * Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true. */ public getEffectiveListenPorts(edge: IRemoteIngress): number[] { - if (edge.listenPorts && edge.listenPorts.length > 0) { - return edge.listenPorts; - } - return this.derivePortsForEdge(edge.id, edge.tags); + const manualPorts = edge.listenPorts || []; + const shouldDerive = edge.autoDerivePorts !== false; + if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b); + const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags); + return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b); + } + + /** + * Get manual and derived port breakdown for an edge (used in API responses). + * Derived ports exclude any ports already present in the manual list. + */ + public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } { + const manual = edge.listenPorts || []; + const shouldDerive = edge.autoDerivePorts !== false; + if (!shouldDerive) return { manual, derived: [] }; + const manualSet = new Set(manual); + const allDerived = this.derivePortsForEdge(edge.id, edge.tags); + const derived = allDerived.filter((p) => !manualSet.has(p)); + return { manual, derived }; } /** @@ -107,6 +127,7 @@ export class RemoteIngressManager { name: string, listenPorts: number[] = [], tags?: string[], + autoDerivePorts: boolean = true, ): Promise { const id = plugins.uuid.v4(); const secret = plugins.crypto.randomBytes(32).toString('hex'); @@ -118,6 +139,7 @@ export class RemoteIngressManager { secret, listenPorts, enabled: true, + autoDerivePorts, tags: tags || [], createdAt: now, updatedAt: now, @@ -150,6 +172,7 @@ export class RemoteIngressManager { updates: { name?: string; listenPorts?: number[]; + autoDerivePorts?: boolean; enabled?: boolean; tags?: string[]; }, @@ -161,6 +184,7 @@ export class RemoteIngressManager { if (updates.name !== undefined) edge.name = updates.name; if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts; + if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts; if (updates.enabled !== undefined) edge.enabled = updates.enabled; if (updates.tags !== undefined) edge.tags = updates.tags; edge.updatedAt = Date.now(); diff --git a/ts_interfaces/data/remoteingress.ts b/ts_interfaces/data/remoteingress.ts index 3e27e0f..15bd7eb 100644 --- a/ts_interfaces/data/remoteingress.ts +++ b/ts_interfaces/data/remoteingress.ts @@ -9,11 +9,17 @@ export interface IRemoteIngress { secret: string; listenPorts: number[]; enabled: boolean; + /** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */ + autoDerivePorts: boolean; tags?: string[]; createdAt: number; updatedAt: number; - /** Effective ports derived from route configs — only present in API responses. */ + /** Effective ports (union of manual + derived) — only present in API responses. */ effectiveListenPorts?: number[]; + /** Ports explicitly set by the user — only present in API responses. */ + manualPorts?: number[]; + /** Ports auto-derived from route configs — only present in API responses. */ + derivedPorts?: number[]; } /** diff --git a/ts_interfaces/requests/remoteingress.ts b/ts_interfaces/requests/remoteingress.ts index 21ff936..c4d33c6 100644 --- a/ts_interfaces/requests/remoteingress.ts +++ b/ts_interfaces/requests/remoteingress.ts @@ -18,6 +18,7 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces identity?: authInterfaces.IIdentity; name: string; listenPorts?: number[]; + autoDerivePorts?: boolean; tags?: string[]; }; response: { @@ -57,6 +58,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces id: string; name?: string; listenPorts?: number[]; + autoDerivePorts?: boolean; enabled?: boolean; tags?: string[]; }; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index dba153b..62881c7 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: '6.7.0', + version: '6.8.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 916370f..655596b 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -822,6 +822,7 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn export const createRemoteIngressAction = remoteIngressStatePart.createAction<{ name: string; listenPorts?: number[]; + autoDerivePorts?: boolean; tags?: string[]; }>(async (statePartArg, dataArg) => { const context = getActionContext(); @@ -836,6 +837,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{ identity: context.identity, name: dataArg.name, listenPorts: dataArg.listenPorts, + autoDerivePorts: dataArg.autoDerivePorts, tags: dataArg.tags, }); @@ -883,6 +885,40 @@ export const deleteRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg, dataArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateRemoteIngress + >('/typedrequest', 'updateRemoteIngress'); + + await request.fire({ + identity: context.identity, + id: dataArg.id, + name: dataArg.name, + listenPorts: dataArg.listenPorts, + autoDerivePorts: dataArg.autoDerivePorts, + tags: dataArg.tags, + }); + + await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to update edge', + }; + } +}); + export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction( async (statePartArg, edgeId) => { const context = getActionContext(); diff --git a/ts_web/elements/ops-view-remoteingress.ts b/ts_web/elements/ops-view-remoteingress.ts index 522b710..ddcee7f 100644 --- a/ts_web/elements/ops-view-remoteingress.ts +++ b/ts_web/elements/ops-view-remoteingress.ts @@ -114,6 +114,17 @@ export class OpsViewRemoteIngress extends DeesElement { background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')}; } + + .portBadge.manual { + background: ${cssManager.bdTheme('#eff6ff', '#172554')}; + color: ${cssManager.bdTheme('#1e40af', '#60a5fa')}; + } + + .portBadge.derived { + background: ${cssManager.bdTheme('#ecfdf5', '#022c22')}; + color: ${cssManager.bdTheme('#047857', '#34d399')}; + border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')}; + } `, ]; @@ -203,7 +214,8 @@ export class OpsViewRemoteIngress extends DeesElement { content: html` - + + `, @@ -226,12 +238,13 @@ export class OpsViewRemoteIngress extends DeesElement { const listenPorts = portsStr ? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p)) : undefined; + const autoDerivePorts = formData.autoDerivePorts !== false; const tags = formData.tags ? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined; await appstate.remoteIngressStatePart.dispatchAction( appstate.createRemoteIngressAction, - { name, listenPorts, tags }, + { name, listenPorts, autoDerivePorts, tags }, ); await modalArg.destroy(); }, @@ -266,6 +279,61 @@ export class OpsViewRemoteIngress extends DeesElement { ); }, }, + { + name: 'Edit', + iconName: 'lucide:pencil', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const edge = actionData.item as interfaces.data.IRemoteIngress; + const { DeesModal } = await import('@design.estate/dees-catalog'); + await DeesModal.createAndShow({ + heading: `Edit Edge: ${edge.name}`, + content: html` + + + + + + + `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Save', + iconName: 'lucide:check', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const formData = await form.collectFormData(); + const portsStr = formData.listenPorts?.trim(); + const listenPorts = portsStr + ? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p)) + : []; + const autoDerivePorts = formData.autoDerivePorts !== false; + const tags = formData.tags + ? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean) + : []; + await appstate.remoteIngressStatePart.dispatchAction( + appstate.updateRemoteIngressAction, + { + id: edge.id, + name: formData.name || edge.name, + listenPorts, + autoDerivePorts, + tags, + }, + ); + await modalArg.destroy(); + }, + }, + ], + }); + }, + }, { name: 'Regenerate Secret', iconName: 'lucide:key', @@ -317,13 +385,12 @@ export class OpsViewRemoteIngress extends DeesElement { } private getPortsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult { - const hasManualPorts = edge.listenPorts && edge.listenPorts.length > 0; - const ports = hasManualPorts ? edge.listenPorts : (edge.effectiveListenPorts || []); - const isAuto = !hasManualPorts && ports.length > 0; - if (ports.length === 0) { + const manualPorts = edge.manualPorts || []; + const derivedPorts = edge.derivedPorts || []; + if (manualPorts.length === 0 && derivedPorts.length === 0) { return html`none`; } - return html`
${ports.map(p => html`${p}`)}${isAuto ? html`(auto)` : ''}
`; + return html`
${manualPorts.map(p => html`${p}`)}${derivedPorts.map(p => html`${p}`)}${derivedPorts.length > 0 ? html`(auto)` : ''}
`; } private getEdgeTunnelCount(edgeId: string): number {