@serve.zone/dcrouter 13.17.3 → 13.17.5
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 +128 -128
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +4 -0
- package/dist_ts/classes.dcrouter.js +20 -5
- package/dist_ts/config/classes.target-profile-manager.d.ts +12 -1
- package/dist_ts/config/classes.target-profile-manager.js +98 -11
- package/dist_ts/vpn/classes.vpn-manager.d.ts +11 -2
- package/dist_ts/vpn/classes.vpn-manager.js +120 -64
- package/dist_ts_interfaces/data/target-profile.d.ts +1 -1
- package/dist_ts_migrations/index.js +25 -18
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/network/ops-view-targetprofiles.d.ts +4 -0
- package/dist_ts_web/elements/network/ops-view-targetprofiles.js +46 -9
- package/dist_ts_web/elements/network/ops-view-vpn.d.ts +6 -7
- package/dist_ts_web/elements/network/ops-view-vpn.js +39 -34
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +28 -4
- package/ts/config/classes.target-profile-manager.ts +129 -6
- package/ts/vpn/classes.vpn-manager.ts +146 -60
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/network/ops-view-targetprofiles.ts +57 -8
- package/ts_web/elements/network/ops-view-vpn.ts +43 -32
|
@@ -13,6 +13,10 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
|
|
13
13
|
export class TargetProfileManager {
|
|
14
14
|
private profiles = new Map<string, ITargetProfile>();
|
|
15
15
|
|
|
16
|
+
constructor(
|
|
17
|
+
private getAllRoutes?: () => Map<string, IRoute>,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
16
20
|
// =========================================================================
|
|
17
21
|
// Lifecycle
|
|
18
22
|
// =========================================================================
|
|
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
|
|
43
47
|
const id = plugins.uuid.v4();
|
|
44
48
|
const now = Date.now();
|
|
45
49
|
|
|
50
|
+
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
|
46
51
|
const profile: ITargetProfile = {
|
|
47
52
|
id,
|
|
48
53
|
name: data.name,
|
|
49
54
|
description: data.description,
|
|
50
55
|
domains: data.domains,
|
|
51
56
|
targets: data.targets,
|
|
52
|
-
routeRefs
|
|
57
|
+
routeRefs,
|
|
53
58
|
createdAt: now,
|
|
54
59
|
updatedAt: now,
|
|
55
60
|
createdBy: data.createdBy,
|
|
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
|
|
70
75
|
throw new Error(`Target profile '${id}' not found`);
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
if (patch.name !== undefined && patch.name !== profile.name) {
|
|
79
|
+
for (const existing of this.profiles.values()) {
|
|
80
|
+
if (existing.id !== id && existing.name === patch.name) {
|
|
81
|
+
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
if (patch.name !== undefined) profile.name = patch.name;
|
|
74
87
|
if (patch.description !== undefined) profile.description = patch.description;
|
|
75
88
|
if (patch.domains !== undefined) profile.domains = patch.domains;
|
|
76
89
|
if (patch.targets !== undefined) profile.targets = patch.targets;
|
|
77
|
-
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
|
90
|
+
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
|
78
91
|
profile.updatedAt = Date.now();
|
|
79
92
|
|
|
80
93
|
await this.persistProfile(profile);
|
|
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
|
|
127
140
|
return this.profiles.get(id);
|
|
128
141
|
}
|
|
129
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Normalize stored route references to route IDs when they can be resolved
|
|
145
|
+
* uniquely against the current route registry.
|
|
146
|
+
*/
|
|
147
|
+
public async normalizeAllRouteRefs(): Promise<void> {
|
|
148
|
+
const allRoutes = this.getAllRoutes?.();
|
|
149
|
+
if (!allRoutes?.size) return;
|
|
150
|
+
|
|
151
|
+
for (const profile of this.profiles.values()) {
|
|
152
|
+
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
|
153
|
+
profile.routeRefs,
|
|
154
|
+
allRoutes,
|
|
155
|
+
'bestEffort',
|
|
156
|
+
);
|
|
157
|
+
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
|
158
|
+
|
|
159
|
+
profile.routeRefs = normalizedRouteRefs;
|
|
160
|
+
profile.updatedAt = Date.now();
|
|
161
|
+
await this.persistProfile(profile);
|
|
162
|
+
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
130
166
|
public listProfiles(): ITargetProfile[] {
|
|
131
167
|
return [...this.profiles.values()];
|
|
132
168
|
}
|
|
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
|
|
178
214
|
route: IDcRouterRouteConfig,
|
|
179
215
|
routeId: string | undefined,
|
|
180
216
|
clients: VpnClientDoc[],
|
|
217
|
+
allRoutes: Map<string, IRoute> = new Map(),
|
|
181
218
|
): Array<string | { ip: string; domains: string[] }> {
|
|
182
219
|
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
|
183
220
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
|
221
|
+
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
|
184
222
|
|
|
185
223
|
for (const client of clients) {
|
|
186
224
|
if (!client.enabled || !client.assignedIp) continue;
|
|
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
|
|
194
232
|
const profile = this.profiles.get(profileId);
|
|
195
233
|
if (!profile) continue;
|
|
196
234
|
|
|
197
|
-
const matchResult = this.routeMatchesProfileDetailed(
|
|
235
|
+
const matchResult = this.routeMatchesProfileDetailed(
|
|
236
|
+
route,
|
|
237
|
+
routeId,
|
|
238
|
+
profile,
|
|
239
|
+
routeDomains,
|
|
240
|
+
routeNameIndex,
|
|
241
|
+
);
|
|
198
242
|
if (matchResult === 'full') {
|
|
199
243
|
fullAccess = true;
|
|
200
244
|
break; // No need to check more profiles
|
|
@@ -224,6 +268,7 @@ export class TargetProfileManager {
|
|
|
224
268
|
): { domains: string[]; targetIps: string[] } {
|
|
225
269
|
const domains = new Set<string>();
|
|
226
270
|
const targetIps = new Set<string>();
|
|
271
|
+
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
|
227
272
|
|
|
228
273
|
// Collect all access specifiers from assigned profiles
|
|
229
274
|
for (const profileId of targetProfileIds) {
|
|
@@ -247,7 +292,12 @@ export class TargetProfileManager {
|
|
|
247
292
|
// Route references: scan all routes
|
|
248
293
|
for (const [routeId, route] of allRoutes) {
|
|
249
294
|
if (!route.enabled) continue;
|
|
250
|
-
if (this.routeMatchesProfile(
|
|
295
|
+
if (this.routeMatchesProfile(
|
|
296
|
+
route.route as IDcRouterRouteConfig,
|
|
297
|
+
routeId,
|
|
298
|
+
profile,
|
|
299
|
+
routeNameIndex,
|
|
300
|
+
)) {
|
|
251
301
|
const routeDomains = (route.route.match as any)?.domains;
|
|
252
302
|
if (Array.isArray(routeDomains)) {
|
|
253
303
|
for (const d of routeDomains) {
|
|
@@ -275,9 +325,16 @@ export class TargetProfileManager {
|
|
|
275
325
|
route: IDcRouterRouteConfig,
|
|
276
326
|
routeId: string | undefined,
|
|
277
327
|
profile: ITargetProfile,
|
|
328
|
+
routeNameIndex: Map<string, string[]>,
|
|
278
329
|
): boolean {
|
|
279
330
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
|
280
|
-
const result = this.routeMatchesProfileDetailed(
|
|
331
|
+
const result = this.routeMatchesProfileDetailed(
|
|
332
|
+
route,
|
|
333
|
+
routeId,
|
|
334
|
+
profile,
|
|
335
|
+
routeDomains,
|
|
336
|
+
routeNameIndex,
|
|
337
|
+
);
|
|
281
338
|
return result !== 'none';
|
|
282
339
|
}
|
|
283
340
|
|
|
@@ -294,11 +351,17 @@ export class TargetProfileManager {
|
|
|
294
351
|
routeId: string | undefined,
|
|
295
352
|
profile: ITargetProfile,
|
|
296
353
|
routeDomains: string[],
|
|
354
|
+
routeNameIndex: Map<string, string[]>,
|
|
297
355
|
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
|
298
356
|
// 1. Route reference match → full access
|
|
299
357
|
if (profile.routeRefs?.length) {
|
|
300
358
|
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
|
301
|
-
if (route.name && profile.routeRefs.includes(route.name))
|
|
359
|
+
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
|
360
|
+
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
|
361
|
+
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
|
362
|
+
return 'full';
|
|
363
|
+
}
|
|
364
|
+
}
|
|
302
365
|
}
|
|
303
366
|
|
|
304
367
|
// 2. Domain match
|
|
@@ -362,6 +425,66 @@ export class TargetProfileManager {
|
|
|
362
425
|
return false;
|
|
363
426
|
}
|
|
364
427
|
|
|
428
|
+
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
|
429
|
+
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
|
430
|
+
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private normalizeRouteRefsAgainstRoutes(
|
|
434
|
+
routeRefs: string[] | undefined,
|
|
435
|
+
allRoutes: Map<string, IRoute>,
|
|
436
|
+
mode: 'strict' | 'bestEffort',
|
|
437
|
+
): string[] | undefined {
|
|
438
|
+
if (!routeRefs?.length) return undefined;
|
|
439
|
+
if (!allRoutes.size) return [...new Set(routeRefs)];
|
|
440
|
+
|
|
441
|
+
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
|
442
|
+
const normalizedRefs = new Set<string>();
|
|
443
|
+
|
|
444
|
+
for (const routeRef of routeRefs) {
|
|
445
|
+
if (allRoutes.has(routeRef)) {
|
|
446
|
+
normalizedRefs.add(routeRef);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
|
451
|
+
if (matchingRouteIds.length === 1) {
|
|
452
|
+
normalizedRefs.add(matchingRouteIds[0]);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (mode === 'bestEffort') {
|
|
457
|
+
normalizedRefs.add(routeRef);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (matchingRouteIds.length > 1) {
|
|
462
|
+
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`Route reference '${routeRef}' not found`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return [...normalizedRefs];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
|
471
|
+
const routeNameIndex = new Map<string, string[]>();
|
|
472
|
+
for (const [routeId, route] of allRoutes) {
|
|
473
|
+
const routeName = route.route.name;
|
|
474
|
+
if (!routeName) continue;
|
|
475
|
+
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
|
476
|
+
matchingRouteIds.push(routeId);
|
|
477
|
+
routeNameIndex.set(routeName, matchingRouteIds);
|
|
478
|
+
}
|
|
479
|
+
return routeNameIndex;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private sameStringArray(left?: string[], right?: string[]): boolean {
|
|
483
|
+
if (!left?.length && !right?.length) return true;
|
|
484
|
+
if (!left || !right || left.length !== right.length) return false;
|
|
485
|
+
return left.every((value, index) => value === right[index]);
|
|
486
|
+
}
|
|
487
|
+
|
|
365
488
|
// =========================================================================
|
|
366
489
|
// Private: persistence
|
|
367
490
|
// =========================================================================
|
|
@@ -55,6 +55,8 @@ export class VpnManager {
|
|
|
55
55
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
|
56
56
|
private clients: Map<string, VpnClientDoc> = new Map();
|
|
57
57
|
private serverKeys?: VpnServerKeysDoc;
|
|
58
|
+
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
|
59
|
+
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
|
58
60
|
|
|
59
61
|
constructor(config: IVpnManagerConfig) {
|
|
60
62
|
this.config = config;
|
|
@@ -88,6 +90,7 @@ export class VpnManager {
|
|
|
88
90
|
if (client.useHostIp) {
|
|
89
91
|
anyClientUsesHostIp = true;
|
|
90
92
|
}
|
|
93
|
+
this.normalizeClientRoutingSettings(client);
|
|
91
94
|
const entry: plugins.smartvpn.IClientEntry = {
|
|
92
95
|
clientId: client.clientId,
|
|
93
96
|
publicKey: client.noisePublicKey,
|
|
@@ -97,13 +100,12 @@ export class VpnManager {
|
|
|
97
100
|
assignedIp: client.assignedIp,
|
|
98
101
|
expiresAt: client.expiresAt,
|
|
99
102
|
security: this.buildClientSecurity(client),
|
|
103
|
+
useHostIp: client.useHostIp,
|
|
104
|
+
useDhcp: client.useDhcp,
|
|
105
|
+
staticIp: client.staticIp,
|
|
106
|
+
forceVlan: client.forceVlan,
|
|
107
|
+
vlanId: client.vlanId,
|
|
100
108
|
};
|
|
101
|
-
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
|
102
|
-
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
|
103
|
-
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
|
104
|
-
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
|
105
|
-
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
|
106
|
-
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
|
107
109
|
clientEntries.push(entry);
|
|
108
110
|
}
|
|
109
111
|
|
|
@@ -112,13 +114,15 @@ export class VpnManager {
|
|
|
112
114
|
|
|
113
115
|
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
|
114
116
|
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
|
115
|
-
let configuredMode = this.config.forwardingMode ?? 'socket';
|
|
117
|
+
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
|
116
118
|
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
|
117
119
|
configuredMode = 'hybrid';
|
|
118
120
|
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
|
119
121
|
}
|
|
120
122
|
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
|
121
123
|
const isBridge = forwardingMode === 'bridge';
|
|
124
|
+
this.resolvedForwardingMode = forwardingMode;
|
|
125
|
+
this.forwardingModeOverride = undefined;
|
|
122
126
|
|
|
123
127
|
// Create and start VpnServer
|
|
124
128
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
|
@@ -143,7 +147,7 @@ export class VpnManager {
|
|
|
143
147
|
wgListenPort,
|
|
144
148
|
clients: clientEntries,
|
|
145
149
|
socketForwardProxyProtocol: !isBridge,
|
|
146
|
-
destinationPolicy: this.
|
|
150
|
+
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
|
147
151
|
serverEndpoint: this.config.serverEndpoint
|
|
148
152
|
? `${this.config.serverEndpoint}:${wgListenPort}`
|
|
149
153
|
: undefined,
|
|
@@ -189,6 +193,7 @@ export class VpnManager {
|
|
|
189
193
|
this.vpnServer.stop();
|
|
190
194
|
this.vpnServer = undefined;
|
|
191
195
|
}
|
|
196
|
+
this.resolvedForwardingMode = undefined;
|
|
192
197
|
logger.log('info', 'VPN server stopped');
|
|
193
198
|
}
|
|
194
199
|
|
|
@@ -213,14 +218,38 @@ export class VpnManager {
|
|
|
213
218
|
throw new Error('VPN server not running');
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
|
222
|
+
|
|
223
|
+
const doc = new VpnClientDoc();
|
|
224
|
+
doc.clientId = opts.clientId;
|
|
225
|
+
doc.enabled = true;
|
|
226
|
+
doc.targetProfileIds = opts.targetProfileIds;
|
|
227
|
+
doc.description = opts.description;
|
|
228
|
+
doc.destinationAllowList = opts.destinationAllowList;
|
|
229
|
+
doc.destinationBlockList = opts.destinationBlockList;
|
|
230
|
+
doc.useHostIp = opts.useHostIp;
|
|
231
|
+
doc.useDhcp = opts.useDhcp;
|
|
232
|
+
doc.staticIp = opts.staticIp;
|
|
233
|
+
doc.forceVlan = opts.forceVlan;
|
|
234
|
+
doc.vlanId = opts.vlanId;
|
|
235
|
+
doc.createdAt = Date.now();
|
|
236
|
+
doc.updatedAt = Date.now();
|
|
237
|
+
this.normalizeClientRoutingSettings(doc);
|
|
238
|
+
|
|
216
239
|
const bundle = await this.vpnServer.createClient({
|
|
217
|
-
clientId:
|
|
218
|
-
description:
|
|
240
|
+
clientId: doc.clientId,
|
|
241
|
+
description: doc.description,
|
|
242
|
+
security: this.buildClientSecurity(doc),
|
|
243
|
+
useHostIp: doc.useHostIp,
|
|
244
|
+
useDhcp: doc.useDhcp,
|
|
245
|
+
staticIp: doc.staticIp,
|
|
246
|
+
forceVlan: doc.forceVlan,
|
|
247
|
+
vlanId: doc.vlanId,
|
|
219
248
|
});
|
|
220
249
|
|
|
221
250
|
// Override AllowedIPs with per-client values based on target profiles
|
|
222
251
|
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
|
223
|
-
const allowedIPs = await this.config.getClientAllowedIPs(
|
|
252
|
+
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
|
224
253
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
|
225
254
|
/AllowedIPs\s*=\s*.+/,
|
|
226
255
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
|
@@ -228,40 +257,16 @@ export class VpnManager {
|
|
|
228
257
|
}
|
|
229
258
|
|
|
230
259
|
// Persist client entry (including WG private key for export/QR)
|
|
231
|
-
const doc = new VpnClientDoc();
|
|
232
260
|
doc.clientId = bundle.entry.clientId;
|
|
233
261
|
doc.enabled = bundle.entry.enabled ?? true;
|
|
234
|
-
doc.targetProfileIds = opts.targetProfileIds;
|
|
235
262
|
doc.description = bundle.entry.description;
|
|
236
263
|
doc.assignedIp = bundle.entry.assignedIp;
|
|
237
264
|
doc.noisePublicKey = bundle.entry.publicKey;
|
|
238
265
|
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
|
239
266
|
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
|
240
267
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
|
241
|
-
doc.createdAt = Date.now();
|
|
242
268
|
doc.updatedAt = Date.now();
|
|
243
269
|
doc.expiresAt = bundle.entry.expiresAt;
|
|
244
|
-
if (opts.destinationAllowList !== undefined) {
|
|
245
|
-
doc.destinationAllowList = opts.destinationAllowList;
|
|
246
|
-
}
|
|
247
|
-
if (opts.destinationBlockList !== undefined) {
|
|
248
|
-
doc.destinationBlockList = opts.destinationBlockList;
|
|
249
|
-
}
|
|
250
|
-
if (opts.useHostIp !== undefined) {
|
|
251
|
-
doc.useHostIp = opts.useHostIp;
|
|
252
|
-
}
|
|
253
|
-
if (opts.useDhcp !== undefined) {
|
|
254
|
-
doc.useDhcp = opts.useDhcp;
|
|
255
|
-
}
|
|
256
|
-
if (opts.staticIp !== undefined) {
|
|
257
|
-
doc.staticIp = opts.staticIp;
|
|
258
|
-
}
|
|
259
|
-
if (opts.forceVlan !== undefined) {
|
|
260
|
-
doc.forceVlan = opts.forceVlan;
|
|
261
|
-
}
|
|
262
|
-
if (opts.vlanId !== undefined) {
|
|
263
|
-
doc.vlanId = opts.vlanId;
|
|
264
|
-
}
|
|
265
270
|
this.clients.set(doc.clientId, doc);
|
|
266
271
|
try {
|
|
267
272
|
await this.persistClient(doc);
|
|
@@ -276,12 +281,6 @@ export class VpnManager {
|
|
|
276
281
|
throw err;
|
|
277
282
|
}
|
|
278
283
|
|
|
279
|
-
// Sync per-client security to the running daemon
|
|
280
|
-
const security = this.buildClientSecurity(doc);
|
|
281
|
-
if (security.destinationPolicy) {
|
|
282
|
-
await this.vpnServer!.updateClient(doc.clientId, { security });
|
|
283
|
-
}
|
|
284
|
-
|
|
285
284
|
this.config.onClientChanged?.();
|
|
286
285
|
return bundle;
|
|
287
286
|
}
|
|
@@ -364,13 +363,13 @@ export class VpnManager {
|
|
|
364
363
|
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
|
365
364
|
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
|
366
365
|
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
|
366
|
+
this.normalizeClientRoutingSettings(client);
|
|
367
367
|
client.updatedAt = Date.now();
|
|
368
368
|
await this.persistClient(client);
|
|
369
369
|
|
|
370
|
-
// Sync per-client security to the running daemon
|
|
371
370
|
if (this.vpnServer) {
|
|
372
|
-
|
|
373
|
-
await this.vpnServer.updateClient(clientId,
|
|
371
|
+
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
|
372
|
+
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
|
374
373
|
}
|
|
375
374
|
|
|
376
375
|
this.config.onClientChanged?.();
|
|
@@ -478,26 +477,28 @@ export class VpnManager {
|
|
|
478
477
|
|
|
479
478
|
/**
|
|
480
479
|
* Build per-client security settings for the smartvpn daemon.
|
|
481
|
-
*
|
|
482
|
-
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
|
480
|
+
* TargetProfile direct IP:port targets extend the effective allow-list.
|
|
483
481
|
*/
|
|
484
482
|
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
|
485
483
|
const security: plugins.smartvpn.IClientSecurity = {};
|
|
484
|
+
const basePolicy = this.getBaseDestinationPolicy(client);
|
|
486
485
|
|
|
487
|
-
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
|
488
486
|
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
487
|
+
const mergedAllowList = this.mergeDestinationLists(
|
|
488
|
+
basePolicy.allowList,
|
|
489
|
+
client.destinationAllowList,
|
|
490
|
+
profileDirectTargets,
|
|
491
|
+
);
|
|
492
|
+
const mergedBlockList = this.mergeDestinationLists(
|
|
493
|
+
basePolicy.blockList,
|
|
494
|
+
client.destinationBlockList,
|
|
495
|
+
);
|
|
495
496
|
|
|
496
497
|
security.destinationPolicy = {
|
|
497
|
-
default:
|
|
498
|
-
target: '
|
|
498
|
+
default: basePolicy.default,
|
|
499
|
+
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
|
499
500
|
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
|
500
|
-
blockList:
|
|
501
|
+
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
|
501
502
|
};
|
|
502
503
|
|
|
503
504
|
return security;
|
|
@@ -510,10 +511,7 @@ export class VpnManager {
|
|
|
510
511
|
public async refreshAllClientSecurity(): Promise<void> {
|
|
511
512
|
if (!this.vpnServer) return;
|
|
512
513
|
for (const client of this.clients.values()) {
|
|
513
|
-
|
|
514
|
-
if (security.destinationPolicy) {
|
|
515
|
-
await this.vpnServer.updateClient(client.clientId, { security });
|
|
516
|
-
}
|
|
514
|
+
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
|
517
515
|
}
|
|
518
516
|
}
|
|
519
517
|
|
|
@@ -550,6 +548,7 @@ export class VpnManager {
|
|
|
550
548
|
private async loadPersistedClients(): Promise<void> {
|
|
551
549
|
const docs = await VpnClientDoc.findAll();
|
|
552
550
|
for (const doc of docs) {
|
|
551
|
+
this.normalizeClientRoutingSettings(doc);
|
|
553
552
|
this.clients.set(doc.clientId, doc);
|
|
554
553
|
}
|
|
555
554
|
if (this.clients.size > 0) {
|
|
@@ -557,6 +556,93 @@ export class VpnManager {
|
|
|
557
556
|
}
|
|
558
557
|
}
|
|
559
558
|
|
|
559
|
+
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
|
560
|
+
return this.resolvedForwardingMode
|
|
561
|
+
?? this.forwardingModeOverride
|
|
562
|
+
?? this.config.forwardingMode
|
|
563
|
+
?? 'socket';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private getDefaultDestinationPolicy(
|
|
567
|
+
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
|
568
|
+
useHostIp = false,
|
|
569
|
+
): plugins.smartvpn.IDestinationPolicy {
|
|
570
|
+
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
|
|
571
|
+
return { default: 'allow' };
|
|
572
|
+
}
|
|
573
|
+
return { default: 'forceTarget', target: '127.0.0.1' };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private getServerDestinationPolicy(
|
|
577
|
+
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
|
578
|
+
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
|
|
579
|
+
): plugins.smartvpn.IDestinationPolicy {
|
|
580
|
+
return this.config.destinationPolicy ?? fallbackPolicy;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
|
|
584
|
+
if (this.config.destinationPolicy) {
|
|
585
|
+
return { ...this.config.destinationPolicy };
|
|
586
|
+
}
|
|
587
|
+
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
|
|
591
|
+
const merged = new Set<string>();
|
|
592
|
+
for (const list of lists) {
|
|
593
|
+
for (const entry of list || []) {
|
|
594
|
+
merged.add(entry);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return [...merged];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private normalizeClientRoutingSettings(
|
|
601
|
+
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
|
|
602
|
+
): void {
|
|
603
|
+
client.useHostIp = client.useHostIp === true;
|
|
604
|
+
|
|
605
|
+
if (!client.useHostIp) {
|
|
606
|
+
client.useDhcp = false;
|
|
607
|
+
client.staticIp = undefined;
|
|
608
|
+
client.forceVlan = false;
|
|
609
|
+
client.vlanId = undefined;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
client.useDhcp = client.useDhcp === true;
|
|
614
|
+
if (client.useDhcp) {
|
|
615
|
+
client.staticIp = undefined;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
client.forceVlan = client.forceVlan === true;
|
|
619
|
+
if (!client.forceVlan) {
|
|
620
|
+
client.vlanId = undefined;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
|
|
625
|
+
return {
|
|
626
|
+
description: client.description,
|
|
627
|
+
security: this.buildClientSecurity(client),
|
|
628
|
+
useHostIp: client.useHostIp,
|
|
629
|
+
useDhcp: client.useDhcp,
|
|
630
|
+
staticIp: client.staticIp,
|
|
631
|
+
forceVlan: client.forceVlan,
|
|
632
|
+
vlanId: client.vlanId,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
|
637
|
+
if (!useHostIp || !this.vpnServer) return;
|
|
638
|
+
if (this.getResolvedForwardingMode() !== 'socket') return;
|
|
639
|
+
|
|
640
|
+
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
|
641
|
+
this.forwardingModeOverride = 'hybrid';
|
|
642
|
+
await this.stop();
|
|
643
|
+
await this.start();
|
|
644
|
+
}
|
|
645
|
+
|
|
560
646
|
private async persistClient(client: VpnClientDoc): Promise<void> {
|
|
561
647
|
await client.save();
|
|
562
648
|
}
|
|
@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
95
95
|
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
|
96
96
|
: '-',
|
|
97
97
|
'Route Refs': profile.routeRefs?.length
|
|
98
|
-
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
|
|
98
|
+
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
|
|
99
99
|
: '-',
|
|
100
100
|
Created: new Date(profile.createdAt).toLocaleDateString(),
|
|
101
101
|
})}
|
|
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
149
149
|
`;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
private
|
|
152
|
+
private getRouteChoices() {
|
|
153
153
|
const routeState = appstate.routeManagementStatePart.getState();
|
|
154
154
|
const routes = routeState?.mergedRoutes || [];
|
|
155
155
|
return routes
|
|
156
|
-
.filter((mr) => mr.route.name)
|
|
157
|
-
.map((mr) => ({
|
|
156
|
+
.filter((mr) => mr.route.name && mr.id)
|
|
157
|
+
.map((mr) => ({
|
|
158
|
+
routeId: mr.id!,
|
|
159
|
+
routeName: mr.route.name!,
|
|
160
|
+
label: `${mr.route.name} (${mr.id})`,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private getRouteCandidates() {
|
|
165
|
+
return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
|
|
169
|
+
if (!routeRefs?.length) return undefined;
|
|
170
|
+
|
|
171
|
+
const routeChoices = this.getRouteChoices();
|
|
172
|
+
const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
|
|
173
|
+
const routeByName = new Map<string, string[]>();
|
|
174
|
+
|
|
175
|
+
for (const route of routeChoices) {
|
|
176
|
+
const labels = routeByName.get(route.routeName) || [];
|
|
177
|
+
labels.push(route.label);
|
|
178
|
+
routeByName.set(route.routeName, labels);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return routeRefs.map((routeRef) => {
|
|
182
|
+
const routeLabel = routeById.get(routeRef);
|
|
183
|
+
if (routeLabel) return routeLabel;
|
|
184
|
+
|
|
185
|
+
const labelsForName = routeByName.get(routeRef) || [];
|
|
186
|
+
if (labelsForName.length === 1) return labelsForName[0];
|
|
187
|
+
|
|
188
|
+
return routeRef;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
|
|
193
|
+
if (!routeRefs.length) return [];
|
|
194
|
+
|
|
195
|
+
const labelToId = new Map(
|
|
196
|
+
this.getRouteChoices().map((route) => [route.label, route.routeId]),
|
|
197
|
+
);
|
|
198
|
+
return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private formatRouteRef(routeRef: string): string {
|
|
202
|
+
return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
|
|
158
203
|
}
|
|
159
204
|
|
|
160
205
|
private async ensureRoutesLoaded() {
|
|
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
203
248
|
};
|
|
204
249
|
})
|
|
205
250
|
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
|
206
|
-
const routeRefs
|
|
251
|
+
const routeRefs = this.resolveRouteLabelsToRefs(
|
|
252
|
+
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
|
253
|
+
);
|
|
207
254
|
|
|
208
255
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
|
209
256
|
name: String(data.name),
|
|
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
222
269
|
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
|
223
270
|
const currentDomains = profile.domains || [];
|
|
224
271
|
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
|
225
|
-
const currentRouteRefs = profile.routeRefs || [];
|
|
272
|
+
const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
|
|
226
273
|
|
|
227
274
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
228
275
|
await this.ensureRoutesLoaded();
|
|
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
261
308
|
};
|
|
262
309
|
})
|
|
263
310
|
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
|
264
|
-
const routeRefs
|
|
311
|
+
const routeRefs = this.resolveRouteLabelsToRefs(
|
|
312
|
+
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
|
313
|
+
);
|
|
265
314
|
|
|
266
315
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
|
267
316
|
id: profile.id,
|
|
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|
|
336
385
|
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
|
|
337
386
|
<div style="font-size: 14px; margin-top: 4px;">
|
|
338
387
|
${profile.routeRefs?.length
|
|
339
|
-
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
|
|
388
|
+
? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
|
|
340
389
|
: '-'}
|
|
341
390
|
</div>
|
|
342
391
|
</div>
|