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 -5
View File
@@ -2,12 +2,17 @@
## Pending ## 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 ## 2026-05-21 - 13.33.0
+13
View File
@@ -196,6 +196,19 @@ const router = new DcRouter({
await router.start(); 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 ## Automation API
The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client. The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client.
+217
View File
@@ -227,6 +227,223 @@ tap.test('TargetProfileManager expands wildcard profile domains to matching conc
expect(accessSpec.domains).toContain('app.hagen.team'); 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 () => { tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
const manager = new VpnManager({ const manager = new VpnManager({
serverEndpoint: 'vpn.example.com', serverEndpoint: 'vpn.example.com',
+10 -2
View File
@@ -2421,6 +2421,7 @@ export class DcRouter {
routeId, routeId,
this.vpnManager.listClients(), this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(), 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}`); 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[]) => { getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return []; if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds); 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 subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]); const ips = new Set<string>([subnet]);
@@ -2471,7 +2477,9 @@ export class DcRouter {
const allRoutes = this.routeConfigManager?.getRoutes() || new Map(); const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec( const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, allRoutes, targetProfileIds,
allRoutes,
sourceIp,
); );
// Add target IPs directly // 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 { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.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). * Manages TargetProfiles (target-side: what can be accessed).
* TargetProfiles define what resources a VPN client can reach: * TargetProfiles define what resources a VPN client can reach:
@@ -35,6 +37,7 @@ export class TargetProfileManager {
domains?: string[]; domains?: string[];
targets?: ITargetProfileTarget[]; targets?: ITargetProfileTarget[];
routeRefs?: string[]; routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
createdBy: string; createdBy: string;
}): Promise<string> { }): Promise<string> {
// Enforce unique profile names // Enforce unique profile names
@@ -55,6 +58,7 @@ export class TargetProfileManager {
domains: data.domains, domains: data.domains,
targets: data.targets, targets: data.targets,
routeRefs, routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy: data.createdBy, createdBy: data.createdBy,
@@ -88,6 +92,9 @@ export class TargetProfileManager {
if (patch.domains !== undefined) profile.domains = patch.domains; if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets; if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs); if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
if (patch.allowRoutesByClientSourceIp !== undefined) {
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
}
profile.updatedAt = Date.now(); profile.updatedAt = Date.now();
await this.persistProfile(profile); await this.persistProfile(profile);
@@ -208,13 +215,15 @@ export class TargetProfileManager {
* *
* Entries are domain-scoped when a profile matches via specific domains that are * 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 * 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( public getMatchingClientIps(
route: IDcRouterRouteConfig, route: IDcRouterRouteConfig,
routeId: string | undefined, routeId: string | undefined,
clients: VpnClientDoc[], clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(), allRoutes: Map<string, IRoute> = new Map(),
clientSourceIps: Map<string, string> = new Map(),
): Array<string | { ip: string; domains: string[] }> { ): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = []; const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains = this.getRouteDomains(route); const routeDomains = this.getRouteDomains(route);
@@ -227,6 +236,7 @@ export class TargetProfileManager {
// Collect scoped domains from all matching profiles for this client // Collect scoped domains from all matching profiles for this client
let fullAccess = false; let fullAccess = false;
const scopedDomains = new Set<string>(); const scopedDomains = new Set<string>();
const clientSourceIp = clientSourceIps.get(client.clientId);
for (const profileId of client.targetProfileIds) { for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId); const profile = this.profiles.get(profileId);
@@ -246,6 +256,16 @@ export class TargetProfileManager {
if (matchResult !== 'none') { if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d); 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) { if (fullAccess) {
@@ -265,6 +285,7 @@ export class TargetProfileManager {
public getClientAccessSpec( public getClientAccessSpec(
targetProfileIds: string[], targetProfileIds: string[],
allRoutes: Map<string, IRoute>, allRoutes: Map<string, IRoute>,
clientSourceIp?: string,
): { domains: string[]; targetIps: string[] } { ): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>(); const domains = new Set<string>();
const targetIps = new Set<string>(); const targetIps = new Set<string>();
@@ -292,13 +313,20 @@ export class TargetProfileManager {
// Route references: scan all routes // Route references: scan all routes
for (const [routeId, route] of allRoutes) { for (const [routeId, route] of allRoutes) {
if (!route.enabled) continue; if (!route.enabled) continue;
if (this.routeMatchesProfile( const dcRoute = route.route as IDcRouterRouteConfig;
route.route as IDcRouterRouteConfig, const routeDomains = this.getRouteDomains(dcRoute);
const profileMatchesRoute = this.routeMatchesProfile(
dcRoute,
routeId, routeId,
profile, profile,
routeNameIndex, 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); domains.add(d);
} }
} }
@@ -422,6 +450,199 @@ export class TargetProfileManager {
return false; 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[] { private getRouteDomains(route: IDcRouterRouteConfig): string[] {
const domains = (route.match as any)?.domains; const domains = (route.match as any)?.domains;
if (!domains) return []; if (!domains) return [];
@@ -503,6 +724,7 @@ export class TargetProfileManager {
domains: doc.domains, domains: doc.domains,
targets: doc.targets, targets: doc.targets,
routeRefs: doc.routeRefs, routeRefs: doc.routeRefs,
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
createdAt: doc.createdAt, createdAt: doc.createdAt,
updatedAt: doc.updatedAt, updatedAt: doc.updatedAt,
createdBy: doc.createdBy, createdBy: doc.createdBy,
@@ -522,6 +744,7 @@ export class TargetProfileManager {
existingDoc.domains = profile.domains; existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets; existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs; existingDoc.routeRefs = profile.routeRefs;
existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
existingDoc.updatedAt = profile.updatedAt; existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save(); await existingDoc.save();
} else { } else {
@@ -532,6 +755,7 @@ export class TargetProfileManager {
doc.domains = profile.domains; doc.domains = profile.domains;
doc.targets = profile.targets; doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs; doc.routeRefs = profile.routeRefs;
doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
doc.createdAt = profile.createdAt; doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt; doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy; doc.createdBy = profile.createdBy;
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public routeRefs?: string[]; public routeRefs?: string[];
@plugins.smartdata.svDb()
public allowRoutesByClientSourceIp?: boolean;
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public createdAt!: number; public createdAt!: number;
@@ -69,6 +69,7 @@ export class TargetProfileHandler {
domains: dataArg.domains, domains: dataArg.domains,
targets: dataArg.targets, targets: dataArg.targets,
routeRefs: dataArg.routeRefs, routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
createdBy: userId, createdBy: userId,
}); });
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
@@ -94,6 +95,7 @@ export class TargetProfileHandler {
domains: dataArg.domains, domains: dataArg.domains,
targets: dataArg.targets, targets: dataArg.targets,
routeRefs: dataArg.routeRefs, routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
}); });
// Re-apply routes and refresh VPN client security to update access // Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes(); await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
+2
View File
@@ -102,6 +102,8 @@ export class VpnHandler {
bytesSent: c.bytesSent, bytesSent: c.bytesSent,
bytesReceived: c.bytesReceived, bytesReceived: c.bytesReceived,
transport: c.transportType, 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 */ /** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void; 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 */ /** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: { destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow'; default: 'forceTarget' | 'block' | 'allow';
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
/** Compute per-client AllowedIPs based on the client's target profile IDs. /** Compute per-client AllowedIPs based on the client's target profile IDs.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs. * Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */ * 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. /** 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. */ * Returns IP strings that should bypass forceTarget and go direct to the real destination. */
getClientDirectTargets?: (targetProfileIds: string[]) => string[]; getClientDirectTargets?: (targetProfileIds: string[]) => string[];
@@ -57,6 +61,9 @@ export class VpnManager {
private serverKeys?: VpnServerKeysDoc; private serverKeys?: VpnServerKeysDoc;
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid'; private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
private forwardingModeOverride?: '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) { constructor(config: IVpnManagerConfig) {
this.config = config; 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}`); 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. * Stop the VPN server.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
this.stopClientSourceIpPolling();
if (this.vpnServer) { if (this.vpnServer) {
try { try {
await this.vpnServer.stopServer(); await this.vpnServer.stopServer();
@@ -189,6 +200,11 @@ export class VpnManager {
await this.vpnServer.stop(); await this.vpnServer.stop();
this.vpnServer = undefined; this.vpnServer = undefined;
} }
const hadClientSourceIps = this.clientSourceIps.size > 0;
this.clientSourceIps.clear();
if (hadClientSourceIps) {
this.config.onClientSourceIpsChanged?.();
}
this.resolvedForwardingMode = undefined; this.resolvedForwardingMode = undefined;
logger.log('info', 'VPN server stopped'); logger.log('info', 'VPN server stopped');
} }
@@ -246,6 +262,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs( bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig, bundle.wireguardConfig,
doc.targetProfileIds || [], doc.targetProfileIds || [],
doc.clientId,
); );
// Persist client entry (including WG private key for export/QR) // Persist client entry (including WG private key for export/QR)
@@ -287,6 +304,7 @@ export class VpnManager {
await this.vpnServer.removeClient(clientId); await this.vpnServer.removeClient(clientId);
const doc = this.clients.get(clientId); const doc = this.clients.get(clientId);
this.clients.delete(clientId); this.clients.delete(clientId);
this.clientSourceIps.delete(clientId);
if (doc) { if (doc) {
await doc.delete(); await doc.delete();
} }
@@ -328,6 +346,7 @@ export class VpnManager {
client.updatedAt = Date.now(); client.updatedAt = Date.now();
await this.persistClient(client); await this.persistClient(client);
} }
this.clientSourceIps.delete(clientId);
this.config.onClientChanged?.(); this.config.onClientChanged?.();
} }
@@ -380,6 +399,7 @@ export class VpnManager {
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs( bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
bundle.wireguardConfig, bundle.wireguardConfig,
client?.targetProfileIds || [], client?.targetProfileIds || [],
clientId,
); );
// Update persisted entry with new keys (including private key for export/QR) // 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; return config;
@@ -445,6 +469,107 @@ export class VpnManager {
return this.vpnServer.listClients(); 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. * Get telemetry for a specific client.
*/ */
@@ -533,10 +658,15 @@ export class VpnManager {
private async rewriteWireGuardAllowedIPs( private async rewriteWireGuardAllowedIPs(
wireguardConfig: string, wireguardConfig: string,
targetProfileIds: string[], targetProfileIds: string[],
clientId?: string,
): Promise<string> { ): Promise<string> {
if (!this.config.getClientAllowedIPs) return wireguardConfig; 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 effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`; 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' { private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
return this.resolvedForwardingMode return this.resolvedForwardingMode
?? this.forwardingModeOverride ?? this.forwardingModeOverride
+2
View File
@@ -23,6 +23,8 @@ export interface ITargetProfile {
targets?: ITargetProfileTarget[]; targets?: ITargetProfileTarget[];
/** Route references by stored route ID. Legacy route names are normalized when unique. */ /** Route references by stored route ID. Legacy route names are normalized when unique. */
routeRefs?: string[]; routeRefs?: string[];
/** Also allow routes whose source security would allow the VPN client's real connecting IP. */
allowRoutesByClientSourceIp?: boolean;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
createdBy: string; createdBy: string;
+4
View File
@@ -45,6 +45,10 @@ export interface IVpnConnectedClient {
bytesSent: number; bytesSent: number;
bytesReceived: number; bytesReceived: number;
transport: string; 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;
} }
/** /**
@@ -57,6 +57,7 @@ export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces
domains?: string[]; domains?: string[];
targets?: ITargetProfileTarget[]; targets?: ITargetProfileTarget[];
routeRefs?: string[]; routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}; };
response: { response: {
success: boolean; success: boolean;
@@ -82,6 +83,7 @@ export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces
domains?: string[]; domains?: string[];
targets?: ITargetProfileTarget[]; targets?: ITargetProfileTarget[];
routeRefs?: string[]; routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}; };
response: { response: {
success: boolean; success: boolean;
+4
View File
@@ -1569,6 +1569,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
domains?: string[]; domains?: string[];
targets?: Array<{ ip: string; port: number }>; targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[]; routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => { }>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext(); const context = getActionContext();
try { try {
@@ -1582,6 +1583,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
domains: dataArg.domains, domains: dataArg.domains,
targets: dataArg.targets, targets: dataArg.targets,
routeRefs: dataArg.routeRefs, routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
}); });
if (!response.success) { if (!response.success) {
return { return {
@@ -1605,6 +1607,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
domains?: string[]; domains?: string[];
targets?: Array<{ ip: string; port: number }>; targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[]; routeRefs?: string[];
allowRoutesByClientSourceIp?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => { }>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext(); const context = getActionContext();
try { try {
@@ -1619,6 +1622,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
domains: dataArg.domains, domains: dataArg.domains,
targets: dataArg.targets, targets: dataArg.targets,
routeRefs: dataArg.routeRefs, routeRefs: dataArg.routeRefs,
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
}); });
if (!response.success) { if (!response.success) {
return { return {
@@ -97,6 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
'Route Refs': profile.routeRefs?.length 'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}` ? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
: '-', : '-',
'Client Source IP Routes': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
Created: new Date(profile.createdAt).toLocaleDateString(), Created: new Date(profile.createdAt).toLocaleDateString(),
})} })}
.dataActions=${[ .dataActions=${[
@@ -223,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list> <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list> <dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list> <dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${false}></dees-input-checkbox>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -258,6 +260,7 @@ export class OpsViewTargetProfiles extends DeesElement {
domains: domains.length > 0 ? domains : undefined, domains: domains.length > 0 ? domains : undefined,
targets: targets.length > 0 ? targets : undefined, targets: targets.length > 0 ? targets : undefined,
routeRefs: routeRefs.length > 0 ? routeRefs : undefined, routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
}); });
modalArg.destroy(); modalArg.destroy();
}, },
@@ -284,6 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list> <dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list> <dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list> <dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -319,6 +323,7 @@ export class OpsViewTargetProfiles extends DeesElement {
domains, domains,
targets, targets,
routeRefs, routeRefs,
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
}); });
modalArg.destroy(); modalArg.destroy();
}, },
@@ -389,6 +394,10 @@ export class OpsViewTargetProfiles extends DeesElement {
: '-'} : '-'}
</div> </div>
</div> </div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Client Source IP Routes</div>
<div style="font-size: 14px; margin-top: 4px;">${profile.allowRoutesByClientSourceIp ? 'Enabled' : 'Disabled'}</div>
</div>
<div> <div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div> <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div> <div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div>
+2
View File
@@ -339,6 +339,7 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml, 'Status': statusHtml,
'Routing': routingHtml, 'Routing': routingHtml,
'VPN IP': client.assignedIp || '-', 'VPN IP': client.assignedIp || '-',
'Source IP': conn?.sourceIp || '-',
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds), 'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
'Description': client.description || '-', 'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(), 'Created': new Date(client.createdAt).toLocaleDateString(),
@@ -487,6 +488,7 @@ export class OpsViewVpn extends DeesElement {
${conn ? html` ${conn ? html`
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div> <div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div> <div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
<div class="infoItem"><span class="infoLabel">Source IP</span><span class="infoValue">${conn.sourceIp || '-'}</span></div>
` : ''} ` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div> <div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div> <div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>