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:
10
changelog.md
10
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
|
||||
|
||||
|
||||
@@ -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
11
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
163
ts/opsserver/handlers/remoteingress.handler.ts
Normal file
163
ts/opsserver/handlers/remoteingress.handler.ts
Normal 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() };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
160
ts/remoteingress/classes.remoteingress-manager.ts
Normal file
160
ts/remoteingress/classes.remoteingress-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
ts/remoteingress/classes.tunnel-manager.ts
Normal file
126
ts/remoteingress/classes.tunnel-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
ts/remoteingress/index.ts
Normal file
2
ts/remoteingress/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes.remoteingress-manager.js';
|
||||
export * from './classes.tunnel-manager.js';
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
25
ts_interfaces/data/remoteingress.ts
Normal file
25
ts_interfaces/data/remoteingress.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
117
ts_interfaces/requests/remoteingress.ts
Normal file
117
ts_interfaces/requests/remoteingress.ts
Normal 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[];
|
||||
};
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
290
ts_web/elements/ops-view-remoteingress.ts
Normal file
290
ts_web/elements/ops-view-remoteingress.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user