diff --git a/changelog.md b/changelog.md index c3e554a..b1a6f51 100644 --- a/changelog.md +++ b/changelog.md @@ -2,12 +2,17 @@ ## Pending +### Features - - - - - +- allow VPN target profiles to grant routes by live client source IP (vpn) + - Add an opt-in target profile flag that evaluates non-vpnOnly route source security against the VPN client's real connecting IP. + - Track live VPN client source IPs from smartvpn remote addresses and WireGuard peer endpoints, refreshing routes when they change. + - Expose the setting and current source IPs in the Ops UI with regression coverage for source-IP matching behavior. +- allow target profiles to grant non-vpnOnly routes by live client source IP (vpn) + - add an opt-in target profile flag to match route source security against a VPN client's real connecting IP + - track live client source IPs from VPN remote addresses and WireGuard peer endpoints and re-apply routes when they change + - expose source IP access settings and current client source IPs through the ops API and UI + - add regression tests for source-IP route matching, block-list handling, vpnOnly exclusions, and WireGuard endpoint refresh ## 2026-05-21 - 13.33.0 diff --git a/readme.md b/readme.md index 60664f6..b8445a1 100644 --- a/readme.md +++ b/readme.md @@ -196,6 +196,19 @@ const router = new DcRouter({ await router.start(); ``` +## VPN Target Profiles + +Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also reach non-`vpnOnly` routes that would have allowed the client's real connecting IP without the VPN. + +dcrouter evaluates the live source IP reported by the VPN transport, such as `remoteAddr` or the WireGuard peer endpoint. If the route source policy allows that real IP, dcrouter injects the client's assigned VPN IP into SmartProxy for that route. The source-IP grant is live-only and is removed or updated when the VPN client disconnects or changes peer endpoint. + +```typescript +const targetProfile = { + name: 'ops laptop source access', + allowRoutesByClientSourceIp: true, +}; +``` + ## Automation API The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client. diff --git a/test/test.vpn-runtime.node.ts b/test/test.vpn-runtime.node.ts index e8515d5..5359c4f 100644 --- a/test/test.vpn-runtime.node.ts +++ b/test/test.vpn-runtime.node.ts @@ -227,6 +227,223 @@ tap.test('TargetProfileManager expands wildcard profile domains to matching conc expect(accessSpec.domains).toContain('app.hagen.team'); }); +tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'source-ip access', + allowRoutesByClientSourceIp: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const entries = manager.getMatchingClientIps( + { + name: 'restricted-public-route', + match: { domains: 'app.example.com', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + security: { ipAllowList: ['203.0.113.10'] }, + } as any, + 'route-1', + [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, + new Map(), + new Map([['client-1', '203.0.113.10']]), + ); + + expect(entries).toEqual(['10.8.0.2']); +}); + +tap.test('TargetProfileManager does not allow non-matching client source IPs', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'source-ip access', + allowRoutesByClientSourceIp: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const entries = manager.getMatchingClientIps( + { + name: 'restricted-public-route', + match: { domains: 'app.example.com', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + security: { ipAllowList: ['203.0.113.10'] }, + } as any, + 'route-1', + [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, + new Map(), + new Map([['client-1', '198.51.100.10']]), + ); + + expect(entries).toEqual([]); +}); + +tap.test('TargetProfileManager source-IP matching respects route block lists', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'source-ip access', + allowRoutesByClientSourceIp: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const entries = manager.getMatchingClientIps( + { + name: 'blocked-route', + match: { domains: 'app.example.com', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + security: { + ipAllowList: ['203.0.113.0/24'], + ipBlockList: ['203.0.113.10'], + }, + } as any, + 'route-1', + [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, + new Map(), + new Map([['client-1', '203.0.113.10']]), + ); + + expect(entries).toEqual([]); +}); + +tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'source-ip access', + allowRoutesByClientSourceIp: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const entries = manager.getMatchingClientIps( + { + name: 'public-route', + match: { domains: 'public.example.com', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + } as any, + 'route-1', + [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, + new Map(), + new Map([['client-1', '203.0.113.10']]), + ); + + expect(entries).toEqual(['10.8.0.2']); +}); + +tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP matching alone', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'source-ip access', + allowRoutesByClientSourceIp: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const entries = manager.getMatchingClientIps( + { + name: 'vpn-only-route', + vpnOnly: true, + match: { domains: 'private.example.com', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + security: { ipAllowList: ['203.0.113.10'] }, + } as any, + 'route-1', + [{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any, + new Map(), + new Map([['client-1', '203.0.113.10']]), + ); + + expect(entries).toEqual([]); +}); + +tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => { + const manager = new TargetProfileManager(); + (manager as any).profiles.set('profile-1', { + id: 'profile-1', + name: 'source-ip access', + allowRoutesByClientSourceIp: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }); + + const routes = new Map([ + ['route-1', { + id: 'route-1', + enabled: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + origin: 'api', + route: { + name: 'source-reachable-app', + match: { domains: 'app.example.com', ports: [443] }, + action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] }, + security: { ipAllowList: ['203.0.113.0/24'] }, + }, + }], + ]) as any; + + const accessSpec = manager.getClientAccessSpec(['profile-1'], routes, '203.0.113.10'); + + expect(accessSpec.domains).toContain('app.example.com'); +}); + +tap.test('VpnManager normalizes real remote addresses', async () => { + expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10'); + expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1'); + expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1'); +}); + +tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => { + const manager = new VpnManager({}); + let sourceIpChangeCalls = 0; + (manager as any).config.onClientSourceIpsChanged = () => { + sourceIpChangeCalls++; + }; + (manager as any).clients = new Map([ + ['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }], + ]); + (manager as any).vpnServer = { + listClients: async () => ([ + { + clientId: 'runtime-client-1', + registeredClientId: 'client-1', + assignedIp: '10.8.0.2', + transportType: 'wireguard', + }, + ]), + listWgPeers: async () => ([ + { + publicKey: 'wg-public-key', + allowedIps: ['10.8.0.2/32'], + endpoint: '198.51.100.44:61234', + bytesSent: 0, + bytesReceived: 0, + packetsSent: 0, + packetsReceived: 0, + }, + ]), + }; + + const changed = await manager.refreshClientSourceIps(); + const changedAgain = await manager.refreshClientSourceIps(); + + expect(changed).toEqual(true); + expect(changedAgain).toEqual(false); + expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44'); + expect(sourceIpChangeCalls).toEqual(1); +}); + tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => { const manager = new VpnManager({ serverEndpoint: 'vpn.example.com', diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 91aef6b..6ffacf1 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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([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 diff --git a/ts/config/classes.target-profile-manager.ts b/ts/config/classes.target-profile-manager.ts index caed8be..0181858 100644 --- a/ts/config/classes.target-profile-manager.ts +++ b/ts/config/classes.target-profile-manager.ts @@ -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 { // 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 = new Map(), + clientSourceIps: Map = new Map(), ): Array { const entries: Array = []; 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(); + 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, + clientSourceIp?: string, ): { domains: string[]; targetIps: string[] } { const domains = new Set(); const targetIps = new Set(); @@ -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; diff --git a/ts/db/documents/classes.target-profile.doc.ts b/ts/db/documents/classes.target-profile.doc.ts index b8647ae..5f33ef8 100644 --- a/ts/db/documents/classes.target-profile.doc.ts +++ b/ts/db/documents/classes.target-profile.doc.ts @@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc; /** 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; + getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise; /** 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(); + private clientSourceIpPollTimer?: ReturnType; + 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 { + 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 { + return new Map(this.clientSourceIps); + } + + public async refreshClientSourceIps(notifyOnChange = true): Promise { + if (!this.vpnServer || this.clientSourceIpRefreshInFlight) { + return false; + } + + this.clientSourceIpRefreshInFlight = true; + try { + const connectedClients = await this.vpnServer.listClients(); + const nextSourceIps = new Map(); + const wireguardClientIds = new Set(); + + 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(); + 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 { 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, right: Map): 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 diff --git a/ts_interfaces/data/target-profile.ts b/ts_interfaces/data/target-profile.ts index 962524b..dfc05ee 100644 --- a/ts_interfaces/data/target-profile.ts +++ b/ts_interfaces/data/target-profile.ts @@ -23,6 +23,8 @@ export interface ITargetProfile { targets?: ITargetProfileTarget[]; /** Route references by stored route ID. Legacy route names are normalized when unique. */ routeRefs?: string[]; + /** Also allow routes whose source security would allow the VPN client's real connecting IP. */ + allowRoutesByClientSourceIp?: boolean; createdAt: number; updatedAt: number; createdBy: string; diff --git a/ts_interfaces/data/vpn.ts b/ts_interfaces/data/vpn.ts index d25605b..acc73c0 100644 --- a/ts_interfaces/data/vpn.ts +++ b/ts_interfaces/data/vpn.ts @@ -45,6 +45,10 @@ export interface IVpnConnectedClient { bytesSent: number; bytesReceived: number; transport: string; + /** Real client IP:port reported by the VPN transport, when available. */ + remoteAddr?: string; + /** Parsed real client IP reported by the VPN transport, when available. */ + sourceIp?: string; } /** diff --git a/ts_interfaces/requests/target-profiles.ts b/ts_interfaces/requests/target-profiles.ts index 81e1c31..1ab1ac5 100644 --- a/ts_interfaces/requests/target-profiles.ts +++ b/ts_interfaces/requests/target-profiles.ts @@ -57,6 +57,7 @@ export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces domains?: string[]; targets?: ITargetProfileTarget[]; routeRefs?: string[]; + allowRoutesByClientSourceIp?: boolean; }; response: { success: boolean; @@ -82,6 +83,7 @@ export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces domains?: string[]; targets?: ITargetProfileTarget[]; routeRefs?: string[]; + allowRoutesByClientSourceIp?: boolean; }; response: { success: boolean; diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index cb42ccb..1ad1ad1 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -1569,6 +1569,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{ domains?: string[]; targets?: Array<{ ip: string; port: number }>; routeRefs?: string[]; + allowRoutesByClientSourceIp?: boolean; }>(async (statePartArg, dataArg, actionContext): Promise => { const context = getActionContext(); try { @@ -1582,6 +1583,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{ domains: dataArg.domains, targets: dataArg.targets, routeRefs: dataArg.routeRefs, + allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp, }); if (!response.success) { return { @@ -1605,6 +1607,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{ domains?: string[]; targets?: Array<{ ip: string; port: number }>; routeRefs?: string[]; + allowRoutesByClientSourceIp?: boolean; }>(async (statePartArg, dataArg, actionContext): Promise => { const context = getActionContext(); try { @@ -1619,6 +1622,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{ domains: dataArg.domains, targets: dataArg.targets, routeRefs: dataArg.routeRefs, + allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp, }); if (!response.success) { return { diff --git a/ts_web/elements/network/ops-view-targetprofiles.ts b/ts_web/elements/network/ops-view-targetprofiles.ts index b2d3c71..fbf5f51 100644 --- a/ts_web/elements/network/ops-view-targetprofiles.ts +++ b/ts_web/elements/network/ops-view-targetprofiles.ts @@ -97,6 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement { 'Route Refs': profile.routeRefs?.length ? html`${profile.routeRefs.map(r => html`${this.formatRouteRef(r)}`)}` : '-', + 'Client Source IP Routes': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No', Created: new Date(profile.createdAt).toLocaleDateString(), })} .dataActions=${[ @@ -223,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement { + `, menuOptions: [ @@ -258,6 +260,7 @@ export class OpsViewTargetProfiles extends DeesElement { domains: domains.length > 0 ? domains : undefined, targets: targets.length > 0 ? targets : undefined, routeRefs: routeRefs.length > 0 ? routeRefs : undefined, + allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true, }); modalArg.destroy(); }, @@ -284,6 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement { + `, menuOptions: [ @@ -319,6 +323,7 @@ export class OpsViewTargetProfiles extends DeesElement { domains, targets, routeRefs, + allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true, }); modalArg.destroy(); }, @@ -389,6 +394,10 @@ export class OpsViewTargetProfiles extends DeesElement { : '-'} +
+
Client Source IP Routes
+
${profile.allowRoutesByClientSourceIp ? 'Enabled' : 'Disabled'}
+
Created
${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}
diff --git a/ts_web/elements/network/ops-view-vpn.ts b/ts_web/elements/network/ops-view-vpn.ts index db4b536..233d484 100644 --- a/ts_web/elements/network/ops-view-vpn.ts +++ b/ts_web/elements/network/ops-view-vpn.ts @@ -339,6 +339,7 @@ export class OpsViewVpn extends DeesElement { 'Status': statusHtml, 'Routing': routingHtml, 'VPN IP': client.assignedIp || '-', + 'Source IP': conn?.sourceIp || '-', 'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds), 'Description': client.description || '-', 'Created': new Date(client.createdAt).toLocaleDateString(), @@ -487,6 +488,7 @@ export class OpsViewVpn extends DeesElement { ${conn ? html`
Connected Since${new Date(conn.connectedSince).toLocaleString()}
Transport${conn.transport}
+
Source IP${conn.sourceIp || '-'}
` : ''}
Description${client.description || '-'}
Target Profiles${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}