Files
dcrouter/ts/remoteingress/classes.remoteingress-manager.ts

324 lines
9.8 KiB
TypeScript

import * as plugins from '../plugins.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc } from '../db/index.js';
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
function extractPorts(portRange: number | Array<number | { 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);
}
/**
* Manages CRUD for remote ingress edge registrations.
* Persists edge configs via smartdata document classes and provides
* the allowed edges list for the Rust hub.
*/
export class RemoteIngressManager {
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
constructor() {
}
/**
* Load all edge registrations from the database into memory.
*/
public async initialize(): Promise<void> {
const docs = await RemoteIngressEdgeDoc.findAll();
for (const doc of docs) {
// Migration: old edges without autoDerivePorts default to true
if ((doc as any).autoDerivePorts === undefined) {
doc.autoDerivePorts = true;
await doc.save();
}
const edge: IRemoteIngress = {
id: doc.id,
name: doc.name,
secret: doc.secret,
listenPorts: doc.listenPorts,
listenPortsUdp: doc.listenPortsUdp,
enabled: doc.enabled,
autoDerivePorts: doc.autoDerivePorts,
tags: doc.tags,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
this.edges.set(edge.id, edge);
}
}
/**
* 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);
}
/**
* Derive UDP listen ports for an edge from routes with transport 'udp' or 'all'.
* These ports need UDP listeners on the edge (e.g. for QUIC/HTTP3).
*/
public deriveUdpPortsForEdge(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;
}
// Only include ports from routes that listen on UDP
const transport = route.match?.transport;
if (transport === 'udp' || transport === 'all') {
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.
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
*/
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
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 the effective UDP listen ports for an edge.
* Manual UDP ports are always included. Auto-derived UDP ports are added when autoDerivePorts is true.
*/
public getEffectiveListenPortsUdp(edge: IRemoteIngress): number[] {
const manualPorts = edge.listenPortsUdp || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
const derivedPorts = this.deriveUdpPortsForEdge(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 };
}
/**
* Create a new edge registration.
*/
public async createEdge(
name: string,
listenPorts: number[] = [],
tags?: string[],
autoDerivePorts: boolean = true,
): 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,
autoDerivePorts,
tags: tags || [],
createdAt: now,
updatedAt: now,
};
const doc = new RemoteIngressEdgeDoc();
Object.assign(doc, edge);
await doc.save();
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[];
autoDerivePorts?: boolean;
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.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
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;
}
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
await doc.delete();
}
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();
const doc = await RemoteIngressEdgeDoc.findById(id);
if (doc) {
Object.assign(doc, edge);
await doc.save();
}
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.
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
*/
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[] }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
result.push({
id: edge.id,
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
});
}
}
return result;
}
}