feat(vpn,target-profiles,migrations): add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips

This commit is contained in:
2026-04-07 21:02:37 +00:00
parent f29ed9757e
commit 7fa6d82e58
24 changed files with 1503 additions and 1563 deletions

View File

@@ -12,6 +12,9 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] };
/**
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
@@ -52,7 +55,7 @@ export class RouteConfigManager {
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => string[],
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
) {}
@@ -402,13 +405,13 @@ export class RouteConfigManager {
if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpnOnly) return route;
const vpnIps = vpnCallback(dcRoute, routeId);
const existingIps = route.security?.ipAllowList || [];
const vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingIps, ...vpnIps],
ipAllowList: [...existingEntries, ...vpnEntries],
},
};
};

View File

@@ -146,7 +146,7 @@ export class TargetProfileManager {
// =========================================================================
/**
* For a set of target profile IDs, collect all explicit target host IPs.
* 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.
*/
@@ -156,7 +156,7 @@ export class TargetProfileManager {
const profile = this.profiles.get(profileId);
if (!profile?.targets?.length) continue;
for (const t of profile.targets) {
ips.add(t.host);
ips.add(t.ip);
}
}
return [...ips];
@@ -168,32 +168,50 @@ export class TargetProfileManager {
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns their assigned IPs for injection into ipAllowList.
* 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.
*/
public getMatchingClientIps(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
): string[] {
const ips: string[] = [];
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
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);
});
// Collect scoped domains from all matching profiles for this client
let fullAccess = false;
const scopedDomains = new Set<string>();
if (matches) {
ips.push(client.assignedIp);
for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
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 (fullAccess) {
entries.push(client.assignedIp);
} else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
}
}
return ips;
return entries;
}
/**
@@ -223,7 +241,7 @@ export class TargetProfileManager {
// Direct target IP entries
if (profile.targets?.length) {
for (const t of profile.targets) {
targetIps.add(t.host);
targetIps.add(t.ip);
}
}
@@ -264,34 +282,67 @@ export class TargetProfileManager {
// =========================================================================
/**
* 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)
* Check if a route matches a profile (boolean convenience wrapper).
*/
private routeMatchesProfile(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
): boolean {
// 1. Route reference match
const routeDomains: string[] = (route.match as any)?.domains || [];
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
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[],
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
// 1. Route reference match → full access
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return true;
if (route.name && profile.routeRefs.includes(route.name)) return true;
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
}
// 2. Domain match (bidirectional: profile-specific + route-wildcard, or vice versa)
if (profile.domains?.length) {
const routeDomains: string[] = (route.match as any)?.domains || [];
// 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)) return true;
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)
// 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)) {
@@ -299,15 +350,15 @@ export class TargetProfileManager {
for (const routeTarget of routeTargets) {
const routeHost = routeTarget.host;
const routePort = routeTarget.port;
if (routeHost === profileTarget.host && routePort === profileTarget.port) {
return true;
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
return 'full';
}
}
}
}
}
return false;
return 'none';
}
/**