766 lines
26 KiB
TypeScript
766 lines
26 KiB
TypeScript
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 { IRoute } from '../../ts_interfaces/data/route-management.js';
|
|
|
|
type TIpAllowEntry = string | { ip: string; domains?: string[] };
|
|
|
|
/**
|
|
* 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>();
|
|
|
|
constructor(
|
|
private getAllRoutes?: () => Map<string, IRoute>,
|
|
) {}
|
|
|
|
// =========================================================================
|
|
// Lifecycle
|
|
// =========================================================================
|
|
|
|
public async initialize(): Promise<void> {
|
|
await this.loadProfiles();
|
|
}
|
|
|
|
// =========================================================================
|
|
// CRUD
|
|
// =========================================================================
|
|
|
|
public async createProfile(data: {
|
|
name: string;
|
|
description?: string;
|
|
domains?: string[];
|
|
targets?: ITargetProfileTarget[];
|
|
routeRefs?: string[];
|
|
allowRoutesByClientSourceIp?: boolean;
|
|
createdBy: string;
|
|
}): Promise<string> {
|
|
// Enforce unique profile names
|
|
for (const existing of this.profiles.values()) {
|
|
if (existing.name === data.name) {
|
|
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
|
|
}
|
|
}
|
|
|
|
const id = plugins.uuid.v4();
|
|
const now = Date.now();
|
|
|
|
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
|
const profile: ITargetProfile = {
|
|
id,
|
|
name: data.name,
|
|
description: data.description,
|
|
domains: data.domains,
|
|
targets: data.targets,
|
|
routeRefs,
|
|
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
|
|
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 && patch.name !== profile.name) {
|
|
for (const existing of this.profiles.values()) {
|
|
if (existing.id !== id && existing.name === patch.name) {
|
|
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = this.normalizeRouteRefs(patch.routeRefs);
|
|
if (patch.allowRoutesByClientSourceIp !== undefined) {
|
|
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
|
|
}
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Normalize stored route references to route IDs when they can be resolved
|
|
* uniquely against the current route registry.
|
|
*/
|
|
public async normalizeAllRouteRefs(): Promise<void> {
|
|
const allRoutes = this.getAllRoutes?.();
|
|
if (!allRoutes?.size) return;
|
|
|
|
for (const profile of this.profiles.values()) {
|
|
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
|
profile.routeRefs,
|
|
allRoutes,
|
|
'bestEffort',
|
|
);
|
|
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
|
|
|
profile.routeRefs = normalizedRouteRefs;
|
|
profile.updatedAt = Date.now();
|
|
await this.persistProfile(profile);
|
|
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.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 }));
|
|
}
|
|
|
|
// =========================================================================
|
|
// Direct target IPs (bypass SmartProxy)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* For a set of target profile IDs, collect all explicit target IPs.
|
|
* These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
|
|
* connect to them directly through the tunnel.
|
|
*/
|
|
public getDirectTargetIps(targetProfileIds: string[]): string[] {
|
|
const ips = new Set<string>();
|
|
for (const profileId of targetProfileIds) {
|
|
const profile = this.profiles.get(profileId);
|
|
if (!profile?.targets?.length) continue;
|
|
for (const t of profile.targets) {
|
|
ips.add(t.ip);
|
|
}
|
|
}
|
|
return [...ips];
|
|
}
|
|
|
|
// =========================================================================
|
|
// Core matching: route → client IPs
|
|
// =========================================================================
|
|
|
|
/**
|
|
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
|
|
* matches the route. Returns IP allow entries for injection into ipAllowList.
|
|
*
|
|
* Entries are domain-scoped when a profile matches via specific domains that are
|
|
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
|
|
* or when profile domains exactly equal the route's domains. Profiles can also opt
|
|
* into source-IP matching against non-vpnOnly route security.
|
|
*/
|
|
public getMatchingClientIps(
|
|
route: IDcRouterRouteConfig,
|
|
routeId: string | undefined,
|
|
clients: VpnClientDoc[],
|
|
allRoutes: Map<string, IRoute> = new Map(),
|
|
clientSourceIps: Map<string, string> = new Map(),
|
|
): Array<string | { ip: string; domains: string[] }> {
|
|
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
|
const routeDomains = this.getRouteDomains(route);
|
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
|
|
|
for (const client of clients) {
|
|
if (!client.enabled || !client.assignedIp) continue;
|
|
if (!client.targetProfileIds?.length) continue;
|
|
|
|
// Collect scoped domains from all matching profiles for this client
|
|
let fullAccess = false;
|
|
const scopedDomains = new Set<string>();
|
|
const clientSourceIp = clientSourceIps.get(client.clientId);
|
|
|
|
for (const profileId of client.targetProfileIds) {
|
|
const profile = this.profiles.get(profileId);
|
|
if (!profile) continue;
|
|
|
|
const matchResult = this.routeMatchesProfileDetailed(
|
|
route,
|
|
routeId,
|
|
profile,
|
|
routeDomains,
|
|
routeNameIndex,
|
|
);
|
|
if (matchResult === 'full') {
|
|
fullAccess = true;
|
|
break; // No need to check more profiles
|
|
}
|
|
if (matchResult !== 'none') {
|
|
for (const d of matchResult.domains) scopedDomains.add(d);
|
|
}
|
|
|
|
if (
|
|
!route.vpnOnly
|
|
&& profile.allowRoutesByClientSourceIp === true
|
|
&& clientSourceIp
|
|
&& this.routeAllowsSourceIp(route, clientSourceIp, routeDomains)
|
|
) {
|
|
fullAccess = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (fullAccess) {
|
|
entries.push(client.assignedIp);
|
|
} else if (scopedDomains.size > 0) {
|
|
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* 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: Map<string, IRoute>,
|
|
clientSourceIp?: string,
|
|
): { domains: string[]; targetIps: string[] } {
|
|
const domains = new Set<string>();
|
|
const targetIps = new Set<string>();
|
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
|
|
|
// 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.ip);
|
|
}
|
|
}
|
|
|
|
// Route references: scan all routes
|
|
for (const [routeId, route] of allRoutes) {
|
|
if (!route.enabled) continue;
|
|
const dcRoute = route.route as IDcRouterRouteConfig;
|
|
const routeDomains = this.getRouteDomains(dcRoute);
|
|
const profileMatchesRoute = this.routeMatchesProfile(
|
|
dcRoute,
|
|
routeId,
|
|
profile,
|
|
routeNameIndex,
|
|
);
|
|
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
|
|
&& clientSourceIp
|
|
&& !dcRoute.vpnOnly
|
|
&& this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
|
|
if (profileMatchesRoute || sourceIpMatchesRoute) {
|
|
for (const d of routeDomains) {
|
|
domains.add(d);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
domains: [...domains],
|
|
targetIps: [...targetIps],
|
|
};
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private: matching logic
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Check if a route matches a profile (boolean convenience wrapper).
|
|
*/
|
|
private routeMatchesProfile(
|
|
route: IDcRouterRouteConfig,
|
|
routeId: string | undefined,
|
|
profile: ITargetProfile,
|
|
routeNameIndex: Map<string, string[]>,
|
|
): boolean {
|
|
const routeDomains = this.getRouteDomains(route);
|
|
const result = this.routeMatchesProfileDetailed(
|
|
route,
|
|
routeId,
|
|
profile,
|
|
routeDomains,
|
|
routeNameIndex,
|
|
);
|
|
return result !== 'none';
|
|
}
|
|
|
|
/**
|
|
* Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
|
|
* or 'none' (no match).
|
|
*
|
|
* - routeRefs / target matches → 'full' (explicit reference = full access)
|
|
* - domain match where profile domains are a subset of route wildcard → 'scoped'
|
|
* - domain match where domains are identical or profile is a wildcard → 'full'
|
|
*/
|
|
private routeMatchesProfileDetailed(
|
|
route: IDcRouterRouteConfig,
|
|
routeId: string | undefined,
|
|
profile: ITargetProfile,
|
|
routeDomains: string[],
|
|
routeNameIndex: Map<string, string[]>,
|
|
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
|
// 1. Route reference match → full access
|
|
if (profile.routeRefs?.length) {
|
|
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
|
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
|
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
|
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
|
return 'full';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Domain match
|
|
if (profile.domains?.length && routeDomains.length) {
|
|
const matchedProfileDomains: string[] = [];
|
|
|
|
for (const profileDomain of profile.domains) {
|
|
for (const routeDomain of routeDomains) {
|
|
if (this.domainMatchesPattern(routeDomain, profileDomain) ||
|
|
this.domainMatchesPattern(profileDomain, routeDomain)) {
|
|
matchedProfileDomains.push(profileDomain);
|
|
break; // This profileDomain matched, move to the next
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchedProfileDomains.length > 0) {
|
|
// Check if profile domains cover the route entirely (same wildcards = full access)
|
|
const isFullCoverage = routeDomains.every((rd) =>
|
|
matchedProfileDomains.some((pd) =>
|
|
rd === pd || this.domainMatchesPattern(rd, pd),
|
|
),
|
|
);
|
|
if (isFullCoverage) return 'full';
|
|
|
|
// Profile domains are a subset → scoped access to those specific domains
|
|
return { type: 'scoped', domains: matchedProfileDomains };
|
|
}
|
|
}
|
|
|
|
// 3. Target match (host + port) → full access (precise by nature)
|
|
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.ip && routePort === profileTarget.port) {
|
|
return 'full';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'none';
|
|
}
|
|
|
|
/**
|
|
* 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 routeAllowsSourceIp(
|
|
route: IDcRouterRouteConfig,
|
|
sourceIp: string,
|
|
routeDomains: string[],
|
|
): boolean {
|
|
const security = (route as any).security;
|
|
const ipAllowList = this.normalizeIpEntries(security?.ipAllowList);
|
|
const ipBlockList = this.normalizeIpEntries(security?.ipBlockList);
|
|
|
|
if (this.ipEntriesMatchSource(ipBlockList, sourceIp, routeDomains)) {
|
|
return false;
|
|
}
|
|
|
|
if (!ipAllowList.length) {
|
|
return true;
|
|
}
|
|
|
|
return this.ipEntriesMatchSource(ipAllowList, sourceIp, routeDomains);
|
|
}
|
|
|
|
private normalizeIpEntries(entries: unknown): TIpAllowEntry[] {
|
|
if (!entries) return [];
|
|
if (Array.isArray(entries)) return entries as TIpAllowEntry[];
|
|
return [entries as TIpAllowEntry];
|
|
}
|
|
|
|
private ipEntriesMatchSource(
|
|
entries: TIpAllowEntry[],
|
|
sourceIp: string,
|
|
routeDomains: string[],
|
|
): boolean {
|
|
return entries.some((entry) => this.ipEntryMatchesSource(entry, sourceIp, routeDomains));
|
|
}
|
|
|
|
private ipEntryMatchesSource(
|
|
entry: TIpAllowEntry,
|
|
sourceIp: string,
|
|
routeDomains: string[],
|
|
): boolean {
|
|
const ipPattern = typeof entry === 'string' ? entry : entry.ip;
|
|
if (typeof ipPattern !== 'string') return false;
|
|
if (!this.ipPatternMatchesSource(ipPattern, sourceIp)) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof entry === 'string' || !entry.domains?.length) {
|
|
return true;
|
|
}
|
|
|
|
if (!routeDomains.length) {
|
|
return false;
|
|
}
|
|
|
|
return routeDomains.some((routeDomain) =>
|
|
entry.domains!.some((entryDomain) =>
|
|
this.domainMatchesPattern(routeDomain, entryDomain)
|
|
|| this.domainMatchesPattern(entryDomain, routeDomain),
|
|
),
|
|
);
|
|
}
|
|
|
|
private ipPatternMatchesSource(pattern: string, sourceIp: string): boolean {
|
|
const trimmedPattern = pattern.trim();
|
|
const trimmedSourceIp = sourceIp.trim();
|
|
if (!trimmedPattern || !trimmedSourceIp) return false;
|
|
if (trimmedPattern === '*') return true;
|
|
if (trimmedPattern === trimmedSourceIp) return true;
|
|
|
|
if (trimmedPattern.includes('/')) {
|
|
return this.ipMatchesCidr(trimmedSourceIp, trimmedPattern);
|
|
}
|
|
|
|
if (trimmedPattern.includes('-')) {
|
|
return this.ipMatchesRange(trimmedSourceIp, trimmedPattern);
|
|
}
|
|
|
|
if (trimmedPattern.includes('*')) {
|
|
return this.ipMatchesWildcard(trimmedSourceIp, trimmedPattern);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private ipMatchesCidr(sourceIp: string, cidr: string): boolean {
|
|
const [networkIp, prefixString] = cidr.split('/');
|
|
if (!networkIp || !prefixString) return false;
|
|
const source = this.ipToComparable(sourceIp);
|
|
const network = this.ipToComparable(networkIp);
|
|
const prefix = Number(prefixString);
|
|
if (!source || !network || source.version !== network.version) return false;
|
|
|
|
const bitCount = source.version === 4 ? 32 : 128;
|
|
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bitCount) return false;
|
|
if (prefix === 0) return true;
|
|
|
|
const shift = BigInt(bitCount - prefix);
|
|
return (source.value >> shift) === (network.value >> shift);
|
|
}
|
|
|
|
private ipMatchesRange(sourceIp: string, range: string): boolean {
|
|
const [startIp, endIp] = range.split('-').map((part) => part.trim());
|
|
if (!startIp || !endIp) return false;
|
|
const source = this.ipToComparable(sourceIp);
|
|
const start = this.ipToComparable(startIp);
|
|
const end = this.ipToComparable(endIp);
|
|
if (!source || !start || !end) return false;
|
|
if (source.version !== start.version || source.version !== end.version) return false;
|
|
return source.value >= start.value && source.value <= end.value;
|
|
}
|
|
|
|
private ipMatchesWildcard(sourceIp: string, pattern: string): boolean {
|
|
const sourceParts = sourceIp.split('.');
|
|
const patternParts = pattern.split('.');
|
|
if (sourceParts.length !== 4 || patternParts.length !== 4) return false;
|
|
|
|
return patternParts.every((patternPart, index) => {
|
|
if (patternPart === '*') return true;
|
|
return patternPart === sourceParts[index];
|
|
});
|
|
}
|
|
|
|
private ipToComparable(ip: string): { version: 4 | 6; value: bigint } | undefined {
|
|
const normalizedIp = this.normalizeIpLiteral(ip);
|
|
const ipVersion = plugins.net.isIP(normalizedIp);
|
|
if (ipVersion === 4) {
|
|
const parts = normalizedIp.split('.').map((part) => Number(part));
|
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
version: 4,
|
|
value: parts.reduce((value, part) => (value << 8n) + BigInt(part), 0n),
|
|
};
|
|
}
|
|
|
|
if (ipVersion === 6) {
|
|
const parts = this.expandIpv6(normalizedIp);
|
|
if (!parts) return undefined;
|
|
return {
|
|
version: 6,
|
|
value: parts.reduce((value, part) => (value << 16n) + BigInt(part), 0n),
|
|
};
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private normalizeIpLiteral(ip: string): string {
|
|
const trimmed = ip.trim().replace(/^\[|\]$/g, '');
|
|
const zoneIndex = trimmed.indexOf('%');
|
|
const withoutZone = zoneIndex === -1 ? trimmed : trimmed.slice(0, zoneIndex);
|
|
const ipv4MappedPrefix = '::ffff:';
|
|
if (withoutZone.toLowerCase().startsWith(ipv4MappedPrefix)) {
|
|
const mappedIpv4 = withoutZone.slice(ipv4MappedPrefix.length);
|
|
if (plugins.net.isIP(mappedIpv4) === 4) return mappedIpv4;
|
|
}
|
|
return withoutZone;
|
|
}
|
|
|
|
private expandIpv6(ip: string): number[] | undefined {
|
|
let normalizedIp = ip.toLowerCase();
|
|
if (normalizedIp.includes('.')) {
|
|
const lastColonIndex = normalizedIp.lastIndexOf(':');
|
|
const ipv4Part = normalizedIp.slice(lastColonIndex + 1);
|
|
const ipv4Comparable = this.ipToComparable(ipv4Part);
|
|
if (!ipv4Comparable || ipv4Comparable.version !== 4) return undefined;
|
|
const high = Number((ipv4Comparable.value >> 16n) & 0xffffn).toString(16);
|
|
const low = Number(ipv4Comparable.value & 0xffffn).toString(16);
|
|
normalizedIp = `${normalizedIp.slice(0, lastColonIndex)}:${high}:${low}`;
|
|
}
|
|
|
|
const doubleColonParts = normalizedIp.split('::');
|
|
if (doubleColonParts.length > 2) return undefined;
|
|
|
|
const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : [];
|
|
const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : [];
|
|
const missingCount = 8 - head.length - tail.length;
|
|
if (missingCount < 0 || (doubleColonParts.length === 1 && missingCount !== 0)) return undefined;
|
|
|
|
const parts = [
|
|
...head,
|
|
...Array(missingCount).fill('0'),
|
|
...tail,
|
|
];
|
|
if (parts.length !== 8) return undefined;
|
|
|
|
const numbers = parts.map((part) => Number.parseInt(part || '0', 16));
|
|
if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
|
|
return undefined;
|
|
}
|
|
return numbers;
|
|
}
|
|
|
|
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
|
|
const domains = (route.match as any)?.domains;
|
|
if (!domains) return [];
|
|
return Array.isArray(domains) ? domains : [domains];
|
|
}
|
|
|
|
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
|
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
|
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
|
}
|
|
|
|
private normalizeRouteRefsAgainstRoutes(
|
|
routeRefs: string[] | undefined,
|
|
allRoutes: Map<string, IRoute>,
|
|
mode: 'strict' | 'bestEffort',
|
|
): string[] | undefined {
|
|
if (!routeRefs?.length) return undefined;
|
|
if (!allRoutes.size) return [...new Set(routeRefs)];
|
|
|
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
|
const normalizedRefs = new Set<string>();
|
|
|
|
for (const routeRef of routeRefs) {
|
|
if (allRoutes.has(routeRef)) {
|
|
normalizedRefs.add(routeRef);
|
|
continue;
|
|
}
|
|
|
|
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
|
if (matchingRouteIds.length === 1) {
|
|
normalizedRefs.add(matchingRouteIds[0]);
|
|
continue;
|
|
}
|
|
|
|
if (mode === 'bestEffort') {
|
|
normalizedRefs.add(routeRef);
|
|
continue;
|
|
}
|
|
|
|
if (matchingRouteIds.length > 1) {
|
|
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
|
}
|
|
throw new Error(`Route reference '${routeRef}' not found`);
|
|
}
|
|
|
|
return [...normalizedRefs];
|
|
}
|
|
|
|
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
|
const routeNameIndex = new Map<string, string[]>();
|
|
for (const [routeId, route] of allRoutes) {
|
|
const routeName = route.route.name;
|
|
if (!routeName) continue;
|
|
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
|
matchingRouteIds.push(routeId);
|
|
routeNameIndex.set(routeName, matchingRouteIds);
|
|
}
|
|
return routeNameIndex;
|
|
}
|
|
|
|
private sameStringArray(left?: string[], right?: string[]): boolean {
|
|
if (!left?.length && !right?.length) return true;
|
|
if (!left || !right || left.length !== right.length) return false;
|
|
return left.every((value, index) => value === right[index]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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,
|
|
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
|
|
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.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
|
|
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.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
|
|
doc.createdAt = profile.createdAt;
|
|
doc.updatedAt = profile.updatedAt;
|
|
doc.createdBy = profile.createdBy;
|
|
await doc.save();
|
|
}
|
|
}
|
|
}
|