BREAKING CHANGE(vpn): replace tag-based VPN access control with source and target profiles

This commit is contained in:
2026-04-05 00:37:37 +00:00
parent 25365678e0
commit 1ddf83b28d
38 changed files with 1546 additions and 321 deletions

View File

@@ -1,8 +1,8 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
import type {
ISecurityProfile,
ISourceProfile,
INetworkTarget,
IRouteMetadata,
IStoredRoute,
@@ -12,7 +12,7 @@ import type {
const MAX_INHERITANCE_DEPTH = 5;
export class ReferenceResolver {
private profiles = new Map<string, ISecurityProfile>();
private profiles = new Map<string, ISourceProfile>();
private targets = new Map<string, INetworkTarget>();
// =========================================================================
@@ -38,7 +38,7 @@ export class ReferenceResolver {
const id = plugins.uuid.v4();
const now = Date.now();
const profile: ISecurityProfile = {
const profile: ISourceProfile = {
id,
name: data.name,
description: data.description,
@@ -51,17 +51,17 @@ export class ReferenceResolver {
this.profiles.set(id, profile);
await this.persistProfile(profile);
logger.log('info', `Created security profile '${profile.name}' (${id})`);
logger.log('info', `Created source profile '${profile.name}' (${id})`);
return id;
}
public async updateProfile(
id: string,
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>,
patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<{ affectedRouteIds: string[] }> {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Security profile '${id}' not found`);
throw new Error(`Source profile '${id}' not found`);
}
if (patch.name !== undefined) profile.name = patch.name;
@@ -71,7 +71,7 @@ export class ReferenceResolver {
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Updated security profile '${profile.name}' (${id})`);
logger.log('info', `Updated source profile '${profile.name}' (${id})`);
// Find routes referencing this profile
const affectedRouteIds = await this.findRoutesByProfileRef(id);
@@ -85,7 +85,7 @@ export class ReferenceResolver {
): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id);
if (!profile) {
return { success: false, message: `Security profile '${id}' not found` };
return { success: false, message: `Source profile '${id}' not found` };
}
// Check usage
@@ -101,7 +101,7 @@ export class ReferenceResolver {
}
// Delete from DB
const doc = await SecurityProfileDoc.findById(id);
const doc = await SourceProfileDoc.findById(id);
if (doc) await doc.delete();
this.profiles.delete(id);
@@ -110,24 +110,24 @@ export class ReferenceResolver {
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})`);
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
}
return { success: true };
}
public getProfile(id: string): ISecurityProfile | undefined {
public getProfile(id: string): ISourceProfile | undefined {
return this.profiles.get(id);
}
public getProfileByName(name: string): ISecurityProfile | undefined {
public getProfileByName(name: string): ISourceProfile | undefined {
for (const profile of this.profiles.values()) {
if (profile.name === name) return profile;
}
return undefined;
}
public listProfiles(): ISecurityProfile[] {
public listProfiles(): ISourceProfile[] {
return [...this.profiles.values()];
}
@@ -137,7 +137,7 @@ export class ReferenceResolver {
usage.set(profile.id, []);
}
for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.securityProfileRef;
const ref = stored.metadata?.sourceProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
@@ -151,7 +151,7 @@ export class ReferenceResolver {
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
if (stored.metadata?.sourceProfileRef === profileId) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
@@ -280,7 +280,7 @@ export class ReferenceResolver {
/**
* Resolve references for a single route.
* Materializes security profile and/or network target into the route's fields.
* Materializes source profile and/or network target into the route's fields.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
@@ -289,19 +289,19 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.securityProfileRef) {
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef);
if (resolvedMetadata.sourceProfileRef) {
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.securityProfileRef);
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
// Merge: profile provides base, route's inline values override
route = {
...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security),
};
resolvedMetadata.securityProfileName = profile?.name;
resolvedMetadata.sourceProfileName = profile?.name;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`);
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
}
}
@@ -335,7 +335,7 @@ export class ReferenceResolver {
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.securityProfileRef === profileId)
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.map((doc) => doc.id);
}
@@ -349,7 +349,7 @@ export class ReferenceResolver {
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) {
if (stored.metadata?.sourceProfileRef === profileId) {
ids.push(routeId);
}
}
@@ -367,10 +367,10 @@ export class ReferenceResolver {
}
// =========================================================================
// Private: security profile resolution with inheritance
// Private: source profile resolution with inheritance
// =========================================================================
private resolveSecurityProfile(
private resolveSourceProfile(
profileId: string,
visited: Set<string> = new Set(),
depth: number = 0,
@@ -396,7 +396,7 @@ export class ReferenceResolver {
// 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);
const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
if (parentSecurity) {
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
}
@@ -453,7 +453,7 @@ export class ReferenceResolver {
// =========================================================================
private async loadProfiles(): Promise<void> {
const docs = await SecurityProfileDoc.findAll();
const docs = await SourceProfileDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.profiles.set(doc.id, {
@@ -469,7 +469,7 @@ export class ReferenceResolver {
}
}
if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`);
logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
}
}
@@ -494,8 +494,8 @@ export class ReferenceResolver {
}
}
private async persistProfile(profile: ISecurityProfile): Promise<void> {
const existingDoc = await SecurityProfileDoc.findById(profile.id);
private async persistProfile(profile: ISourceProfile): Promise<void> {
const existingDoc = await SourceProfileDoc.findById(profile.id);
if (existingDoc) {
existingDoc.name = profile.name;
existingDoc.description = profile.description;
@@ -504,7 +504,7 @@ export class ReferenceResolver {
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
const doc = new SecurityProfileDoc();
const doc = new SourceProfileDoc();
doc.id = profile.id;
doc.name = profile.name;
doc.description = profile.description;
@@ -550,8 +550,8 @@ export class ReferenceResolver {
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
securityProfileRef: undefined,
securityProfileName: undefined,
sourceProfileRef: undefined,
sourceProfileName: undefined,
};
doc.updatedAt = Date.now();
await doc.save();

View File

@@ -21,7 +21,7 @@ export class RouteConfigManager {
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnAllowList?: (tags?: string[]) => string[],
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => string[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
) {}
@@ -363,22 +363,19 @@ export class RouteConfigManager {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList;
const vpnCallback = this.getVpnClientIpsForRoute;
// Helper: inject VPN security into a route if vpn.enabled is set
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
if (!vpnAllowList) return route;
// Helper: inject VPN security into a vpnOnly route
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpn?.enabled) return route;
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
const mandatory = dcRoute.vpn.mandatory === true; // defaults to false
if (!dcRoute.vpnOnly) return route;
const allowList = vpnCallback(dcRoute, routeId);
return {
...route,
security: {
...route.security,
ipAllowList: mandatory
? allowList
: [...(route.security?.ipAllowList || []), ...allowList],
ipAllowList: allowList,
},
};
};
@@ -400,7 +397,7 @@ export class RouteConfigManager {
if (http3Config?.enabled !== false) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(route));
enabledRoutes.push(injectVpn(route, stored.id));
}
}

View File

@@ -0,0 +1,348 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js';
/**
* Manages TargetProfiles (target-side: what can be accessed).
* TargetProfiles define what resources a VPN client can reach:
* domains, specific IP:port targets, and/or direct route references.
*/
export class TargetProfileManager {
private profiles = new Map<string, ITargetProfile>();
// =========================================================================
// Lifecycle
// =========================================================================
public async initialize(): Promise<void> {
await this.loadProfiles();
}
// =========================================================================
// CRUD
// =========================================================================
public async createProfile(data: {
name: string;
description?: string;
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
createdBy: string;
}): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const profile: ITargetProfile = {
id,
name: data.name,
description: data.description,
domains: data.domains,
targets: data.targets,
routeRefs: data.routeRefs,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
};
this.profiles.set(id, profile);
await this.persistProfile(profile);
logger.log('info', `Created target profile '${profile.name}' (${id})`);
return id;
}
public async updateProfile(
id: string,
patch: Partial<Omit<ITargetProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<void> {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Target profile '${id}' not found`);
}
if (patch.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description;
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Updated target profile '${profile.name}' (${id})`);
}
public async deleteProfile(
id: string,
force?: boolean,
): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id);
if (!profile) {
return { success: false, message: `Target profile '${id}' not found` };
}
// Check if any VPN clients reference this profile
const clients = await VpnClientDoc.findAll();
const referencingClients = clients.filter(
(c) => c.targetProfileIds?.includes(id),
);
if (referencingClients.length > 0 && !force) {
return {
success: false,
message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`,
};
}
// Delete from DB
const doc = await TargetProfileDoc.findById(id);
if (doc) await doc.delete();
this.profiles.delete(id);
if (referencingClients.length > 0) {
// Remove profile ref from clients
for (const client of referencingClients) {
client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id);
client.updatedAt = Date.now();
await client.save();
}
logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`);
} else {
logger.log('info', `Deleted target profile '${profile.name}' (${id})`);
}
return { success: true };
}
public getProfile(id: string): ITargetProfile | undefined {
return this.profiles.get(id);
}
public listProfiles(): ITargetProfile[] {
return [...this.profiles.values()];
}
/**
* Get which VPN clients reference a target profile.
*/
public async getProfileUsage(profileId: string): Promise<Array<{ clientId: string; description?: string }>> {
const clients = await VpnClientDoc.findAll();
return clients
.filter((c) => c.targetProfileIds?.includes(profileId))
.map((c) => ({ clientId: c.clientId, description: c.description }));
}
// =========================================================================
// Core matching: route → client IPs
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns their assigned IPs for injection into ipAllowList.
*/
public getMatchingClientIps(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
): string[] {
const ips: string[] = [];
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
if (!client.targetProfileIds?.length) continue;
// Check if any of the client's profiles match this route
const matches = client.targetProfileIds.some((profileId) => {
const profile = this.profiles.get(profileId);
if (!profile) return false;
return this.routeMatchesProfile(route, routeId, profile);
});
if (matches) {
ips.push(client.assignedIp);
}
}
return ips;
}
/**
* For a given client (by its targetProfileIds), compute the set of
* domains and target IPs it can access. Used for WireGuard AllowedIPs.
*/
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: IDcRouterRouteConfig[],
storedRoutes: Map<string, IStoredRoute>,
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
// Collect all access specifiers from assigned profiles
for (const profileId of targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
// Direct domain entries
if (profile.domains?.length) {
for (const d of profile.domains) {
domains.add(d);
}
}
// Direct target IP entries
if (profile.targets?.length) {
for (const t of profile.targets) {
targetIps.add(t.host);
}
}
// Route references: scan constructor routes
for (const route of allRoutes) {
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
const routeDomains = (route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
}
// Route references: scan stored routes
for (const [storedId, stored] of storedRoutes) {
if (!stored.enabled) continue;
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
const routeDomains = (stored.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
}
}
return {
domains: [...domains],
targetIps: [...targetIps],
};
}
// =========================================================================
// Private: matching logic
// =========================================================================
/**
* Check if a route matches a profile. A profile matches if ANY condition is true:
* 1. Profile's routeRefs contains the route's name or stored route id
* 2. Profile's domains overlaps with route.match.domains (wildcard matching)
* 3. Profile's targets overlaps with route.action.targets (host + port match)
*/
private routeMatchesProfile(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
): boolean {
// 1. Route reference match
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return true;
if (route.name && profile.routeRefs.includes(route.name)) return true;
}
// 2. Domain match
if (profile.domains?.length) {
const routeDomains: string[] = (route.match as any)?.domains || [];
for (const profileDomain of profile.domains) {
for (const routeDomain of routeDomains) {
if (this.domainMatchesPattern(routeDomain, profileDomain)) return true;
}
}
}
// 3. Target match (host + port)
if (profile.targets?.length) {
const routeTargets = (route.action as any)?.targets;
if (Array.isArray(routeTargets)) {
for (const profileTarget of profile.targets) {
for (const routeTarget of routeTargets) {
const routeHost = routeTarget.host;
const routePort = routeTarget.port;
if (routeHost === profileTarget.host && routePort === profileTarget.port) {
return true;
}
}
}
}
}
return false;
}
/**
* Check if a domain matches a pattern.
* - '*.example.com' matches 'sub.example.com', 'a.b.example.com'
* - 'example.com' matches only 'example.com'
*/
private domainMatchesPattern(domain: string, pattern: string): boolean {
if (pattern === domain) return true;
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1); // '.example.com'
return domain.endsWith(suffix) && domain.length > suffix.length;
}
return false;
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadProfiles(): Promise<void> {
const docs = await TargetProfileDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.profiles.set(doc.id, {
id: doc.id,
name: doc.name,
description: doc.description,
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
}
}
private async persistProfile(profile: ITargetProfile): Promise<void> {
const existingDoc = await TargetProfileDoc.findById(profile.id);
if (existingDoc) {
existingDoc.name = profile.name;
existingDoc.description = profile.description;
existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
const doc = new TargetProfileDoc();
doc.id = profile.id;
doc.name = profile.name;
doc.description = profile.description;
doc.domains = profile.domains;
doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
await doc.save();
}
}
}

View File

@@ -3,4 +3,5 @@ export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { DbSeeder } from './classes.db-seeder.js';
export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';