2026-02-16 11:25:16 +00:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
|
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
2026-02-17 10:55:31 +00:00
|
|
|
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
2026-02-16 11:25:16 +00:00
|
|
|
|
|
|
|
|
const STORAGE_PREFIX = '/remote-ingress/';
|
|
|
|
|
|
2026-02-17 10:55:31 +00:00
|
|
|
/**
|
|
|
|
|
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
|
|
|
|
*/
|
|
|
|
|
function extractPorts(portRange: number | number[] | Array<{ from: number; to: number }>): number[] {
|
|
|
|
|
const ports = new Set<number>();
|
|
|
|
|
if (typeof portRange === 'number') {
|
|
|
|
|
ports.add(portRange);
|
|
|
|
|
} else if (Array.isArray(portRange)) {
|
|
|
|
|
for (const entry of portRange) {
|
|
|
|
|
if (typeof entry === 'number') {
|
|
|
|
|
ports.add(entry);
|
|
|
|
|
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
|
|
|
|
|
for (let p = entry.from; p <= entry.to; p++) {
|
|
|
|
|
ports.add(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [...ports].sort((a, b) => a - b);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 11:25:16 +00:00
|
|
|
/**
|
|
|
|
|
* 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();
|
2026-02-17 10:55:31 +00:00
|
|
|
private routes: IDcRouterRouteConfig[] = [];
|
2026-02-16 11:25:16 +00:00
|
|
|
|
|
|
|
|
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) {
|
2026-02-17 14:17:18 +00:00
|
|
|
// Migration: old edges without autoDerivePorts default to true
|
|
|
|
|
if ((edge as any).autoDerivePorts === undefined) {
|
|
|
|
|
edge.autoDerivePorts = true;
|
|
|
|
|
await this.storageManager.setJSON(key, edge);
|
|
|
|
|
}
|
2026-02-16 11:25:16 +00:00
|
|
|
this.edges.set(edge.id, edge);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 10:55:31 +00:00
|
|
|
/**
|
|
|
|
|
* Store the current route configs for port derivation.
|
|
|
|
|
*/
|
|
|
|
|
public setRoutes(routes: IDcRouterRouteConfig[]): void {
|
|
|
|
|
this.routes = routes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
|
|
|
|
|
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
|
|
|
|
|
* When edgeFilter is absent, the route applies to all edges.
|
|
|
|
|
*/
|
|
|
|
|
public derivePortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
|
|
|
|
|
const ports = new Set<number>();
|
|
|
|
|
|
|
|
|
|
for (const route of this.routes) {
|
|
|
|
|
if (!route.remoteIngress?.enabled) continue;
|
|
|
|
|
|
|
|
|
|
// Apply edge filter if present
|
|
|
|
|
const filter = route.remoteIngress.edgeFilter;
|
|
|
|
|
if (filter && filter.length > 0) {
|
|
|
|
|
const idMatch = filter.includes(edgeId);
|
|
|
|
|
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
|
|
|
|
|
if (!idMatch && !tagMatch) continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract ports from the route match
|
|
|
|
|
if (route.match?.ports) {
|
|
|
|
|
for (const p of extractPorts(route.match.ports)) {
|
|
|
|
|
ports.add(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...ports].sort((a, b) => a - b);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the effective listen ports for an edge.
|
2026-02-17 14:17:18 +00:00
|
|
|
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
|
2026-02-17 10:55:31 +00:00
|
|
|
*/
|
|
|
|
|
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
|
2026-02-17 14:17:18 +00:00
|
|
|
const manualPorts = edge.listenPorts || [];
|
|
|
|
|
const shouldDerive = edge.autoDerivePorts !== false;
|
|
|
|
|
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
|
|
|
|
|
const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags);
|
|
|
|
|
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get manual and derived port breakdown for an edge (used in API responses).
|
|
|
|
|
* Derived ports exclude any ports already present in the manual list.
|
|
|
|
|
*/
|
|
|
|
|
public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } {
|
|
|
|
|
const manual = edge.listenPorts || [];
|
|
|
|
|
const shouldDerive = edge.autoDerivePorts !== false;
|
|
|
|
|
if (!shouldDerive) return { manual, derived: [] };
|
|
|
|
|
const manualSet = new Set(manual);
|
|
|
|
|
const allDerived = this.derivePortsForEdge(edge.id, edge.tags);
|
|
|
|
|
const derived = allDerived.filter((p) => !manualSet.has(p));
|
|
|
|
|
return { manual, derived };
|
2026-02-17 10:55:31 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-16 11:25:16 +00:00
|
|
|
/**
|
|
|
|
|
* Create a new edge registration.
|
|
|
|
|
*/
|
|
|
|
|
public async createEdge(
|
|
|
|
|
name: string,
|
2026-02-17 10:55:31 +00:00
|
|
|
listenPorts: number[] = [],
|
2026-02-16 11:25:16 +00:00
|
|
|
tags?: string[],
|
2026-02-17 14:17:18 +00:00
|
|
|
autoDerivePorts: boolean = true,
|
2026-02-16 11:25:16 +00:00
|
|
|
): 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,
|
2026-02-17 14:17:18 +00:00
|
|
|
autoDerivePorts,
|
2026-02-16 11:25:16 +00:00
|
|
|
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[];
|
2026-02-17 14:17:18 +00:00
|
|
|
autoDerivePorts?: boolean;
|
2026-02-16 11:25:16 +00:00
|
|
|
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;
|
2026-02-17 14:17:18 +00:00
|
|
|
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
|
2026-02-16 11:25:16 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|