diff --git a/changelog.md b/changelog.md index 7b6c74b..c101e98 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-16 - 6.4.0 - feat(remoteingress) +add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI + +- Introduce RemoteIngressManager for CRUD and persistent storage of edge registrations +- Introduce TunnelManager to run the RemoteIngressHub, track connected edge statuses, and sync allowed edges to the hub +- Integrate remote ingress into DcRouter (options.remoteIngressConfig, setupRemoteIngress, startup/shutdown handling, and startup summary) +- Add OpsServer RemoteIngressHandler exposing typedrequest APIs (create/update/delete/regenerate/get/status) +- Add web UI: Remote Ingress view, app state parts, actions and components to manage edges and display runtime statuses +- Add typedrequest and data interfaces for remoteingress and export the remoteingress module; add @serve.zone/remoteingress dependency in package.json + ## 2026-02-16 - 6.3.0 - feat(dcrouter) add configurable baseDir and centralized path resolution; use resolved data paths for storage, cache and DNS diff --git a/package.json b/package.json index 572b903..dd8a3d0 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@push.rocks/smartstate": "^2.0.30", "@push.rocks/smartunique": "^3.0.9", "@serve.zone/interfaces": "^5.3.0", + "@serve.zone/remoteingress": "^3.0.1", "@tsclass/tsclass": "^9.3.0", "lru-cache": "^11.2.6", "uuid": "^13.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63f37b5..ac36780 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@serve.zone/interfaces': specifier: ^5.3.0 version: 5.3.0 + '@serve.zone/remoteingress': + specifier: ^3.0.1 + version: 3.0.1 '@tsclass/tsclass': specifier: ^9.3.0 version: 9.3.0 @@ -1337,6 +1340,9 @@ packages: '@serve.zone/interfaces@5.3.0': resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} + '@serve.zone/remoteingress@3.0.1': + resolution: {integrity: sha512-B2TEjW9GF80QA2MhWFKO9a8k0VYY2rkoY7pX5AoAcuqVJjvOP2Izq/HfL4dYkKEmDkhRUd89W0/WXkHUJLfp8Q==} + '@sindresorhus/is@5.6.0': resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} @@ -6847,6 +6853,11 @@ snapshots: '@push.rocks/smartlog-interfaces': 3.0.2 '@tsclass/tsclass': 9.3.0 + '@serve.zone/remoteingress@3.0.1': + dependencies: + '@push.rocks/qenv': 6.1.3 + '@push.rocks/smartrust': 1.2.1 + '@sindresorhus/is@5.6.0': {} '@smithy/abort-controller@4.2.8': diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 7a9b16a..7d6ff15 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.3.0', + version: '6.4.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 587ddc2..1090773 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -21,6 +21,7 @@ import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js'; import { OpsServer } from './opsserver/index.js'; import { MetricsManager } from './monitoring/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; +import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; export interface IDcRouterOptions { /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ @@ -155,6 +156,22 @@ export interface IDcRouterOptions { * Enables MAC Authentication Bypass (MAB) and VLAN assignment */ radiusConfig?: IRadiusServerConfig; + + /** + * Remote Ingress configuration for edge tunnel nodes + * Enables edge nodes to accept incoming connections and tunnel them to this DcRouter + */ + remoteIngressConfig?: { + /** Enable remote ingress hub (default: false) */ + enabled?: boolean; + /** Port for tunnel connections from edge nodes (default: 8443) */ + tunnelPort?: number; + /** TLS configuration for the tunnel server */ + tls?: { + certPath?: string; + keyPath?: string; + }; + }; } /** @@ -189,6 +206,10 @@ export class DcRouter { public cacheDb?: CacheDb; public cacheCleaner?: CacheCleaner; + // Remote Ingress + public remoteIngressManager?: RemoteIngressManager; + public tunnelManager?: TunnelManager; + // Certificate status tracking from SmartProxy events (keyed by domain) public certificateStatusMap = new Map console.error('Error stopping RADIUS server:', err)) : + Promise.resolve(), + + // Stop Remote Ingress tunnel manager if running + this.tunnelManager ? + this.tunnelManager.stop().catch(err => console.error('Error stopping TunnelManager:', err)) : Promise.resolve() ]); @@ -1532,6 +1573,31 @@ export class DcRouter { } } + /** + * Set up Remote Ingress hub for edge tunnel connections + */ + private async setupRemoteIngress(): Promise { + if (!this.options.remoteIngressConfig?.enabled) { + return; + } + + logger.log('info', 'Setting up Remote Ingress hub...'); + + // Initialize the edge registration manager + this.remoteIngressManager = new RemoteIngressManager(this.storageManager); + await this.remoteIngressManager.initialize(); + + // Create and start the tunnel manager + this.tunnelManager = new TunnelManager(this.remoteIngressManager, { + tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443, + targetHost: '127.0.0.1', + }); + await this.tunnelManager.start(); + + const edgeCount = this.remoteIngressManager.getAllEdges().length; + logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`); + } + /** * Set up RADIUS server for network authentication */ diff --git a/ts/index.ts b/ts/index.ts index 90514f2..6f47028 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -10,4 +10,7 @@ export * from './classes.dcrouter.js'; // RADIUS module export * from './radius/index.js'; +// Remote Ingress module +export * from './remoteingress/index.js'; + export const runCli = async () => {}; diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index e52adbf..3501c9c 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -19,6 +19,7 @@ export class OpsServer { private radiusHandler: handlers.RadiusHandler; private emailOpsHandler: handlers.EmailOpsHandler; private certificateHandler: handlers.CertificateHandler; + private remoteIngressHandler: handlers.RemoteIngressHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -59,6 +60,7 @@ export class OpsServer { this.radiusHandler = new handlers.RadiusHandler(this); this.emailOpsHandler = new handlers.EmailOpsHandler(this); this.certificateHandler = new handlers.CertificateHandler(this); + this.remoteIngressHandler = new handlers.RemoteIngressHandler(this); console.log('āœ… OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index c483f4b..ab72bfe 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -5,4 +5,5 @@ export * from './security.handler.js'; export * from './stats.handler.js'; export * from './radius.handler.js'; export * from './email-ops.handler.js'; -export * from './certificate.handler.js'; \ No newline at end of file +export * from './certificate.handler.js'; +export * from './remoteingress.handler.js'; \ No newline at end of file diff --git a/ts/opsserver/handlers/remoteingress.handler.ts b/ts/opsserver/handlers/remoteingress.handler.ts new file mode 100644 index 0000000..afd8e18 --- /dev/null +++ b/ts/opsserver/handlers/remoteingress.handler.ts @@ -0,0 +1,163 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class RemoteIngressHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private registerHandlers(): void { + // Get all remote ingress edges + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getRemoteIngresses', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.remoteIngressManager; + if (!manager) { + return { edges: [] }; + } + // Return edges without secrets + const edges = manager.getAllEdges().map((e) => ({ + ...e, + secret: '********', // Never expose secrets via API + })); + return { edges }; + }, + ), + ); + + // Create a new remote ingress edge + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createRemoteIngress', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.remoteIngressManager; + const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager; + + if (!manager) { + return { + success: false, + edge: null as any, + }; + } + + const edge = await manager.createEdge( + dataArg.name, + dataArg.listenPorts, + dataArg.tags, + ); + + // Sync allowed edges with the hub + if (tunnelManager) { + await tunnelManager.syncAllowedEdges(); + } + + return { success: true, edge }; + }, + ), + ); + + // Delete a remote ingress edge + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteRemoteIngress', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.remoteIngressManager; + const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager; + + if (!manager) { + return { success: false, message: 'RemoteIngress not configured' }; + } + + const deleted = await manager.deleteEdge(dataArg.id); + if (deleted && tunnelManager) { + await tunnelManager.syncAllowedEdges(); + } + + return { + success: deleted, + message: deleted ? undefined : 'Edge not found', + }; + }, + ), + ); + + // Update a remote ingress edge + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateRemoteIngress', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.remoteIngressManager; + const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager; + + if (!manager) { + return { success: false, edge: null as any }; + } + + const edge = await manager.updateEdge(dataArg.id, { + name: dataArg.name, + listenPorts: dataArg.listenPorts, + enabled: dataArg.enabled, + tags: dataArg.tags, + }); + + if (!edge) { + return { success: false, edge: null as any }; + } + + // Sync allowed edges if enabled status changed + if (tunnelManager && dataArg.enabled !== undefined) { + await tunnelManager.syncAllowedEdges(); + } + + return { success: true, edge: { ...edge, secret: '********' } }; + }, + ), + ); + + // Regenerate secret for an edge + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'regenerateRemoteIngressSecret', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.remoteIngressManager; + const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager; + + if (!manager) { + return { success: false, secret: '' }; + } + + const secret = await manager.regenerateSecret(dataArg.id); + if (!secret) { + return { success: false, secret: '' }; + } + + // Sync allowed edges since secret changed + if (tunnelManager) { + await tunnelManager.syncAllowedEdges(); + } + + return { success: true, secret }; + }, + ), + ); + + // Get runtime status of all edges + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getRemoteIngressStatus', + async (dataArg, toolsArg) => { + const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager; + if (!tunnelManager) { + return { statuses: [] }; + } + return { statuses: tunnelManager.getEdgeStatuses() }; + }, + ), + ); + } +} diff --git a/ts/plugins.ts b/ts/plugins.ts index 69e991a..9948f97 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -23,9 +23,11 @@ export { // @serve.zone scope import * as servezoneInterfaces from '@serve.zone/interfaces'; +import * as remoteingress from '@serve.zone/remoteingress'; export { - servezoneInterfaces + servezoneInterfaces, + remoteingress, } // @api.global scope diff --git a/ts/remoteingress/classes.remoteingress-manager.ts b/ts/remoteingress/classes.remoteingress-manager.ts new file mode 100644 index 0000000..bc0dc13 --- /dev/null +++ b/ts/remoteingress/classes.remoteingress-manager.ts @@ -0,0 +1,160 @@ +import * as plugins from '../plugins.js'; +import type { StorageManager } from '../storage/classes.storagemanager.js'; +import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js'; + +const STORAGE_PREFIX = '/remote-ingress/'; + +/** + * Manages CRUD for remote ingress edge registrations. + * Persists edge configs via StorageManager and provides + * the allowed edges list for the Rust hub. + */ +export class RemoteIngressManager { + private storageManager: StorageManager; + private edges: Map = new Map(); + + constructor(storageManager: StorageManager) { + this.storageManager = storageManager; + } + + /** + * Load all edge registrations from storage into memory. + */ + public async initialize(): Promise { + const keys = await this.storageManager.list(STORAGE_PREFIX); + for (const key of keys) { + const edge = await this.storageManager.getJSON(key); + if (edge) { + this.edges.set(edge.id, edge); + } + } + } + + /** + * Create a new edge registration. + */ + public async createEdge( + name: string, + listenPorts: number[], + tags?: string[], + ): Promise { + const id = plugins.uuid.v4(); + const secret = plugins.crypto.randomBytes(32).toString('hex'); + const now = Date.now(); + + const edge: IRemoteIngress = { + id, + name, + secret, + listenPorts, + enabled: true, + tags: tags || [], + createdAt: now, + updatedAt: now, + }; + + await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge); + this.edges.set(id, edge); + return edge; + } + + /** + * Get an edge by ID. + */ + public getEdge(id: string): IRemoteIngress | undefined { + return this.edges.get(id); + } + + /** + * Get all edge registrations. + */ + public getAllEdges(): IRemoteIngress[] { + return Array.from(this.edges.values()); + } + + /** + * Update an edge registration. + */ + public async updateEdge( + id: string, + updates: { + name?: string; + listenPorts?: number[]; + enabled?: boolean; + tags?: string[]; + }, + ): Promise { + const edge = this.edges.get(id); + if (!edge) { + return null; + } + + if (updates.name !== undefined) edge.name = updates.name; + if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts; + if (updates.enabled !== undefined) edge.enabled = updates.enabled; + if (updates.tags !== undefined) edge.tags = updates.tags; + edge.updatedAt = Date.now(); + + await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge); + this.edges.set(id, edge); + return edge; + } + + /** + * Delete an edge registration. + */ + public async deleteEdge(id: string): Promise { + if (!this.edges.has(id)) { + return false; + } + await this.storageManager.delete(`${STORAGE_PREFIX}${id}`); + this.edges.delete(id); + return true; + } + + /** + * Regenerate the secret for an edge. + */ + public async regenerateSecret(id: string): Promise { + const edge = this.edges.get(id); + if (!edge) { + return null; + } + + edge.secret = plugins.crypto.randomBytes(32).toString('hex'); + edge.updatedAt = Date.now(); + + await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge); + this.edges.set(id, edge); + return edge.secret; + } + + /** + * Verify an edge's secret using constant-time comparison. + */ + public verifySecret(id: string, secret: string): boolean { + const edge = this.edges.get(id); + if (!edge) { + return false; + } + const expected = Buffer.from(edge.secret); + const provided = Buffer.from(secret); + if (expected.length !== provided.length) { + return false; + } + return plugins.crypto.timingSafeEqual(expected, provided); + } + + /** + * Get the list of allowed edges (enabled only) for the Rust hub. + */ + public getAllowedEdges(): Array<{ id: string; secret: string }> { + const result: Array<{ id: string; secret: string }> = []; + for (const edge of this.edges.values()) { + if (edge.enabled) { + result.push({ id: edge.id, secret: edge.secret }); + } + } + return result; + } +} diff --git a/ts/remoteingress/classes.tunnel-manager.ts b/ts/remoteingress/classes.tunnel-manager.ts new file mode 100644 index 0000000..1e13ced --- /dev/null +++ b/ts/remoteingress/classes.tunnel-manager.ts @@ -0,0 +1,126 @@ +import * as plugins from '../plugins.js'; +import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js'; +import type { RemoteIngressManager } from './classes.remoteingress-manager.js'; + +export interface ITunnelManagerConfig { + tunnelPort?: number; + targetHost?: string; +} + +/** + * Manages the RemoteIngressHub instance and tracks connected edge statuses. + */ +export class TunnelManager { + private hub: InstanceType; + private manager: RemoteIngressManager; + private config: ITunnelManagerConfig; + private edgeStatuses: Map = new Map(); + + constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) { + this.manager = manager; + this.config = config; + this.hub = new plugins.remoteingress.RemoteIngressHub(); + + // Listen for edge connect/disconnect events + this.hub.on('edgeConnected', (data: { edgeId: string }) => { + const existing = this.edgeStatuses.get(data.edgeId); + this.edgeStatuses.set(data.edgeId, { + edgeId: data.edgeId, + connected: true, + publicIp: existing?.publicIp ?? null, + activeTunnels: 0, + lastHeartbeat: Date.now(), + connectedAt: Date.now(), + }); + }); + + this.hub.on('edgeDisconnected', (data: { edgeId: string }) => { + const existing = this.edgeStatuses.get(data.edgeId); + if (existing) { + existing.connected = false; + existing.activeTunnels = 0; + } + }); + + this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => { + const existing = this.edgeStatuses.get(data.edgeId); + if (existing) { + existing.activeTunnels++; + existing.lastHeartbeat = Date.now(); + } + }); + + this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => { + const existing = this.edgeStatuses.get(data.edgeId); + if (existing && existing.activeTunnels > 0) { + existing.activeTunnels--; + } + }); + } + + /** + * Start the tunnel hub and load allowed edges. + */ + public async start(): Promise { + await this.hub.start({ + tunnelPort: this.config.tunnelPort ?? 8443, + targetHost: this.config.targetHost ?? '127.0.0.1', + }); + + // Send allowed edges to the hub + await this.syncAllowedEdges(); + } + + /** + * Stop the tunnel hub. + */ + public async stop(): Promise { + await this.hub.stop(); + this.edgeStatuses.clear(); + } + + /** + * Sync allowed edges from the manager to the hub. + * Call this after creating/deleting/updating edges. + */ + public async syncAllowedEdges(): Promise { + const edges = this.manager.getAllowedEdges(); + await this.hub.updateAllowedEdges(edges); + } + + /** + * Get runtime statuses for all known edges. + */ + public getEdgeStatuses(): IRemoteIngressStatus[] { + return Array.from(this.edgeStatuses.values()); + } + + /** + * Get status for a specific edge. + */ + public getEdgeStatus(edgeId: string): IRemoteIngressStatus | undefined { + return this.edgeStatuses.get(edgeId); + } + + /** + * Get the count of connected edges. + */ + public getConnectedCount(): number { + let count = 0; + for (const status of this.edgeStatuses.values()) { + if (status.connected) count++; + } + return count; + } + + /** + * Get the total number of active tunnels across all edges. + */ + public getTotalActiveTunnels(): number { + let total = 0; + for (const status of this.edgeStatuses.values()) { + total += status.activeTunnels; + } + return total; + } +} diff --git a/ts/remoteingress/index.ts b/ts/remoteingress/index.ts new file mode 100644 index 0000000..a229eec --- /dev/null +++ b/ts/remoteingress/index.ts @@ -0,0 +1,2 @@ +export * from './classes.remoteingress-manager.js'; +export * from './classes.tunnel-manager.js'; diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index df78ce3..200340e 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -1,2 +1,3 @@ export * from './auth.js'; -export * from './stats.js'; \ No newline at end of file +export * from './stats.js'; +export * from './remoteingress.js'; \ No newline at end of file diff --git a/ts_interfaces/data/remoteingress.ts b/ts_interfaces/data/remoteingress.ts new file mode 100644 index 0000000..3fc8b17 --- /dev/null +++ b/ts_interfaces/data/remoteingress.ts @@ -0,0 +1,25 @@ +/** + * A stored remote ingress edge registration. + */ +export interface IRemoteIngress { + id: string; + name: string; + secret: string; + listenPorts: number[]; + enabled: boolean; + tags?: string[]; + createdAt: number; + updatedAt: number; +} + +/** + * Runtime status of a remote ingress edge. + */ +export interface IRemoteIngressStatus { + edgeId: string; + connected: boolean; + publicIp: string | null; + activeTunnels: number; + lastHeartbeat: number | null; + connectedAt: number | null; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index 1e3539e..c5c4975 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -5,4 +5,5 @@ export * from './stats.js'; export * from './combined.stats.js'; export * from './radius.js'; export * from './email-ops.js'; -export * from './certificate.js'; \ No newline at end of file +export * from './certificate.js'; +export * from './remoteingress.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/remoteingress.ts b/ts_interfaces/requests/remoteingress.ts new file mode 100644 index 0000000..2c6677b --- /dev/null +++ b/ts_interfaces/requests/remoteingress.ts @@ -0,0 +1,117 @@ +import * as plugins from '../plugins.js'; +import * as authInterfaces from '../data/auth.js'; +import type { IRemoteIngress, IRemoteIngressStatus } from '../data/remoteingress.js'; + +// ============================================================================ +// Remote Ingress Edge Management +// ============================================================================ + +/** + * Create a new remote ingress edge registration. + */ +export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateRemoteIngress +> { + method: 'createRemoteIngress'; + request: { + identity?: authInterfaces.IIdentity; + name: string; + listenPorts: number[]; + tags?: string[]; + }; + response: { + success: boolean; + edge: IRemoteIngress; + }; +} + +/** + * Delete a remote ingress edge registration. + */ +export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteRemoteIngress +> { + method: 'deleteRemoteIngress'; + request: { + identity?: authInterfaces.IIdentity; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Update a remote ingress edge registration. + */ +export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateRemoteIngress +> { + method: 'updateRemoteIngress'; + request: { + identity?: authInterfaces.IIdentity; + id: string; + name?: string; + listenPorts?: number[]; + enabled?: boolean; + tags?: string[]; + }; + response: { + success: boolean; + edge: IRemoteIngress; + }; +} + +/** + * Regenerate the secret for a remote ingress edge. + */ +export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RegenerateRemoteIngressSecret +> { + method: 'regenerateRemoteIngressSecret'; + request: { + identity?: authInterfaces.IIdentity; + id: string; + }; + response: { + success: boolean; + secret: string; + }; +} + +/** + * Get all remote ingress edge registrations. + */ +export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetRemoteIngresses +> { + method: 'getRemoteIngresses'; + request: { + identity?: authInterfaces.IIdentity; + }; + response: { + edges: IRemoteIngress[]; + }; +} + +/** + * Get runtime status of all remote ingress edges. + */ +export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetRemoteIngressStatus +> { + method: 'getRemoteIngressStatus'; + request: { + identity?: authInterfaces.IIdentity; + }; + response: { + statuses: IRemoteIngressStatus[]; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 7a9b16a..7d6ff15 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.3.0', + version: '6.4.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 6f5b1f7..278ced2 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -116,7 +116,7 @@ export const configStatePart = await appState.getStatePart( // Determine initial view from URL path const getInitialView = (): string => { const path = typeof window !== 'undefined' ? window.location.pathname : '/'; - const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates']; + const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress']; const segments = path.split('/').filter(Boolean); const view = segments[0]; return validViews.includes(view) ? view : 'overview'; @@ -192,6 +192,34 @@ export const certificateStatePart = await appState.getStatePart( + 'remoteIngress', + { + edges: [], + statuses: [], + selectedEdgeId: null, + newEdgeSecret: null, + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft' +); + // Actions for state management interface IActionContext { identity: interfaces.data.IIdentity | null; @@ -378,6 +406,13 @@ export const setActiveViewAction = uiStatePart.createAction(async (state }, 100); } + // If switching to remoteingress view, ensure we fetch edge data + if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') { + setTimeout(() => { + remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); + }, 100); + } + return { ...currentState, activeView: viewName, @@ -745,6 +780,150 @@ export const reprovisionCertificateAction = certificateStatePart.createAction { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetRemoteIngresses + >('/typedrequest', 'getRemoteIngresses'); + + const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetRemoteIngressStatus + >('/typedrequest', 'getRemoteIngressStatus'); + + const [edgesResponse, statusResponse] = await Promise.all([ + edgesRequest.fire({ identity: context.identity }), + statusRequest.fire({ identity: context.identity }), + ]); + + return { + ...currentState, + edges: edgesResponse.edges, + statuses: statusResponse.statuses, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch remote ingress data', + }; + } +}); + +export const createRemoteIngressAction = remoteIngressStatePart.createAction<{ + name: string; + listenPorts: number[]; + tags?: string[]; +}>(async (statePartArg, dataArg) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateRemoteIngress + >('/typedrequest', 'createRemoteIngress'); + + const response = await request.fire({ + identity: context.identity, + name: dataArg.name, + listenPorts: dataArg.listenPorts, + tags: dataArg.tags, + }); + + if (response.success) { + // Refresh the list and store the new secret for display + await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); + return { + ...statePartArg.getState(), + newEdgeSecret: response.edge.secret, + }; + } + + return currentState; + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to create edge', + }; + } +}); + +export const deleteRemoteIngressAction = remoteIngressStatePart.createAction( + async (statePartArg, edgeId) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteRemoteIngress + >('/typedrequest', 'deleteRemoteIngress'); + + await request.fire({ + identity: context.identity, + id: edgeId, + }); + + await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to delete edge', + }; + } + } +); + +export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction( + async (statePartArg, edgeId) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_RegenerateRemoteIngressSecret + >('/typedrequest', 'regenerateRemoteIngressSecret'); + + const response = await request.fire({ + identity: context.identity, + id: edgeId, + }); + + if (response.success) { + return { + ...currentState, + newEdgeSecret: response.secret, + }; + } + + return currentState; + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to regenerate secret', + }; + } + } +); + +export const clearNewEdgeSecretAction = remoteIngressStatePart.createAction( + async (statePartArg) => { + return { + ...statePartArg.getState(), + newEdgeSecret: null, + }; + } +); + // Combined refresh action for efficient polling async function dispatchCombinedRefreshAction() { const context = getActionContext(); diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 15b93dc..adaf409 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -6,4 +6,5 @@ export * from './ops-view-logs.js'; export * from './ops-view-config.js'; export * from './ops-view-security.js'; export * from './ops-view-certificates.js'; +export * from './ops-view-remoteingress.js'; export * from './shared/index.js'; \ No newline at end of file diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index bcee590..9217310 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -20,6 +20,7 @@ import { OpsViewLogs } from './ops-view-logs.js'; import { OpsViewConfig } from './ops-view-config.js'; import { OpsViewSecurity } from './ops-view-security.js'; import { OpsViewCertificates } from './ops-view-certificates.js'; +import { OpsViewRemoteIngress } from './ops-view-remoteingress.js'; @customElement('ops-dashboard') export class OpsDashboard extends DeesElement { @@ -66,6 +67,10 @@ export class OpsDashboard extends DeesElement { name: 'Certificates', element: OpsViewCertificates, }, + { + name: 'RemoteIngress', + element: OpsViewRemoteIngress, + }, ]; /** diff --git a/ts_web/elements/ops-view-remoteingress.ts b/ts_web/elements/ops-view-remoteingress.ts new file mode 100644 index 0000000..f84e23a --- /dev/null +++ b/ts_web/elements/ops-view-remoteingress.ts @@ -0,0 +1,290 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as appstate from '../appstate.js'; +import * as interfaces from '../../dist_ts_interfaces/index.js'; +import { viewHostCss } from './shared/css.js'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-remoteingress': OpsViewRemoteIngress; + } +} + +@customElement('ops-view-remoteingress') +export class OpsViewRemoteIngress extends DeesElement { + @state() + accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState(); + + constructor() { + super(); + const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => { + this.riState = newState; + }); + this.rxSubscriptions.push(sub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.remoteIngressStatePart.dispatchAction(appstate.fetchRemoteIngressAction, null); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .remoteIngressContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .statusBadge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .statusBadge.connected { + background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; + color: ${cssManager.bdTheme('#166534', '#4ade80')}; + } + + .statusBadge.disconnected { + background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + } + + .statusBadge.disabled { + background: ${cssManager.bdTheme('#f3f4f6', '#374151')}; + color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; + } + + .secretDialog { + padding: 16px; + background: ${cssManager.bdTheme('#fffbeb', '#1c1917')}; + border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')}; + border-radius: 8px; + margin-bottom: 16px; + } + + .secretDialog code { + display: block; + padding: 8px 12px; + background: ${cssManager.bdTheme('#1f2937', '#111827')}; + color: #10b981; + border-radius: 4px; + font-family: monospace; + font-size: 13px; + word-break: break-all; + margin: 8px 0; + user-select: all; + } + + .secretDialog .warning { + font-size: 12px; + color: ${cssManager.bdTheme('#92400e', '#fbbf24')}; + margin-top: 8px; + } + + .portsDisplay { + display: flex; + gap: 4px; + flex-wrap: wrap; + } + + .portBadge { + display: inline-flex; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + background: ${cssManager.bdTheme('#eff6ff', '#172554')}; + color: ${cssManager.bdTheme('#1e40af', '#60a5fa')}; + } + `, + ]; + + render(): TemplateResult { + const totalEdges = this.riState.edges.length; + const connectedEdges = this.riState.statuses.filter(s => s.connected).length; + const disconnectedEdges = totalEdges - connectedEdges; + const activeTunnels = this.riState.statuses.reduce((sum, s) => sum + s.activeTunnels, 0); + + const statsTiles: IStatsTile[] = [ + { + id: 'totalEdges', + title: 'Total Edges', + type: 'number', + value: totalEdges, + icon: 'lucide:server', + description: 'Registered edge nodes', + color: '#3b82f6', + }, + { + id: 'connectedEdges', + title: 'Connected', + type: 'number', + value: connectedEdges, + icon: 'lucide:link', + description: 'Currently connected edges', + color: '#10b981', + }, + { + id: 'disconnectedEdges', + title: 'Disconnected', + type: 'number', + value: disconnectedEdges, + icon: 'lucide:unlink', + description: 'Offline edge nodes', + color: disconnectedEdges > 0 ? '#ef4444' : '#6b7280', + }, + { + id: 'activeTunnels', + title: 'Active Tunnels', + type: 'number', + value: activeTunnels, + icon: 'lucide:cable', + description: 'Active client connections', + color: '#8b5cf6', + }, + ]; + + return html` + Remote Ingress + + ${this.riState.newEdgeSecret ? html` +
+ Edge Secret (copy now - shown only once): + ${this.riState.newEdgeSecret} +
This secret will not be shown again. Save it securely.
+ appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeSecretAction, null)} + >Dismiss +
+ ` : ''} + +
+ + + ({ + name: edge.name, + status: this.getEdgeStatusHtml(edge), + publicIp: this.getEdgePublicIp(edge.id), + ports: this.getPortsHtml(edge.listenPorts), + tunnels: this.getEdgeTunnelCount(edge.id), + lastHeartbeat: this.getLastHeartbeat(edge.id), + })} + .dataActions=${[ + { + name: 'Regenerate Secret', + iconName: 'lucide:key', + action: async (edge: interfaces.data.IRemoteIngress) => { + await appstate.remoteIngressStatePart.dispatchAction( + appstate.regenerateRemoteIngressSecretAction, + edge.id, + ); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + action: async (edge: interfaces.data.IRemoteIngress) => { + await appstate.remoteIngressStatePart.dispatchAction( + appstate.deleteRemoteIngressAction, + edge.id, + ); + }, + }, + ]} + .createNewAction=${async () => { + const { DeesModal } = await import('@design.estate/dees-catalog'); + const result = await DeesModal.createAndShow({ + heading: 'Create Edge Node', + content: html` + + + + + + `, + menuOptions: [], + }); + if (result) { + const formData = result as any; + const ports = (formData.name ? formData.listenPorts : '443') + .split(',') + .map((p: string) => parseInt(p.trim(), 10)) + .filter((p: number) => !isNaN(p)); + const tags = formData.tags + ? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean) + : undefined; + await appstate.remoteIngressStatePart.dispatchAction( + appstate.createRemoteIngressAction, + { + name: formData.name, + listenPorts: ports, + tags, + }, + ); + } + }} + > +
+ `; + } + + private getEdgeStatus(edgeId: string): interfaces.data.IRemoteIngressStatus | undefined { + return this.riState.statuses.find(s => s.edgeId === edgeId); + } + + private getEdgeStatusHtml(edge: interfaces.data.IRemoteIngress): TemplateResult { + if (!edge.enabled) { + return html`Disabled`; + } + const status = this.getEdgeStatus(edge.id); + if (status?.connected) { + return html`Connected`; + } + return html`Disconnected`; + } + + private getEdgePublicIp(edgeId: string): string { + const status = this.getEdgeStatus(edgeId); + return status?.publicIp || '-'; + } + + private getPortsHtml(ports: number[]): TemplateResult { + return html`
${ports.map(p => html`${p}`)}
`; + } + + private getEdgeTunnelCount(edgeId: string): number { + const status = this.getEdgeStatus(edgeId); + return status?.activeTunnels || 0; + } + + private getLastHeartbeat(edgeId: string): string { + const status = this.getEdgeStatus(edgeId); + if (!status?.lastHeartbeat) return '-'; + const ago = Date.now() - status.lastHeartbeat; + if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`; + if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`; + return `${Math.floor(ago / 3600000)}h ago`; + } +} diff --git a/ts_web/router.ts b/ts_web/router.ts index ce595e1..d65d7fb 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -3,7 +3,7 @@ import * as appstate from './appstate.js'; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; -export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'] as const; +export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const; export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const; export type TValidView = typeof validViews[number];