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

This commit is contained in:
2026-02-16 11:25:16 +00:00
parent fb472f353c
commit c889141ec3
23 changed files with 1174 additions and 8 deletions

View File

@@ -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

View File

@@ -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"

11
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -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.'
}

View File

@@ -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<string, {
status: 'valid' | 'failed';
@@ -266,6 +287,11 @@ export class DcRouter {
await this.setupRadiusServer();
}
// Set up Remote Ingress hub if configured
if (this.options.remoteIngressConfig?.enabled) {
await this.setupRemoteIngress();
}
this.logStartupSummary();
} catch (error) {
console.error('❌ Error starting DcRouter:', error);
@@ -352,6 +378,16 @@ export class DcRouter {
console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`);
}
// Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
console.log('\n🌐 Remote Ingress:');
console.log(` ├─ Tunnel Port: ${this.options.remoteIngressConfig.tunnelPort || 8443}`);
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
const connectedCount = this.tunnelManager.getConnectedCount();
console.log(` ├─ Registered Edges: ${edgeCount}`);
console.log(` └─ Connected Edges: ${connectedCount}`);
}
// Storage summary
if (this.storageManager && this.options.storage) {
console.log('\n💾 Storage:');
@@ -886,6 +922,11 @@ export class DcRouter {
// Stop RADIUS server if running
this.radiusServer ?
this.radiusServer.stop().catch(err => 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<void> {
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
*/

View File

@@ -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 () => {};

View File

@@ -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');
}

View File

@@ -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';
export * from './certificate.handler.js';
export * from './remoteingress.handler.js';

View File

@@ -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<interfaces.requests.IReq_GetRemoteIngresses>(
'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<interfaces.requests.IReq_CreateRemoteIngress>(
'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<interfaces.requests.IReq_DeleteRemoteIngress>(
'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<interfaces.requests.IReq_UpdateRemoteIngress>(
'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<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'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<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus',
async (dataArg, toolsArg) => {
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!tunnelManager) {
return { statuses: [] };
}
return { statuses: tunnelManager.getEdgeStatuses() };
},
),
);
}
}

View File

@@ -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

View File

@@ -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<string, IRemoteIngress> = new Map();
constructor(storageManager: StorageManager) {
this.storageManager = storageManager;
}
/**
* Load all edge registrations from storage into memory.
*/
public async initialize(): Promise<void> {
const keys = await this.storageManager.list(STORAGE_PREFIX);
for (const key of keys) {
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
if (edge) {
this.edges.set(edge.id, edge);
}
}
}
/**
* Create a new edge registration.
*/
public async createEdge(
name: string,
listenPorts: number[],
tags?: string[],
): Promise<IRemoteIngress> {
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<IRemoteIngress | null> {
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<boolean> {
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<string | null> {
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;
}
}

View File

@@ -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<typeof plugins.remoteingress.RemoteIngressHub>;
private manager: RemoteIngressManager;
private config: ITunnelManagerConfig;
private edgeStatuses: Map<string, IRemoteIngressStatus> = 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<void> {
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<void> {
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<void> {
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;
}
}

View File

@@ -0,0 +1,2 @@
export * from './classes.remoteingress-manager.js';
export * from './classes.tunnel-manager.js';

View File

@@ -1,2 +1,3 @@
export * from './auth.js';
export * from './stats.js';
export * from './stats.js';
export * from './remoteingress.js';

View File

@@ -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;
}

View File

@@ -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';
export * from './certificate.js';
export * from './remoteingress.js';

View File

@@ -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[];
};
}

View File

@@ -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.'
}

View File

@@ -116,7 +116,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
// 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<ICertificateStat
'soft'
);
// ============================================================================
// Remote Ingress State
// ============================================================================
export interface IRemoteIngressState {
edges: interfaces.data.IRemoteIngress[];
statuses: interfaces.data.IRemoteIngressStatus[];
selectedEdgeId: string | null;
newEdgeSecret: string | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngressState>(
'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<string>(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<st
}
);
// ============================================================================
// Remote Ingress Actions
// ============================================================================
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
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<string>(
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<string>(
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();

View File

@@ -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';

View File

@@ -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,
},
];
/**

View File

@@ -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`
<ops-sectionheading>Remote Ingress</ops-sectionheading>
${this.riState.newEdgeSecret ? html`
<div class="secretDialog">
<strong>Edge Secret (copy now - shown only once):</strong>
<code>${this.riState.newEdgeSecret}</code>
<div class="warning">This secret will not be shown again. Save it securely.</div>
<dees-button
@click=${() => appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeSecretAction, null)}
>Dismiss</dees-button>
</div>
` : ''}
<div class="remoteIngressContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Edge Nodes'}
.heading2=${'Manage remote ingress edge registrations'}
.data=${this.riState.edges}
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
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`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Listen Ports (comma-separated)'} .required=${true} .value=${'443,25'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
</dees-form>
`,
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,
},
);
}
}}
></dees-table>
</div>
`;
}
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`<span class="statusBadge disabled">Disabled</span>`;
}
const status = this.getEdgeStatus(edge.id);
if (status?.connected) {
return html`<span class="statusBadge connected">Connected</span>`;
}
return html`<span class="statusBadge disconnected">Disconnected</span>`;
}
private getEdgePublicIp(edgeId: string): string {
const status = this.getEdgeStatus(edgeId);
return status?.publicIp || '-';
}
private getPortsHtml(ports: number[]): TemplateResult {
return html`<div class="portsDisplay">${ports.map(p => html`<span class="portBadge">${p}</span>`)}</div>`;
}
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`;
}
}

View File

@@ -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];