BREAKING CHANGE(vpn): replace tag-based VPN access control with source and target profiles
This commit is contained in:
348
ts/config/classes.target-profile-manager.ts
Normal file
348
ts/config/classes.target-profile-manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user