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
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user