feat(vpn): allow target profiles to grant non-vpnOnly routes by live client source IP

This commit is contained in:
2026-05-21 23:44:01 +00:00
parent 27d077feed
commit 8188b4712c
15 changed files with 667 additions and 15 deletions
+10 -2
View File
@@ -2421,6 +2421,7 @@ export class DcRouter {
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
this.vpnManager.getClientSourceIpMap(),
);
};
}
@@ -2458,11 +2459,16 @@ export class DcRouter {
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
});
},
onClientSourceIpsChanged: () => {
this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client source IP change: ${err?.message || err}`);
});
},
getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
},
getClientAllowedIPs: async (targetProfileIds: string[]) => {
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, sourceIp?: string) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
@@ -2471,7 +2477,9 @@ export class DcRouter {
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, allRoutes,
targetProfileIds,
allRoutes,
sourceIp,
);
// Add target IPs directly
+229 -5
View File
@@ -5,6 +5,8 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
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:
@@ -35,6 +37,7 @@ export class TargetProfileManager {
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
@@ -55,6 +58,7 @@ export class TargetProfileManager {
domains: data.domains,
targets: data.targets,
routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
@@ -88,6 +92,9 @@ export class TargetProfileManager {
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);
@@ -208,13 +215,15 @@ export class TargetProfileManager {
*
* 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.
* 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);
@@ -227,6 +236,7 @@ export class TargetProfileManager {
// 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);
@@ -246,6 +256,16 @@ export class TargetProfileManager {
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) {
@@ -265,6 +285,7 @@ export class TargetProfileManager {
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: Map<string, IRoute>,
clientSourceIp?: string,
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
@@ -292,13 +313,20 @@ export class TargetProfileManager {
// Route references: scan all routes
for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue;
if (this.routeMatchesProfile(
route.route as IDcRouterRouteConfig,
const dcRoute = route.route as IDcRouterRouteConfig;
const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId,
profile,
routeNameIndex,
)) {
for (const d of this.getRouteDomains(route.route as IDcRouterRouteConfig)) {
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& clientSourceIp
&& !dcRoute.vpnOnly
&& this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
}
}
@@ -422,6 +450,199 @@ export class TargetProfileManager {
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 [];
@@ -503,6 +724,7 @@ export class TargetProfileManager {
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
@@ -522,6 +744,7 @@ export class TargetProfileManager {
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 {
@@ -532,6 +755,7 @@ export class TargetProfileManager {
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;
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
@plugins.smartdata.svDb()
public routeRefs?: string[];
@plugins.smartdata.svDb()
public allowRoutesByClientSourceIp?: boolean;
@plugins.smartdata.svDb()
public createdAt!: number;
@@ -69,6 +69,7 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
createdBy: userId,
});
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -94,6 +95,7 @@ export class TargetProfileHandler {
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
});
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
+2
View File
@@ -102,6 +102,8 @@ export class VpnHandler {
bytesSent: c.bytesSent,
bytesReceived: c.bytesReceived,
transport: c.transportType,
remoteAddr: c.remoteAddr,
sourceIp: manager.getClientSourceIp(c.registeredClientId || c.clientId),
})),
};
},
+158 -3
View File
@@ -19,6 +19,10 @@ export interface IVpnManagerConfig {
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Called when a live VPN client's real source IP changes. */
onClientSourceIpsChanged?: () => void;
/** Poll interval for live VPN client real source IP updates. Default: 10 seconds. */
clientSourceIpPollIntervalMs?: number;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
/** Compute per-client AllowedIPs based on the client's target profile IDs.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise<string[]>;
/** Resolve per-client destination allow-list IPs from target profile IDs.
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
@@ -57,6 +61,9 @@ export class VpnManager {
private serverKeys?: VpnServerKeysDoc;
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
private clientSourceIps = new Map<string, string>();
private clientSourceIpPollTimer?: ReturnType<typeof setInterval>;
private clientSourceIpRefreshInFlight = false;
constructor(config: IVpnManagerConfig) {
this.config = config;
@@ -173,6 +180,9 @@ export class VpnManager {
}
}
await this.refreshClientSourceIps(false);
this.startClientSourceIpPolling();
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
@@ -180,6 +190,7 @@ export class VpnManager {
* Stop the VPN server.
*/
public async stop(): Promise<void> {
this.stopClientSourceIpPolling();
if (this.vpnServer) {
try {
await this.vpnServer.stopServer();
@@ -189,6 +200,11 @@ export class VpnManager {
await this.vpnServer.stop();
this.vpnServer = undefined;
}
const hadClientSourceIps = this.clientSourceIps.size > 0;
this.clientSourceIps.clear();
if (hadClientSourceIps) {
this.config.onClientSourceIpsChanged?.();
}
this.resolvedForwardingMode = undefined;
logger.log('info', 'VPN server stopped');
}
@@ -246,6 +262,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
doc.targetProfileIds || [],
doc.clientId,
);
// Persist client entry (including WG private key for export/QR)
@@ -287,6 +304,7 @@ export class VpnManager {
await this.vpnServer.removeClient(clientId);
const doc = this.clients.get(clientId);
this.clients.delete(clientId);
this.clientSourceIps.delete(clientId);
if (doc) {
await doc.delete();
}
@@ -328,6 +346,7 @@ export class VpnManager {
client.updatedAt = Date.now();
await this.persistClient(client);
}
this.clientSourceIps.delete(clientId);
this.config.onClientChanged?.();
}
@@ -380,6 +399,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig,
client?.targetProfileIds || [],
clientId,
);
// Update persisted entry with new keys (including private key for export/QR)
@@ -413,7 +433,11 @@ export class VpnManager {
);
}
config = await this.rewriteWireGuardAllowedIPs(config, persisted?.targetProfileIds || []);
config = await this.rewriteWireGuardAllowedIPs(
config,
persisted?.targetProfileIds || [],
clientId,
);
}
return config;
@@ -445,6 +469,107 @@ export class VpnManager {
return this.vpnServer.listClients();
}
public getClientSourceIp(clientId: string): string | undefined {
return this.clientSourceIps.get(clientId);
}
public getClientSourceIpMap(): Map<string, string> {
return new Map(this.clientSourceIps);
}
public async refreshClientSourceIps(notifyOnChange = true): Promise<boolean> {
if (!this.vpnServer || this.clientSourceIpRefreshInFlight) {
return false;
}
this.clientSourceIpRefreshInFlight = true;
try {
const connectedClients = await this.vpnServer.listClients();
const nextSourceIps = new Map<string, string>();
const wireguardClientIds = new Set<string>();
for (const connectedClient of connectedClients) {
const clientId = connectedClient.registeredClientId || connectedClient.clientId;
if (!clientId) continue;
if (connectedClient.transportType === 'wireguard') {
wireguardClientIds.add(clientId);
}
const sourceIp = VpnManager.normalizeRemoteAddress(connectedClient.remoteAddr);
if (sourceIp) {
nextSourceIps.set(clientId, sourceIp);
}
}
if (wireguardClientIds.size > 0 && typeof (this.vpnServer as any).listWgPeers === 'function') {
try {
const wgPeers = await this.vpnServer.listWgPeers();
const endpointByPublicKey = new Map<string, string>();
for (const peer of wgPeers) {
const endpointIp = VpnManager.normalizeRemoteAddress(peer.endpoint);
if (peer.publicKey && endpointIp) {
endpointByPublicKey.set(peer.publicKey, endpointIp);
}
}
for (const client of this.clients.values()) {
if (nextSourceIps.has(client.clientId)) continue;
if (!wireguardClientIds.has(client.clientId)) continue;
if (!client.wgPublicKey) continue;
const endpointIp = endpointByPublicKey.get(client.wgPublicKey);
if (endpointIp) {
nextSourceIps.set(client.clientId, endpointIp);
}
}
} catch (err) {
logger.log('warn', `VPN: Failed to refresh WireGuard peer endpoints: ${(err as Error).message}`);
}
}
if (this.sameSourceIpMap(this.clientSourceIps, nextSourceIps)) {
return false;
}
this.clientSourceIps = nextSourceIps;
if (notifyOnChange) {
this.config.onClientSourceIpsChanged?.();
}
return true;
} catch (err) {
logger.log('warn', `VPN: Failed to refresh client source IPs: ${(err as Error).message}`);
return false;
} finally {
this.clientSourceIpRefreshInFlight = false;
}
}
public static normalizeRemoteAddress(remoteAddress?: string): string | undefined {
const remoteAddressString = remoteAddress?.trim();
if (!remoteAddressString) return undefined;
if (remoteAddressString.startsWith('[')) {
const closingBracketIndex = remoteAddressString.indexOf(']');
if (closingBracketIndex > 0) {
const bracketedIp = remoteAddressString.slice(1, closingBracketIndex);
return plugins.net.isIP(bracketedIp) ? bracketedIp : undefined;
}
}
if (plugins.net.isIP(remoteAddressString)) {
return remoteAddressString;
}
const lastColonIndex = remoteAddressString.lastIndexOf(':');
if (lastColonIndex > -1 && remoteAddressString.indexOf(':') === lastColonIndex) {
const host = remoteAddressString.slice(0, lastColonIndex);
if (plugins.net.isIP(host)) {
return host;
}
}
return undefined;
}
/**
* Get telemetry for a specific client.
*/
@@ -533,10 +658,15 @@ export class VpnManager {
private async rewriteWireGuardAllowedIPs(
wireguardConfig: string,
targetProfileIds: string[],
clientId?: string,
): Promise<string> {
if (!this.config.getClientAllowedIPs) return wireguardConfig;
const allowedIPs = await this.config.getClientAllowedIPs(targetProfileIds);
const allowedIPs = await this.config.getClientAllowedIPs(
targetProfileIds,
clientId,
clientId ? this.getClientSourceIp(clientId) : undefined,
);
const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
@@ -587,6 +717,31 @@ export class VpnManager {
}
}
private startClientSourceIpPolling(): void {
this.stopClientSourceIpPolling();
const pollIntervalMs = Math.max(1000, this.config.clientSourceIpPollIntervalMs ?? 10_000);
this.clientSourceIpPollTimer = setInterval(() => {
void this.refreshClientSourceIps().catch((err) => {
logger.log('warn', `VPN: Client source IP polling failed: ${err?.message || err}`);
});
}, pollIntervalMs);
this.clientSourceIpPollTimer.unref?.();
}
private stopClientSourceIpPolling(): void {
if (!this.clientSourceIpPollTimer) return;
clearInterval(this.clientSourceIpPollTimer);
this.clientSourceIpPollTimer = undefined;
}
private sameSourceIpMap(left: Map<string, string>, right: Map<string, string>): boolean {
if (left.size !== right.size) return false;
for (const [clientId, sourceIp] of left) {
if (right.get(clientId) !== sourceIp) return false;
}
return true;
}
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
return this.resolvedForwardingMode
?? this.forwardingModeOverride