feat(vpn): allow target profiles to grant non-vpnOnly routes by live client source IP
This commit is contained in:
+10
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ITargetProfilesState> => {
|
||||
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<ITargetProfilesState> => {
|
||||
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 {
|
||||
|
||||
@@ -97,6 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
'Route Refs': profile.routeRefs?.length
|
||||
? 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(),
|
||||
})}
|
||||
.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=${'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-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>
|
||||
`,
|
||||
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 {
|
||||
<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=${'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>
|
||||
`,
|
||||
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 {
|
||||
: '-'}
|
||||
</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 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>
|
||||
|
||||
@@ -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`
|
||||
<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">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">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
||||
|
||||
Reference in New Issue
Block a user