feat(config): add reusable security profiles and network targets with route reference resolution

This commit is contained in:
2026-04-02 15:44:36 +00:00
parent 6344c2deae
commit 55699f6618
31 changed files with 2845 additions and 12 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '12.1.0',
version: '12.2.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -21,7 +21,7 @@ import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -137,6 +137,10 @@ export interface IDcRouterOptions {
dbName?: string;
/** Cache cleanup interval in hours (default: 1) */
cleanupIntervalHours?: number;
/** Seed default security profiles and network targets when DB is empty on first startup. */
seedOnEmpty?: boolean;
/** Custom seed data for profiles and targets (overrides built-in defaults). */
seedData?: import('./config/classes.db-seeder.js').ISeedData;
};
/**
@@ -269,6 +273,7 @@ export class DcRouter {
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
public referenceResolver?: ReferenceResolver;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null;
@@ -456,6 +461,10 @@ export class DcRouter {
.optional()
.dependsOn('SmartProxy', 'DcRouterDb')
.withStart(async () => {
// Initialize reference resolver first (profiles + targets)
this.referenceResolver = new ReferenceResolver();
await this.referenceResolver.initialize();
this.routeConfigManager = new RouteConfigManager(
() => this.getConstructorRoutes(),
() => this.smartProxy,
@@ -468,14 +477,23 @@ export class DcRouter {
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
}
: undefined,
this.referenceResolver,
);
this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize();
// Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver);
await seeder.seedIfEmpty(
this.options.dbConfig?.seedOnEmpty,
this.options.dbConfig?.seedData,
);
})
.withStop(async () => {
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.referenceResolver = undefined;
})
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
);

View File

@@ -0,0 +1,95 @@
import { logger } from '../logger.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
import type { IRouteSecurity } from '../../ts_interfaces/data/route-management.js';
export interface ISeedData {
profiles?: Array<{
name: string;
description?: string;
security: IRouteSecurity;
extendsProfiles?: string[];
}>;
targets?: Array<{
name: string;
description?: string;
host: string | string[];
port: number;
}>;
}
export class DbSeeder {
constructor(private referenceResolver: ReferenceResolver) {}
/**
* Check if DB is empty and seed if configured.
* Called once during ConfigManagers service startup, after initialize().
*/
public async seedIfEmpty(
seedOnEmpty?: boolean,
seedData?: ISeedData,
): Promise<void> {
if (!seedOnEmpty) return;
const existingProfiles = this.referenceResolver.listProfiles();
const existingTargets = this.referenceResolver.listTargets();
if (existingProfiles.length > 0 || existingTargets.length > 0) {
logger.log('info', 'DB already contains profiles/targets, skipping seed');
return;
}
logger.log('info', 'Seeding database with initial profiles and targets...');
const profilesToSeed: NonNullable<ISeedData['profiles']> = seedData?.profiles ?? DEFAULT_PROFILES;
const targetsToSeed: NonNullable<ISeedData['targets']> = seedData?.targets ?? DEFAULT_TARGETS;
for (const p of profilesToSeed) {
await this.referenceResolver.createProfile({
name: p.name,
description: p.description,
security: p.security,
extendsProfiles: p.extendsProfiles,
createdBy: 'system-seed',
});
}
for (const t of targetsToSeed) {
await this.referenceResolver.createTarget({
name: t.name,
description: t.description,
host: t.host,
port: t.port,
createdBy: 'system-seed',
});
}
logger.log('info', `Seeded ${profilesToSeed.length} profile(s) and ${targetsToSeed.length} target(s)`);
}
}
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
{
name: 'PUBLIC',
description: 'Allow all traffic — no IP restrictions',
security: {
ipAllowList: ['*'],
},
},
{
name: 'STANDARD',
description: 'Standard internal access with common private subnets',
security: {
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8', '127.0.0.1', '::1'],
maxConnections: 1000,
},
},
];
const DEFAULT_TARGETS: Array<NonNullable<ISeedData['targets']>[number]> = [
{
name: 'LOCALHOST',
description: 'Local machine on port 443',
host: '127.0.0.1',
port: 443,
},
];

View File

@@ -0,0 +1,576 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
import type {
ISecurityProfile,
INetworkTarget,
IRouteMetadata,
IStoredRoute,
IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
export class ReferenceResolver {
private profiles = new Map<string, ISecurityProfile>();
private targets = new Map<string, INetworkTarget>();
// =========================================================================
// Lifecycle
// =========================================================================
public async initialize(): Promise<void> {
await this.loadProfiles();
await this.loadTargets();
}
// =========================================================================
// Profile CRUD
// =========================================================================
public async createProfile(data: {
name: string;
description?: string;
security: IRouteSecurity;
extendsProfiles?: string[];
createdBy: string;
}): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const profile: ISecurityProfile = {
id,
name: data.name,
description: data.description,
security: data.security,
extendsProfiles: data.extendsProfiles,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
};
this.profiles.set(id, profile);
await this.persistProfile(profile);
logger.log('info', `Created security profile '${profile.name}' (${id})`);
return id;
}
public async updateProfile(
id: string,
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<{ affectedRouteIds: string[] }> {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Security profile '${id}' not found`);
}
if (patch.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description;
if (patch.security !== undefined) profile.security = patch.security;
if (patch.extendsProfiles !== undefined) profile.extendsProfiles = patch.extendsProfiles;
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
// Find routes referencing this profile
const affectedRouteIds = await this.findRoutesByProfileRef(id);
return { affectedRouteIds };
}
public async deleteProfile(
id: string,
force: boolean,
storedRoutes?: Map<string, IStoredRoute>,
): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id);
if (!profile) {
return { success: false, message: `Security profile '${id}' not found` };
}
// Check usage
const affectedIds = storedRoutes
? this.findRoutesByProfileRefSync(id, storedRoutes)
: await this.findRoutesByProfileRef(id);
if (affectedIds.length > 0 && !force) {
return {
success: false,
message: `Profile '${profile.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
};
}
// Delete from DB
const doc = await SecurityProfileDoc.findById(id);
if (doc) await doc.delete();
this.profiles.delete(id);
// If force-deleting with referencing routes, clear refs but keep resolved values
if (affectedIds.length > 0) {
await this.clearProfileRefsOnRoutes(affectedIds);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted security profile '${profile.name}' (${id})`);
}
return { success: true };
}
public getProfile(id: string): ISecurityProfile | undefined {
return this.profiles.get(id);
}
public getProfileByName(name: string): ISecurityProfile | undefined {
for (const profile of this.profiles.values()) {
if (profile.name === name) return profile;
}
return undefined;
}
public listProfiles(): ISecurityProfile[] {
return [...this.profiles.values()];
}
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) {
usage.set(profile.id, []);
}
for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.securityProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
return usage;
}
public getProfileUsageForId(
profileId: string,
storedRoutes: Map<string, IStoredRoute>,
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
return routes;
}
// =========================================================================
// Target CRUD
// =========================================================================
public async createTarget(data: {
name: string;
description?: string;
host: string | string[];
port: number;
createdBy: string;
}): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const target: INetworkTarget = {
id,
name: data.name,
description: data.description,
host: data.host,
port: data.port,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
};
this.targets.set(id, target);
await this.persistTarget(target);
logger.log('info', `Created network target '${target.name}' (${id})`);
return id;
}
public async updateTarget(
id: string,
patch: Partial<Omit<INetworkTarget, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<{ affectedRouteIds: string[] }> {
const target = this.targets.get(id);
if (!target) {
throw new Error(`Network target '${id}' not found`);
}
if (patch.name !== undefined) target.name = patch.name;
if (patch.description !== undefined) target.description = patch.description;
if (patch.host !== undefined) target.host = patch.host;
if (patch.port !== undefined) target.port = patch.port;
target.updatedAt = Date.now();
await this.persistTarget(target);
logger.log('info', `Updated network target '${target.name}' (${id})`);
const affectedRouteIds = await this.findRoutesByTargetRef(id);
return { affectedRouteIds };
}
public async deleteTarget(
id: string,
force: boolean,
storedRoutes?: Map<string, IStoredRoute>,
): Promise<{ success: boolean; message?: string }> {
const target = this.targets.get(id);
if (!target) {
return { success: false, message: `Network target '${id}' not found` };
}
const affectedIds = storedRoutes
? this.findRoutesByTargetRefSync(id, storedRoutes)
: await this.findRoutesByTargetRef(id);
if (affectedIds.length > 0 && !force) {
return {
success: false,
message: `Target '${target.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
};
}
const doc = await NetworkTargetDoc.findById(id);
if (doc) await doc.delete();
this.targets.delete(id);
if (affectedIds.length > 0) {
await this.clearTargetRefsOnRoutes(affectedIds);
logger.log('warn', `Force-deleted target '${target.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted network target '${target.name}' (${id})`);
}
return { success: true };
}
public getTarget(id: string): INetworkTarget | undefined {
return this.targets.get(id);
}
public getTargetByName(name: string): INetworkTarget | undefined {
for (const target of this.targets.values()) {
if (target.name === name) return target;
}
return undefined;
}
public listTargets(): INetworkTarget[] {
return [...this.targets.values()];
}
public getTargetUsageForId(
targetId: string,
storedRoutes: Map<string, IStoredRoute>,
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
return routes;
}
// =========================================================================
// Resolution
// =========================================================================
/**
* Resolve references for a single route.
* Materializes security profile and/or network target into the route's fields.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
route: plugins.smartproxy.IRouteConfig,
metadata?: IRouteMetadata,
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.securityProfileRef) {
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
// Merge: profile provides base, route's inline values override
route = {
...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security),
};
resolvedMetadata.securityProfileName = profile?.name;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
}
}
if (resolvedMetadata.networkTargetRef) {
const target = this.targets.get(resolvedMetadata.networkTargetRef);
if (target) {
route = {
...route,
action: {
...route.action,
targets: [{
host: target.host as string,
port: target.port,
}],
},
};
resolvedMetadata.networkTargetName = target.name;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Network target '${resolvedMetadata.networkTargetRef}' not found during resolution`);
}
}
return { route, metadata: resolvedMetadata };
}
// =========================================================================
// Reference lookup helpers
// =========================================================================
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
.map((doc) => doc.id);
}
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
.map((doc) => doc.id);
}
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
ids.push(routeId);
}
}
return ids;
}
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) {
ids.push(routeId);
}
}
return ids;
}
// =========================================================================
// Private: security profile resolution with inheritance
// =========================================================================
private resolveSecurityProfile(
profileId: string,
visited: Set<string> = new Set(),
depth: number = 0,
): IRouteSecurity | null {
if (depth > MAX_INHERITANCE_DEPTH) {
logger.log('warn', `Max inheritance depth (${MAX_INHERITANCE_DEPTH}) exceeded resolving profile '${profileId}'`);
return null;
}
if (visited.has(profileId)) {
logger.log('warn', `Circular inheritance detected for profile '${profileId}'`);
return null;
}
const profile = this.profiles.get(profileId);
if (!profile) return null;
visited.add(profileId);
// Start with an empty base
let baseSecurity: IRouteSecurity = {};
// Resolve parent profiles first (top-down, later overrides earlier)
if (profile.extendsProfiles?.length) {
for (const parentId of profile.extendsProfiles) {
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1);
if (parentSecurity) {
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
}
}
}
// Apply this profile's security on top
return this.mergeSecurityFields(baseSecurity, profile.security);
}
/**
* Merge two IRouteSecurity objects.
* `override` values take precedence over `base` values.
* For ipAllowList/ipBlockList: union arrays and deduplicate.
* For scalar/object fields: override wins if present.
*/
private mergeSecurityFields(
base: IRouteSecurity | undefined,
override: IRouteSecurity | undefined,
): IRouteSecurity {
if (!base && !override) return {};
if (!base) return { ...override };
if (!override) return { ...base };
const merged: IRouteSecurity = { ...base };
// IP lists: union
if (override.ipAllowList || base.ipAllowList) {
merged.ipAllowList = [...new Set([
...(base.ipAllowList || []),
...(override.ipAllowList || []),
])];
}
if (override.ipBlockList || base.ipBlockList) {
merged.ipBlockList = [...new Set([
...(base.ipBlockList || []),
...(override.ipBlockList || []),
])];
}
// Scalar/object fields: override wins
if (override.maxConnections !== undefined) merged.maxConnections = override.maxConnections;
if (override.rateLimit !== undefined) merged.rateLimit = override.rateLimit;
if (override.authentication !== undefined) merged.authentication = override.authentication;
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
return merged;
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadProfiles(): Promise<void> {
const docs = await SecurityProfileDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.profiles.set(doc.id, {
id: doc.id,
name: doc.name,
description: doc.description,
security: doc.security,
extendsProfiles: doc.extendsProfiles,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
}
}
private async loadTargets(): Promise<void> {
const docs = await NetworkTargetDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.targets.set(doc.id, {
id: doc.id,
name: doc.name,
description: doc.description,
host: doc.host,
port: doc.port,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.targets.size > 0) {
logger.log('info', `Loaded ${this.targets.size} network target(s) from storage`);
}
}
private async persistProfile(profile: ISecurityProfile): Promise<void> {
const existingDoc = await SecurityProfileDoc.findById(profile.id);
if (existingDoc) {
existingDoc.name = profile.name;
existingDoc.description = profile.description;
existingDoc.security = profile.security;
existingDoc.extendsProfiles = profile.extendsProfiles;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
const doc = new SecurityProfileDoc();
doc.id = profile.id;
doc.name = profile.name;
doc.description = profile.description;
doc.security = profile.security;
doc.extendsProfiles = profile.extendsProfiles;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
await doc.save();
}
}
private async persistTarget(target: INetworkTarget): Promise<void> {
const existingDoc = await NetworkTargetDoc.findById(target.id);
if (existingDoc) {
existingDoc.name = target.name;
existingDoc.description = target.description;
existingDoc.host = target.host;
existingDoc.port = target.port;
existingDoc.updatedAt = target.updatedAt;
await existingDoc.save();
} else {
const doc = new NetworkTargetDoc();
doc.id = target.id;
doc.name = target.name;
doc.description = target.description;
doc.host = target.host;
doc.port = target.port;
doc.createdAt = target.createdAt;
doc.updatedAt = target.updatedAt;
doc.createdBy = target.createdBy;
await doc.save();
}
}
// =========================================================================
// Private: ref cleanup on force-delete
// =========================================================================
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
securityProfileRef: undefined,
securityProfileName: undefined,
};
doc.updatedAt = Date.now();
await doc.save();
}
}
}
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
networkTargetRef: undefined,
networkTargetName: undefined,
};
doc.updatedAt = Date.now();
await doc.save();
}
}
}
}

View File

@@ -6,9 +6,11 @@ import type {
IRouteOverride,
IMergedRoute,
IRouteWarning,
IRouteMetadata,
} from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>();
@@ -20,8 +22,14 @@ export class RouteConfigManager {
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
private referenceResolver?: ReferenceResolver,
) {}
/** Expose stored routes map for reference resolution lookups. */
public getStoredRoutes(): Map<string, IStoredRoute> {
return this.storedRoutes;
}
/**
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
*/
@@ -62,6 +70,7 @@ export class RouteConfigManager {
storedRouteId: stored.id,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
metadata: stored.metadata,
});
}
@@ -76,6 +85,7 @@ export class RouteConfigManager {
route: plugins.smartproxy.IRouteConfig,
createdBy: string,
enabled = true,
metadata?: IRouteMetadata,
): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
@@ -85,6 +95,14 @@ export class RouteConfigManager {
route.name = `programmatic-${id.slice(0, 8)}`;
}
// Resolve references if metadata has refs and resolver is available
let resolvedMetadata = metadata;
if (metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(route, metadata);
route = resolved.route;
resolvedMetadata = resolved.metadata;
}
const stored: IStoredRoute = {
id,
route,
@@ -92,6 +110,7 @@ export class RouteConfigManager {
createdAt: now,
updatedAt: now,
createdBy,
metadata: resolvedMetadata,
};
this.storedRoutes.set(id, stored);
@@ -102,7 +121,11 @@ export class RouteConfigManager {
public async updateRoute(
id: string,
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
patch: {
route?: Partial<plugins.smartproxy.IRouteConfig>;
enabled?: boolean;
metadata?: Partial<IRouteMetadata>;
},
): Promise<boolean> {
const stored = this.storedRoutes.get(id);
if (!stored) return false;
@@ -113,6 +136,17 @@ export class RouteConfigManager {
if (patch.enabled !== undefined) {
stored.enabled = patch.enabled;
}
if (patch.metadata !== undefined) {
stored.metadata = { ...stored.metadata, ...patch.metadata };
}
// Re-resolve if metadata refs exist and resolver is available
if (stored.metadata && this.referenceResolver) {
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
@@ -188,6 +222,7 @@ export class RouteConfigManager {
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
metadata: doc.metadata,
});
}
}
@@ -220,6 +255,7 @@ export class RouteConfigManager {
existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy;
existingDoc.metadata = stored.metadata;
await existingDoc.save();
} else {
const doc = new StoredRouteDoc();
@@ -229,6 +265,7 @@ export class RouteConfigManager {
doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy;
doc.metadata = stored.metadata;
await doc.save();
}
}
@@ -277,6 +314,32 @@ export class RouteConfigManager {
}
}
// =========================================================================
// Re-resolve routes after profile/target changes
// =========================================================================
/**
* Re-resolve specific routes by ID (after a profile or target is updated).
* Persists each route and calls applyRoutes() once at the end.
*/
public async reResolveRoutes(routeIds: string[]): Promise<void> {
if (!this.referenceResolver || routeIds.length === 0) return;
for (const routeId of routeIds) {
const stored = this.storedRoutes.get(routeId);
if (!stored?.metadata) continue;
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
stored.route = resolved.route;
stored.metadata = resolved.metadata;
stored.updatedAt = Date.now();
await this.persistRoute(stored);
}
await this.applyRoutes();
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
}
// =========================================================================
// Private: apply merged routes to SmartProxy
// =========================================================================

View File

@@ -1,4 +1,6 @@
// Export validation tools only
export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { DbSeeder } from './classes.db-seeder.js';

View File

@@ -0,0 +1,48 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public host!: string | string[];
@plugins.smartdata.svDb()
public port!: number;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<NetworkTargetDoc | null> {
return await NetworkTargetDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
return await NetworkTargetDoc.getInstance({ name });
}
public static async findAll(): Promise<NetworkTargetDoc[]> {
return await NetworkTargetDoc.getInstances({});
}
}

View File

@@ -0,0 +1,49 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public security!: IRouteSecurity;
@plugins.smartdata.svDb()
public extendsProfiles?: string[];
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<SecurityProfileDoc | null> {
return await SecurityProfileDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<SecurityProfileDoc | null> {
return await SecurityProfileDoc.getInstance({ name });
}
public static async findAll(): Promise<SecurityProfileDoc[]> {
return await SecurityProfileDoc.getInstances({});
}
}

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@@ -24,6 +25,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
@plugins.smartdata.svDb()
public createdBy!: string;
@plugins.smartdata.svDb()
public metadata?: IRouteMetadata;
constructor() {
super();
}

View File

@@ -6,6 +6,8 @@ export * from './classes.cached.ip.reputation.js';
export * from './classes.stored-route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.api-token.doc.js';
export * from './classes.security-profile.doc.js';
export * from './classes.network-target.doc.js';
// VPN document classes
export * from './classes.vpn-server-keys.doc.js';

View File

@@ -29,6 +29,8 @@ export class OpsServer {
private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler;
private securityProfileHandler!: handlers.SecurityProfileHandler;
private networkTargetHandler!: handlers.NetworkTargetHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -88,6 +90,8 @@ export class OpsServer {
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
this.vpnHandler = new handlers.VpnHandler(this);
this.securityProfileHandler = new handlers.SecurityProfileHandler(this);
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -9,4 +9,6 @@ export * from './certificate.handler.js';
export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';
export * from './vpn.handler.js';
export * from './vpn.handler.js';
export * from './security-profile.handler.js';
export * from './network-target.handler.js';

View File

@@ -0,0 +1,167 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class NetworkTargetHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get all network targets
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
'getNetworkTargets',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { targets: [] };
}
return { targets: resolver.listTargets() };
},
),
);
// Get a single network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTarget>(
'getNetworkTarget',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { target: null };
}
return { target: resolver.getTarget(dataArg.id) || null };
},
),
);
// Create a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateNetworkTarget>(
'createNetworkTarget',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'targets:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { success: false, message: 'Reference resolver not initialized' };
}
const id = await resolver.createTarget({
name: dataArg.name,
description: dataArg.description,
host: dataArg.host,
port: dataArg.port,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateNetworkTarget>(
'updateNetworkTarget',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const { affectedRouteIds } = await resolver.updateTarget(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
host: dataArg.host,
port: dataArg.port,
});
if (affectedRouteIds.length > 0) {
await manager.reResolveRoutes(affectedRouteIds);
}
return { success: true, affectedRouteCount: affectedRouteIds.length };
},
),
);
// Delete a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteNetworkTarget>(
'deleteNetworkTarget',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const result = await resolver.deleteTarget(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
);
if (result.success && dataArg.force) {
await manager.applyRoutes();
}
return result;
},
),
);
// Get routes using a network target
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargetUsage>(
'getNetworkTargetUsage',
async (dataArg) => {
await this.requireAuth(dataArg, 'targets:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { routes: [] };
}
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
},
),
);
}
}

View File

@@ -71,7 +71,7 @@ export class RouteManagementHandler {
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true);
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
return { success: true, storedRouteId: id };
},
),
@@ -90,6 +90,7 @@ export class RouteManagementHandler {
const ok = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any,
enabled: dataArg.enabled,
metadata: dataArg.metadata,
});
return { success: ok, message: ok ? undefined : 'Route not found' };
},

View File

@@ -0,0 +1,169 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class SecurityProfileHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get all security profiles
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>(
'getSecurityProfiles',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { profiles: [] };
}
return { profiles: resolver.listProfiles() };
},
),
);
// Get a single security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>(
'getSecurityProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { profile: null };
}
return { profile: resolver.getProfile(dataArg.id) || null };
},
),
);
// Create a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>(
'createSecurityProfile',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) {
return { success: false, message: 'Reference resolver not initialized' };
}
const id = await resolver.createProfile({
name: dataArg.name,
description: dataArg.description,
security: dataArg.security,
extendsProfiles: dataArg.extendsProfiles,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>(
'updateSecurityProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const { affectedRouteIds } = await resolver.updateProfile(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
security: dataArg.security,
extendsProfiles: dataArg.extendsProfiles,
});
// Propagate to affected routes
if (affectedRouteIds.length > 0) {
await manager.reResolveRoutes(affectedRouteIds);
}
return { success: true, affectedRouteCount: affectedRouteIds.length };
},
),
);
// Delete a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>(
'deleteSecurityProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { success: false, message: 'Not initialized' };
}
const result = await resolver.deleteProfile(
dataArg.id,
dataArg.force ?? false,
manager.getStoredRoutes(),
);
// If force-deleted with affected routes, re-apply
if (result.success && dataArg.force) {
await manager.applyRoutes();
}
return result;
},
),
);
// Get routes using a security profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>(
'getSecurityProfileUsage',
async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) {
return { routes: [] };
}
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
},
),
);
}
}