@serve.zone/dcrouter 13.33.0 → 13.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_serve/bundle.js +12 -5
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +1 -1
- package/dist_ts/classes.dcrouter.js +12 -8
- package/dist_ts/config/classes.route-config-manager.d.ts +6 -7
- package/dist_ts/config/classes.route-config-manager.js +19 -28
- package/dist_ts/config/classes.target-profile-manager.d.ts +12 -7
- package/dist_ts/config/classes.target-profile-manager.js +38 -11
- package/dist_ts/db/documents/classes.target-profile.doc.d.ts +1 -0
- package/dist_ts/db/documents/classes.target-profile.doc.js +8 -2
- package/dist_ts/opsserver/handlers/target-profile.handler.js +3 -1
- package/dist_ts/opsserver/handlers/vpn.handler.js +3 -1
- package/dist_ts/vpn/classes.vpn-manager.d.ts +15 -1
- package/dist_ts/vpn/classes.vpn-manager.js +140 -6
- package/dist_ts_interfaces/data/target-profile.d.ts +2 -0
- package/dist_ts_interfaces/data/vpn.d.ts +4 -0
- package/dist_ts_interfaces/requests/target-profiles.d.ts +2 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate.d.ts +2 -0
- package/dist_ts_web/appstate.js +3 -1
- package/dist_ts_web/elements/network/ops-view-targetprofiles.js +10 -1
- package/dist_ts_web/elements/network/ops-view-vpn.js +3 -1
- package/package.json +6 -6
- package/readme.md +13 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +15 -10
- package/ts/config/classes.route-config-manager.ts +25 -33
- package/ts/config/classes.target-profile-manager.ts +48 -14
- package/ts/db/documents/classes.target-profile.doc.ts +3 -0
- package/ts/opsserver/handlers/target-profile.handler.ts +2 -0
- package/ts/opsserver/handlers/vpn.handler.ts +2 -0
- package/ts/vpn/classes.vpn-manager.ts +160 -3
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +4 -0
- package/ts_web/elements/network/ops-view-targetprofiles.ts +9 -0
- package/ts_web/elements/network/ops-view-vpn.ts +2 -0
|
@@ -5,6 +5,8 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
|
|
|
5
5
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
|
6
6
|
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
|
7
7
|
|
|
8
|
+
type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Manages TargetProfiles (target-side: what can be accessed).
|
|
10
12
|
* TargetProfiles define what resources a VPN client can reach:
|
|
@@ -35,6 +37,7 @@ export class TargetProfileManager {
|
|
|
35
37
|
domains?: string[];
|
|
36
38
|
targets?: ITargetProfileTarget[];
|
|
37
39
|
routeRefs?: string[];
|
|
40
|
+
allowRoutesByClientSourceIp?: boolean;
|
|
38
41
|
createdBy: string;
|
|
39
42
|
}): Promise<string> {
|
|
40
43
|
// Enforce unique profile names
|
|
@@ -55,6 +58,7 @@ export class TargetProfileManager {
|
|
|
55
58
|
domains: data.domains,
|
|
56
59
|
targets: data.targets,
|
|
57
60
|
routeRefs,
|
|
61
|
+
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
|
|
58
62
|
createdAt: now,
|
|
59
63
|
updatedAt: now,
|
|
60
64
|
createdBy: data.createdBy,
|
|
@@ -88,6 +92,9 @@ export class TargetProfileManager {
|
|
|
88
92
|
if (patch.domains !== undefined) profile.domains = patch.domains;
|
|
89
93
|
if (patch.targets !== undefined) profile.targets = patch.targets;
|
|
90
94
|
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
|
95
|
+
if (patch.allowRoutesByClientSourceIp !== undefined) {
|
|
96
|
+
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
|
|
97
|
+
}
|
|
91
98
|
profile.updatedAt = Date.now();
|
|
92
99
|
|
|
93
100
|
await this.persistProfile(profile);
|
|
@@ -199,29 +206,30 @@ export class TargetProfileManager {
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
// =========================================================================
|
|
202
|
-
// Core matching: route → client
|
|
209
|
+
// Core matching: route → VPN client grants
|
|
203
210
|
// =========================================================================
|
|
204
211
|
|
|
205
212
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
213
|
+
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
|
|
214
|
+
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
|
|
208
215
|
*
|
|
209
216
|
* Entries are domain-scoped when a profile matches via specific domains that are
|
|
210
217
|
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
|
|
211
|
-
* or when profile domains exactly equal the route's domains.
|
|
218
|
+
* or when profile domains exactly equal the route's domains. Profiles can also opt
|
|
219
|
+
* into source-policy routes; SmartProxy evaluates the real source IP per connection.
|
|
212
220
|
*/
|
|
213
|
-
public
|
|
221
|
+
public getMatchingVpnClients(
|
|
214
222
|
route: IDcRouterRouteConfig,
|
|
215
223
|
routeId: string | undefined,
|
|
216
224
|
clients: VpnClientDoc[],
|
|
217
225
|
allRoutes: Map<string, IRoute> = new Map(),
|
|
218
|
-
):
|
|
219
|
-
const entries:
|
|
226
|
+
): TVpnClientAllowEntry[] {
|
|
227
|
+
const entries: TVpnClientAllowEntry[] = [];
|
|
220
228
|
const routeDomains = this.getRouteDomains(route);
|
|
221
229
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
|
222
230
|
|
|
223
231
|
for (const client of clients) {
|
|
224
|
-
if (!client.enabled || !client.
|
|
232
|
+
if (!client.enabled || !client.clientId) continue;
|
|
225
233
|
if (!client.targetProfileIds?.length) continue;
|
|
226
234
|
|
|
227
235
|
// Collect scoped domains from all matching profiles for this client
|
|
@@ -246,12 +254,20 @@ export class TargetProfileManager {
|
|
|
246
254
|
if (matchResult !== 'none') {
|
|
247
255
|
for (const d of matchResult.domains) scopedDomains.add(d);
|
|
248
256
|
}
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
profile.allowRoutesByClientSourceIp === true
|
|
260
|
+
&& this.routeHasSourcePolicy(route)
|
|
261
|
+
) {
|
|
262
|
+
fullAccess = true;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
249
265
|
}
|
|
250
266
|
|
|
251
267
|
if (fullAccess) {
|
|
252
|
-
entries.push(client.
|
|
268
|
+
entries.push(client.clientId);
|
|
253
269
|
} else if (scopedDomains.size > 0) {
|
|
254
|
-
entries.push({
|
|
270
|
+
entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
|
|
255
271
|
}
|
|
256
272
|
}
|
|
257
273
|
|
|
@@ -292,13 +308,18 @@ export class TargetProfileManager {
|
|
|
292
308
|
// Route references: scan all routes
|
|
293
309
|
for (const [routeId, route] of allRoutes) {
|
|
294
310
|
if (!route.enabled) continue;
|
|
295
|
-
|
|
296
|
-
|
|
311
|
+
const dcRoute = route.route as IDcRouterRouteConfig;
|
|
312
|
+
const routeDomains = this.getRouteDomains(dcRoute);
|
|
313
|
+
const profileMatchesRoute = this.routeMatchesProfile(
|
|
314
|
+
dcRoute,
|
|
297
315
|
routeId,
|
|
298
316
|
profile,
|
|
299
317
|
routeNameIndex,
|
|
300
|
-
)
|
|
301
|
-
|
|
318
|
+
);
|
|
319
|
+
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
|
|
320
|
+
&& this.routeHasSourcePolicy(dcRoute);
|
|
321
|
+
if (profileMatchesRoute || sourceIpMatchesRoute) {
|
|
322
|
+
for (const d of routeDomains) {
|
|
302
323
|
domains.add(d);
|
|
303
324
|
}
|
|
304
325
|
}
|
|
@@ -422,6 +443,16 @@ export class TargetProfileManager {
|
|
|
422
443
|
return false;
|
|
423
444
|
}
|
|
424
445
|
|
|
446
|
+
private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
|
|
447
|
+
const security = (route as any).security;
|
|
448
|
+
const blockEntries = Array.isArray(security?.ipBlockList)
|
|
449
|
+
? security.ipBlockList
|
|
450
|
+
: security?.ipBlockList
|
|
451
|
+
? [security.ipBlockList]
|
|
452
|
+
: [];
|
|
453
|
+
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
|
|
454
|
+
}
|
|
455
|
+
|
|
425
456
|
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
|
|
426
457
|
const domains = (route.match as any)?.domains;
|
|
427
458
|
if (!domains) return [];
|
|
@@ -503,6 +534,7 @@ export class TargetProfileManager {
|
|
|
503
534
|
domains: doc.domains,
|
|
504
535
|
targets: doc.targets,
|
|
505
536
|
routeRefs: doc.routeRefs,
|
|
537
|
+
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
|
|
506
538
|
createdAt: doc.createdAt,
|
|
507
539
|
updatedAt: doc.updatedAt,
|
|
508
540
|
createdBy: doc.createdBy,
|
|
@@ -522,6 +554,7 @@ export class TargetProfileManager {
|
|
|
522
554
|
existingDoc.domains = profile.domains;
|
|
523
555
|
existingDoc.targets = profile.targets;
|
|
524
556
|
existingDoc.routeRefs = profile.routeRefs;
|
|
557
|
+
existingDoc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
|
|
525
558
|
existingDoc.updatedAt = profile.updatedAt;
|
|
526
559
|
await existingDoc.save();
|
|
527
560
|
} else {
|
|
@@ -532,6 +565,7 @@ export class TargetProfileManager {
|
|
|
532
565
|
doc.domains = profile.domains;
|
|
533
566
|
doc.targets = profile.targets;
|
|
534
567
|
doc.routeRefs = profile.routeRefs;
|
|
568
|
+
doc.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
|
|
535
569
|
doc.createdAt = profile.createdAt;
|
|
536
570
|
doc.updatedAt = profile.updatedAt;
|
|
537
571
|
doc.createdBy = profile.createdBy;
|
|
@@ -25,6 +25,9 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
|
|
|
25
25
|
@plugins.smartdata.svDb()
|
|
26
26
|
public routeRefs?: string[];
|
|
27
27
|
|
|
28
|
+
@plugins.smartdata.svDb()
|
|
29
|
+
public allowRoutesByClientSourceIp?: boolean;
|
|
30
|
+
|
|
28
31
|
@plugins.smartdata.svDb()
|
|
29
32
|
public createdAt!: number;
|
|
30
33
|
|
|
@@ -69,6 +69,7 @@ export class TargetProfileHandler {
|
|
|
69
69
|
domains: dataArg.domains,
|
|
70
70
|
targets: dataArg.targets,
|
|
71
71
|
routeRefs: dataArg.routeRefs,
|
|
72
|
+
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
|
72
73
|
createdBy: userId,
|
|
73
74
|
});
|
|
74
75
|
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
|
@@ -94,6 +95,7 @@ export class TargetProfileHandler {
|
|
|
94
95
|
domains: dataArg.domains,
|
|
95
96
|
targets: dataArg.targets,
|
|
96
97
|
routeRefs: dataArg.routeRefs,
|
|
98
|
+
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
|
97
99
|
});
|
|
98
100
|
// Re-apply routes and refresh VPN client security to update access
|
|
99
101
|
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
|
@@ -19,6 +19,10 @@ export interface IVpnManagerConfig {
|
|
|
19
19
|
}>;
|
|
20
20
|
/** Called when clients are created/deleted/toggled — triggers route re-application */
|
|
21
21
|
onClientChanged?: () => void;
|
|
22
|
+
/** Called when a live VPN client's real source IP changes. */
|
|
23
|
+
onClientSourceIpsChanged?: () => void;
|
|
24
|
+
/** Poll interval for live VPN client real source IP updates. Default: 10 seconds. */
|
|
25
|
+
clientSourceIpPollIntervalMs?: number;
|
|
22
26
|
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
|
|
23
27
|
destinationPolicy?: {
|
|
24
28
|
default: 'forceTarget' | 'block' | 'allow';
|
|
@@ -29,7 +33,7 @@ export interface IVpnManagerConfig {
|
|
|
29
33
|
/** Compute per-client AllowedIPs based on the client's target profile IDs.
|
|
30
34
|
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
|
31
35
|
* When not set, defaults to [subnet]. */
|
|
32
|
-
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
|
|
36
|
+
getClientAllowedIPs?: (targetProfileIds: string[], clientId?: string, sourceIp?: string) => Promise<string[]>;
|
|
33
37
|
/** Resolve per-client destination allow-list IPs from target profile IDs.
|
|
34
38
|
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
|
|
35
39
|
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
|
|
@@ -57,6 +61,9 @@ export class VpnManager {
|
|
|
57
61
|
private serverKeys?: VpnServerKeysDoc;
|
|
58
62
|
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
|
59
63
|
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
|
64
|
+
private clientSourceIps = new Map<string, string>();
|
|
65
|
+
private clientSourceIpPollTimer?: ReturnType<typeof setInterval>;
|
|
66
|
+
private clientSourceIpRefreshInFlight = false;
|
|
60
67
|
|
|
61
68
|
constructor(config: IVpnManagerConfig) {
|
|
62
69
|
this.config = config;
|
|
@@ -145,6 +152,8 @@ export class VpnManager {
|
|
|
145
152
|
wgListenPort,
|
|
146
153
|
clients: clientEntries,
|
|
147
154
|
socketForwardProxyProtocol: !isBridge,
|
|
155
|
+
socketForwardProxyProtocolSource: 'remoteIp',
|
|
156
|
+
socketForwardProxyProtocolVpnMetadata: true,
|
|
148
157
|
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
|
149
158
|
serverEndpoint,
|
|
150
159
|
clientAllowedIPs: [subnet],
|
|
@@ -173,6 +182,9 @@ export class VpnManager {
|
|
|
173
182
|
}
|
|
174
183
|
}
|
|
175
184
|
|
|
185
|
+
await this.refreshClientSourceIps(false);
|
|
186
|
+
this.startClientSourceIpPolling();
|
|
187
|
+
|
|
176
188
|
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
|
|
177
189
|
}
|
|
178
190
|
|
|
@@ -180,6 +192,7 @@ export class VpnManager {
|
|
|
180
192
|
* Stop the VPN server.
|
|
181
193
|
*/
|
|
182
194
|
public async stop(): Promise<void> {
|
|
195
|
+
this.stopClientSourceIpPolling();
|
|
183
196
|
if (this.vpnServer) {
|
|
184
197
|
try {
|
|
185
198
|
await this.vpnServer.stopServer();
|
|
@@ -189,6 +202,11 @@ export class VpnManager {
|
|
|
189
202
|
await this.vpnServer.stop();
|
|
190
203
|
this.vpnServer = undefined;
|
|
191
204
|
}
|
|
205
|
+
const hadClientSourceIps = this.clientSourceIps.size > 0;
|
|
206
|
+
this.clientSourceIps.clear();
|
|
207
|
+
if (hadClientSourceIps) {
|
|
208
|
+
this.config.onClientSourceIpsChanged?.();
|
|
209
|
+
}
|
|
192
210
|
this.resolvedForwardingMode = undefined;
|
|
193
211
|
logger.log('info', 'VPN server stopped');
|
|
194
212
|
}
|
|
@@ -246,6 +264,7 @@ export class VpnManager {
|
|
|
246
264
|
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
|
247
265
|
bundle.wireguardConfig,
|
|
248
266
|
doc.targetProfileIds || [],
|
|
267
|
+
doc.clientId,
|
|
249
268
|
);
|
|
250
269
|
|
|
251
270
|
// Persist client entry (including WG private key for export/QR)
|
|
@@ -287,6 +306,7 @@ export class VpnManager {
|
|
|
287
306
|
await this.vpnServer.removeClient(clientId);
|
|
288
307
|
const doc = this.clients.get(clientId);
|
|
289
308
|
this.clients.delete(clientId);
|
|
309
|
+
this.clientSourceIps.delete(clientId);
|
|
290
310
|
if (doc) {
|
|
291
311
|
await doc.delete();
|
|
292
312
|
}
|
|
@@ -328,6 +348,7 @@ export class VpnManager {
|
|
|
328
348
|
client.updatedAt = Date.now();
|
|
329
349
|
await this.persistClient(client);
|
|
330
350
|
}
|
|
351
|
+
this.clientSourceIps.delete(clientId);
|
|
331
352
|
this.config.onClientChanged?.();
|
|
332
353
|
}
|
|
333
354
|
|
|
@@ -380,6 +401,7 @@ export class VpnManager {
|
|
|
380
401
|
bundle.wireguardConfig = await this.rewriteWireGuardAllowedIPs(
|
|
381
402
|
bundle.wireguardConfig,
|
|
382
403
|
client?.targetProfileIds || [],
|
|
404
|
+
clientId,
|
|
383
405
|
);
|
|
384
406
|
|
|
385
407
|
// Update persisted entry with new keys (including private key for export/QR)
|
|
@@ -413,7 +435,11 @@ export class VpnManager {
|
|
|
413
435
|
);
|
|
414
436
|
}
|
|
415
437
|
|
|
416
|
-
config = await this.rewriteWireGuardAllowedIPs(
|
|
438
|
+
config = await this.rewriteWireGuardAllowedIPs(
|
|
439
|
+
config,
|
|
440
|
+
persisted?.targetProfileIds || [],
|
|
441
|
+
clientId,
|
|
442
|
+
);
|
|
417
443
|
}
|
|
418
444
|
|
|
419
445
|
return config;
|
|
@@ -445,6 +471,107 @@ export class VpnManager {
|
|
|
445
471
|
return this.vpnServer.listClients();
|
|
446
472
|
}
|
|
447
473
|
|
|
474
|
+
public getClientSourceIp(clientId: string): string | undefined {
|
|
475
|
+
return this.clientSourceIps.get(clientId);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
public getClientSourceIpMap(): Map<string, string> {
|
|
479
|
+
return new Map(this.clientSourceIps);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
public async refreshClientSourceIps(notifyOnChange = true): Promise<boolean> {
|
|
483
|
+
if (!this.vpnServer || this.clientSourceIpRefreshInFlight) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
this.clientSourceIpRefreshInFlight = true;
|
|
488
|
+
try {
|
|
489
|
+
const connectedClients = await this.vpnServer.listClients();
|
|
490
|
+
const nextSourceIps = new Map<string, string>();
|
|
491
|
+
const wireguardClientIds = new Set<string>();
|
|
492
|
+
|
|
493
|
+
for (const connectedClient of connectedClients) {
|
|
494
|
+
const clientId = connectedClient.registeredClientId || connectedClient.clientId;
|
|
495
|
+
if (!clientId) continue;
|
|
496
|
+
if (connectedClient.transportType === 'wireguard') {
|
|
497
|
+
wireguardClientIds.add(clientId);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const sourceIp = VpnManager.normalizeRemoteAddress(connectedClient.remoteAddr);
|
|
501
|
+
if (sourceIp) {
|
|
502
|
+
nextSourceIps.set(clientId, sourceIp);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (wireguardClientIds.size > 0 && typeof (this.vpnServer as any).listWgPeers === 'function') {
|
|
507
|
+
try {
|
|
508
|
+
const wgPeers = await this.vpnServer.listWgPeers();
|
|
509
|
+
const endpointByPublicKey = new Map<string, string>();
|
|
510
|
+
for (const peer of wgPeers) {
|
|
511
|
+
const endpointIp = VpnManager.normalizeRemoteAddress(peer.endpoint);
|
|
512
|
+
if (peer.publicKey && endpointIp) {
|
|
513
|
+
endpointByPublicKey.set(peer.publicKey, endpointIp);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for (const client of this.clients.values()) {
|
|
518
|
+
if (nextSourceIps.has(client.clientId)) continue;
|
|
519
|
+
if (!wireguardClientIds.has(client.clientId)) continue;
|
|
520
|
+
if (!client.wgPublicKey) continue;
|
|
521
|
+
const endpointIp = endpointByPublicKey.get(client.wgPublicKey);
|
|
522
|
+
if (endpointIp) {
|
|
523
|
+
nextSourceIps.set(client.clientId, endpointIp);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
logger.log('warn', `VPN: Failed to refresh WireGuard peer endpoints: ${(err as Error).message}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (this.sameSourceIpMap(this.clientSourceIps, nextSourceIps)) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
this.clientSourceIps = nextSourceIps;
|
|
536
|
+
if (notifyOnChange) {
|
|
537
|
+
this.config.onClientSourceIpsChanged?.();
|
|
538
|
+
}
|
|
539
|
+
return true;
|
|
540
|
+
} catch (err) {
|
|
541
|
+
logger.log('warn', `VPN: Failed to refresh client source IPs: ${(err as Error).message}`);
|
|
542
|
+
return false;
|
|
543
|
+
} finally {
|
|
544
|
+
this.clientSourceIpRefreshInFlight = false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
public static normalizeRemoteAddress(remoteAddress?: string): string | undefined {
|
|
549
|
+
const remoteAddressString = remoteAddress?.trim();
|
|
550
|
+
if (!remoteAddressString) return undefined;
|
|
551
|
+
|
|
552
|
+
if (remoteAddressString.startsWith('[')) {
|
|
553
|
+
const closingBracketIndex = remoteAddressString.indexOf(']');
|
|
554
|
+
if (closingBracketIndex > 0) {
|
|
555
|
+
const bracketedIp = remoteAddressString.slice(1, closingBracketIndex);
|
|
556
|
+
return plugins.net.isIP(bracketedIp) ? bracketedIp : undefined;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (plugins.net.isIP(remoteAddressString)) {
|
|
561
|
+
return remoteAddressString;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const lastColonIndex = remoteAddressString.lastIndexOf(':');
|
|
565
|
+
if (lastColonIndex > -1 && remoteAddressString.indexOf(':') === lastColonIndex) {
|
|
566
|
+
const host = remoteAddressString.slice(0, lastColonIndex);
|
|
567
|
+
if (plugins.net.isIP(host)) {
|
|
568
|
+
return host;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
448
575
|
/**
|
|
449
576
|
* Get telemetry for a specific client.
|
|
450
577
|
*/
|
|
@@ -533,10 +660,15 @@ export class VpnManager {
|
|
|
533
660
|
private async rewriteWireGuardAllowedIPs(
|
|
534
661
|
wireguardConfig: string,
|
|
535
662
|
targetProfileIds: string[],
|
|
663
|
+
clientId?: string,
|
|
536
664
|
): Promise<string> {
|
|
537
665
|
if (!this.config.getClientAllowedIPs) return wireguardConfig;
|
|
538
666
|
|
|
539
|
-
const allowedIPs = await this.config.getClientAllowedIPs(
|
|
667
|
+
const allowedIPs = await this.config.getClientAllowedIPs(
|
|
668
|
+
targetProfileIds,
|
|
669
|
+
clientId,
|
|
670
|
+
clientId ? this.getClientSourceIp(clientId) : undefined,
|
|
671
|
+
);
|
|
540
672
|
const effectiveAllowedIPs = allowedIPs.length ? allowedIPs : [this.getSubnet()];
|
|
541
673
|
const allowedLine = `AllowedIPs = ${effectiveAllowedIPs.join(', ')}`;
|
|
542
674
|
|
|
@@ -587,6 +719,31 @@ export class VpnManager {
|
|
|
587
719
|
}
|
|
588
720
|
}
|
|
589
721
|
|
|
722
|
+
private startClientSourceIpPolling(): void {
|
|
723
|
+
this.stopClientSourceIpPolling();
|
|
724
|
+
const pollIntervalMs = Math.max(1000, this.config.clientSourceIpPollIntervalMs ?? 10_000);
|
|
725
|
+
this.clientSourceIpPollTimer = setInterval(() => {
|
|
726
|
+
void this.refreshClientSourceIps().catch((err) => {
|
|
727
|
+
logger.log('warn', `VPN: Client source IP polling failed: ${err?.message || err}`);
|
|
728
|
+
});
|
|
729
|
+
}, pollIntervalMs);
|
|
730
|
+
this.clientSourceIpPollTimer.unref?.();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private stopClientSourceIpPolling(): void {
|
|
734
|
+
if (!this.clientSourceIpPollTimer) return;
|
|
735
|
+
clearInterval(this.clientSourceIpPollTimer);
|
|
736
|
+
this.clientSourceIpPollTimer = undefined;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private sameSourceIpMap(left: Map<string, string>, right: Map<string, string>): boolean {
|
|
740
|
+
if (left.size !== right.size) return false;
|
|
741
|
+
for (const [clientId, sourceIp] of left) {
|
|
742
|
+
if (right.get(clientId) !== sourceIp) return false;
|
|
743
|
+
}
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
|
|
590
747
|
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
|
591
748
|
return this.resolvedForwardingMode
|
|
592
749
|
?? this.forwardingModeOverride
|
package/ts_web/appstate.ts
CHANGED
|
@@ -1569,6 +1569,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
|
|
1569
1569
|
domains?: string[];
|
|
1570
1570
|
targets?: Array<{ ip: string; port: number }>;
|
|
1571
1571
|
routeRefs?: string[];
|
|
1572
|
+
allowRoutesByClientSourceIp?: boolean;
|
|
1572
1573
|
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
|
1573
1574
|
const context = getActionContext();
|
|
1574
1575
|
try {
|
|
@@ -1582,6 +1583,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
|
|
|
1582
1583
|
domains: dataArg.domains,
|
|
1583
1584
|
targets: dataArg.targets,
|
|
1584
1585
|
routeRefs: dataArg.routeRefs,
|
|
1586
|
+
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
|
1585
1587
|
});
|
|
1586
1588
|
if (!response.success) {
|
|
1587
1589
|
return {
|
|
@@ -1605,6 +1607,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
|
|
1605
1607
|
domains?: string[];
|
|
1606
1608
|
targets?: Array<{ ip: string; port: number }>;
|
|
1607
1609
|
routeRefs?: string[];
|
|
1610
|
+
allowRoutesByClientSourceIp?: boolean;
|
|
1608
1611
|
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
|
|
1609
1612
|
const context = getActionContext();
|
|
1610
1613
|
try {
|
|
@@ -1619,6 +1622,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
|
|
|
1619
1622
|
domains: dataArg.domains,
|
|
1620
1623
|
targets: dataArg.targets,
|
|
1621
1624
|
routeRefs: dataArg.routeRefs,
|
|
1625
|
+
allowRoutesByClientSourceIp: dataArg.allowRoutesByClientSourceIp,
|
|
1622
1626
|
});
|
|
1623
1627
|
if (!response.success) {
|
|
1624
1628
|
return {
|
|
@@ -97,6 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
97
97
|
'Route Refs': profile.routeRefs?.length
|
|
98
98
|
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
|
|
99
99
|
: '-',
|
|
100
|
+
'Source-Policy Route Grants': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
|
|
100
101
|
Created: new Date(profile.createdAt).toLocaleDateString(),
|
|
101
102
|
})}
|
|
102
103
|
.dataActions=${[
|
|
@@ -223,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
223
224
|
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
|
|
224
225
|
<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>
|
|
225
226
|
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
|
|
227
|
+
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${false}></dees-input-checkbox>
|
|
226
228
|
</dees-form>
|
|
227
229
|
`,
|
|
228
230
|
menuOptions: [
|
|
@@ -258,6 +260,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
258
260
|
domains: domains.length > 0 ? domains : undefined,
|
|
259
261
|
targets: targets.length > 0 ? targets : undefined,
|
|
260
262
|
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
|
|
263
|
+
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
|
|
261
264
|
});
|
|
262
265
|
modalArg.destroy();
|
|
263
266
|
},
|
|
@@ -284,6 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
284
287
|
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
|
|
285
288
|
<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>
|
|
286
289
|
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
|
|
290
|
+
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
|
|
287
291
|
</dees-form>
|
|
288
292
|
`,
|
|
289
293
|
menuOptions: [
|
|
@@ -319,6 +323,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
319
323
|
domains,
|
|
320
324
|
targets,
|
|
321
325
|
routeRefs,
|
|
326
|
+
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
|
|
322
327
|
});
|
|
323
328
|
modalArg.destroy();
|
|
324
329
|
},
|
|
@@ -389,6 +394,10 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
389
394
|
: '-'}
|
|
390
395
|
</div>
|
|
391
396
|
</div>
|
|
397
|
+
<div>
|
|
398
|
+
<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>
|
|
399
|
+
<div style="font-size: 14px; margin-top: 4px;">${profile.allowRoutesByClientSourceIp ? 'Enabled' : 'Disabled'}</div>
|
|
400
|
+
</div>
|
|
392
401
|
<div>
|
|
393
402
|
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
|
|
394
403
|
<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 {
|
|
|
339
339
|
'Status': statusHtml,
|
|
340
340
|
'Routing': routingHtml,
|
|
341
341
|
'VPN IP': client.assignedIp || '-',
|
|
342
|
+
'Source IP': conn?.sourceIp || '-',
|
|
342
343
|
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
|
|
343
344
|
'Description': client.description || '-',
|
|
344
345
|
'Created': new Date(client.createdAt).toLocaleDateString(),
|
|
@@ -487,6 +488,7 @@ export class OpsViewVpn extends DeesElement {
|
|
|
487
488
|
${conn ? html`
|
|
488
489
|
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue">${new Date(conn.connectedSince).toLocaleString()}</span></div>
|
|
489
490
|
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
|
491
|
+
<div class="infoItem"><span class="infoLabel">Source IP</span><span class="infoValue">${conn.sourceIp || '-'}</span></div>
|
|
490
492
|
` : ''}
|
|
491
493
|
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
|
492
494
|
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|