Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aa07e81c7 | |||
| aec8b72ca3 | |||
| 466654ee4c | |||
| f1a11e3f6a |
14
changelog.md
14
changelog.md
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
|
||||
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
||||
|
||||
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
|
||||
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
|
||||
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
|
||||
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
|
||||
|
||||
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
||||
sync route filter toggle selection via component changeSubject
|
||||
|
||||
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
|
||||
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
|
||||
|
||||
## 2026-04-13 - 13.17.2 - fix(monitoring)
|
||||
exclude unconfigured routes from domain activity aggregation
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.17.2",
|
||||
"version": "13.17.5",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.17.2',
|
||||
version: '13.17.5',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -547,7 +547,9 @@ export class DcRouter {
|
||||
await this.referenceResolver.initialize();
|
||||
|
||||
// Initialize target profile manager
|
||||
this.targetProfileManager = new TargetProfileManager();
|
||||
this.targetProfileManager = new TargetProfileManager(
|
||||
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
await this.targetProfileManager.initialize();
|
||||
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
@@ -560,7 +562,10 @@ export class DcRouter {
|
||||
return [];
|
||||
}
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route, routeId, this.vpnManager.listClients(),
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
@@ -583,6 +588,7 @@ export class DcRouter {
|
||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
);
|
||||
await this.targetProfileManager.normalizeAllRouteRefs();
|
||||
|
||||
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||
const seeder = new DbSeeder(this.referenceResolver);
|
||||
@@ -2283,8 +2289,11 @@ export class DcRouter {
|
||||
|
||||
// Resolve DNS A records for matched domains (with caching)
|
||||
for (const domain of domains) {
|
||||
const stripped = domain.replace(/^\*\./, '');
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
||||
if (this.isWildcardVpnDomain(domain)) {
|
||||
this.logSkippedWildcardAllowedIp(domain);
|
||||
continue;
|
||||
}
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||
for (const ip of resolvedIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
}
|
||||
@@ -2303,6 +2312,8 @@ export class DcRouter {
|
||||
|
||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
||||
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
|
||||
private warnedWildcardVpnDomains = new Set<string>();
|
||||
|
||||
/**
|
||||
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||
@@ -2328,6 +2339,19 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
|
||||
private isWildcardVpnDomain(domain: string): boolean {
|
||||
return domain.includes('*');
|
||||
}
|
||||
|
||||
private logSkippedWildcardAllowedIp(domain: string): void {
|
||||
if (this.warnedWildcardVpnDomains.has(domain)) return;
|
||||
this.warnedWildcardVpnDomains.add(domain);
|
||||
logger.log(
|
||||
'warn',
|
||||
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
|
||||
);
|
||||
}
|
||||
|
||||
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||
// via the getVpnAllowList callback — no longer a separate method here.
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
||||
export class TargetProfileManager {
|
||||
private profiles = new Map<string, ITargetProfile>();
|
||||
|
||||
constructor(
|
||||
private getAllRoutes?: () => Map<string, IRoute>,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||
const profile: ITargetProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
domains: data.domains,
|
||||
targets: data.targets,
|
||||
routeRefs: data.routeRefs,
|
||||
routeRefs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
||||
throw new Error(`Target profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined && patch.name !== profile.name) {
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.id !== id && existing.name === patch.name) {
|
||||
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
if (patch.description !== undefined) profile.description = patch.description;
|
||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize stored route references to route IDs when they can be resolved
|
||||
* uniquely against the current route registry.
|
||||
*/
|
||||
public async normalizeAllRouteRefs(): Promise<void> {
|
||||
const allRoutes = this.getAllRoutes?.();
|
||||
if (!allRoutes?.size) return;
|
||||
|
||||
for (const profile of this.profiles.values()) {
|
||||
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||
profile.routeRefs,
|
||||
allRoutes,
|
||||
'bestEffort',
|
||||
);
|
||||
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||
|
||||
profile.routeRefs = normalizedRouteRefs;
|
||||
profile.updatedAt = Date.now();
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
public listProfiles(): ITargetProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
allRoutes: Map<string, IRoute> = new Map(),
|
||||
): Array<string | { ip: string; domains: string[] }> {
|
||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
const matchResult = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
if (matchResult === 'full') {
|
||||
fullAccess = true;
|
||||
break; // No need to check more profiles
|
||||
@@ -224,6 +268,7 @@ export class TargetProfileManager {
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
// Collect all access specifiers from assigned profiles
|
||||
for (const profileId of targetProfileIds) {
|
||||
@@ -247,7 +292,12 @@ export class TargetProfileManager {
|
||||
// Route references: scan all routes
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
if (!route.enabled) continue;
|
||||
if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
|
||||
if (this.routeMatchesProfile(
|
||||
route.route as IDcRouterRouteConfig,
|
||||
routeId,
|
||||
profile,
|
||||
routeNameIndex,
|
||||
)) {
|
||||
const routeDomains = (route.route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
@@ -275,9 +325,16 @@ export class TargetProfileManager {
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): boolean {
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
const result = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
return result !== 'none';
|
||||
}
|
||||
|
||||
@@ -294,11 +351,17 @@ export class TargetProfileManager {
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeDomains: string[],
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||
// 1. Route reference match → full access
|
||||
if (profile.routeRefs?.length) {
|
||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
|
||||
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Domain match
|
||||
@@ -362,6 +425,66 @@ export class TargetProfileManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||
}
|
||||
|
||||
private normalizeRouteRefsAgainstRoutes(
|
||||
routeRefs: string[] | undefined,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
mode: 'strict' | 'bestEffort',
|
||||
): string[] | undefined {
|
||||
if (!routeRefs?.length) return undefined;
|
||||
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
const normalizedRefs = new Set<string>();
|
||||
|
||||
for (const routeRef of routeRefs) {
|
||||
if (allRoutes.has(routeRef)) {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||
if (matchingRouteIds.length === 1) {
|
||||
normalizedRefs.add(matchingRouteIds[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === 'bestEffort') {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchingRouteIds.length > 1) {
|
||||
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||
}
|
||||
throw new Error(`Route reference '${routeRef}' not found`);
|
||||
}
|
||||
|
||||
return [...normalizedRefs];
|
||||
}
|
||||
|
||||
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||
const routeNameIndex = new Map<string, string[]>();
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
const routeName = route.route.name;
|
||||
if (!routeName) continue;
|
||||
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||
matchingRouteIds.push(routeId);
|
||||
routeNameIndex.set(routeName, matchingRouteIds);
|
||||
}
|
||||
return routeNameIndex;
|
||||
}
|
||||
|
||||
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||
if (!left?.length && !right?.length) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
@@ -55,6 +55,8 @@ export class VpnManager {
|
||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||
private clients: Map<string, VpnClientDoc> = new Map();
|
||||
private serverKeys?: VpnServerKeysDoc;
|
||||
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||
|
||||
constructor(config: IVpnManagerConfig) {
|
||||
this.config = config;
|
||||
@@ -88,6 +90,7 @@ export class VpnManager {
|
||||
if (client.useHostIp) {
|
||||
anyClientUsesHostIp = true;
|
||||
}
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
const entry: plugins.smartvpn.IClientEntry = {
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
@@ -97,13 +100,12 @@ export class VpnManager {
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
||||
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
||||
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
||||
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
||||
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
||||
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
||||
clientEntries.push(entry);
|
||||
}
|
||||
|
||||
@@ -112,13 +114,15 @@ export class VpnManager {
|
||||
|
||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||
let configuredMode = this.config.forwardingMode ?? 'socket';
|
||||
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||
configuredMode = 'hybrid';
|
||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||
}
|
||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||
const isBridge = forwardingMode === 'bridge';
|
||||
this.resolvedForwardingMode = forwardingMode;
|
||||
this.forwardingModeOverride = undefined;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
@@ -143,7 +147,7 @@ export class VpnManager {
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
@@ -189,6 +193,7 @@ export class VpnManager {
|
||||
this.vpnServer.stop();
|
||||
this.vpnServer = undefined;
|
||||
}
|
||||
this.resolvedForwardingMode = undefined;
|
||||
logger.log('info', 'VPN server stopped');
|
||||
}
|
||||
|
||||
@@ -213,14 +218,38 @@ export class VpnManager {
|
||||
throw new Error('VPN server not running');
|
||||
}
|
||||
|
||||
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
||||
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = opts.clientId;
|
||||
doc.enabled = true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = opts.description;
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
doc.staticIp = opts.staticIp;
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
doc.vlanId = opts.vlanId;
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
|
||||
const bundle = await this.vpnServer.createClient({
|
||||
clientId: opts.clientId,
|
||||
description: opts.description,
|
||||
clientId: doc.clientId,
|
||||
description: doc.description,
|
||||
security: this.buildClientSecurity(doc),
|
||||
useHostIp: doc.useHostIp,
|
||||
useDhcp: doc.useDhcp,
|
||||
staticIp: doc.staticIp,
|
||||
forceVlan: doc.forceVlan,
|
||||
vlanId: doc.vlanId,
|
||||
});
|
||||
|
||||
// Override AllowedIPs with per-client values based on target profiles
|
||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
|
||||
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -228,40 +257,16 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
doc.enabled = bundle.entry.enabled ?? true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = bundle.entry.description;
|
||||
doc.assignedIp = bundle.entry.assignedIp;
|
||||
doc.noisePublicKey = bundle.entry.publicKey;
|
||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.expiresAt = bundle.entry.expiresAt;
|
||||
if (opts.destinationAllowList !== undefined) {
|
||||
doc.destinationAllowList = opts.destinationAllowList;
|
||||
}
|
||||
if (opts.destinationBlockList !== undefined) {
|
||||
doc.destinationBlockList = opts.destinationBlockList;
|
||||
}
|
||||
if (opts.useHostIp !== undefined) {
|
||||
doc.useHostIp = opts.useHostIp;
|
||||
}
|
||||
if (opts.useDhcp !== undefined) {
|
||||
doc.useDhcp = opts.useDhcp;
|
||||
}
|
||||
if (opts.staticIp !== undefined) {
|
||||
doc.staticIp = opts.staticIp;
|
||||
}
|
||||
if (opts.forceVlan !== undefined) {
|
||||
doc.forceVlan = opts.forceVlan;
|
||||
}
|
||||
if (opts.vlanId !== undefined) {
|
||||
doc.vlanId = opts.vlanId;
|
||||
}
|
||||
this.clients.set(doc.clientId, doc);
|
||||
try {
|
||||
await this.persistClient(doc);
|
||||
@@ -276,12 +281,6 @@ export class VpnManager {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
const security = this.buildClientSecurity(doc);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer!.updateClient(doc.clientId, { security });
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
return bundle;
|
||||
}
|
||||
@@ -364,13 +363,13 @@ export class VpnManager {
|
||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
if (this.vpnServer) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
await this.vpnServer.updateClient(clientId, { security });
|
||||
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
@@ -478,26 +477,28 @@ export class VpnManager {
|
||||
|
||||
/**
|
||||
* Build per-client security settings for the smartvpn daemon.
|
||||
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
|
||||
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
||||
* TargetProfile direct IP:port targets extend the effective allow-list.
|
||||
*/
|
||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||
const security: plugins.smartvpn.IClientSecurity = {};
|
||||
const basePolicy = this.getBaseDestinationPolicy(client);
|
||||
|
||||
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
||||
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||
|
||||
// Merge with per-client explicit allow list
|
||||
const mergedAllowList = [
|
||||
...(client.destinationAllowList || []),
|
||||
...profileDirectTargets,
|
||||
];
|
||||
const mergedAllowList = this.mergeDestinationLists(
|
||||
basePolicy.allowList,
|
||||
client.destinationAllowList,
|
||||
profileDirectTargets,
|
||||
);
|
||||
const mergedBlockList = this.mergeDestinationLists(
|
||||
basePolicy.blockList,
|
||||
client.destinationBlockList,
|
||||
);
|
||||
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
default: basePolicy.default,
|
||||
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: client.destinationBlockList,
|
||||
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||
};
|
||||
|
||||
return security;
|
||||
@@ -510,10 +511,7 @@ export class VpnManager {
|
||||
public async refreshAllClientSecurity(): Promise<void> {
|
||||
if (!this.vpnServer) return;
|
||||
for (const client of this.clients.values()) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer.updateClient(client.clientId, { security });
|
||||
}
|
||||
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +548,7 @@ export class VpnManager {
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const docs = await VpnClientDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
this.clients.set(doc.clientId, doc);
|
||||
}
|
||||
if (this.clients.size > 0) {
|
||||
@@ -557,6 +556,93 @@ export class VpnManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
||||
return this.resolvedForwardingMode
|
||||
?? this.forwardingModeOverride
|
||||
?? this.config.forwardingMode
|
||||
?? 'socket';
|
||||
}
|
||||
|
||||
private getDefaultDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
useHostIp = false,
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
|
||||
return { default: 'allow' };
|
||||
}
|
||||
return { default: 'forceTarget', target: '127.0.0.1' };
|
||||
}
|
||||
|
||||
private getServerDestinationPolicy(
|
||||
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
|
||||
): plugins.smartvpn.IDestinationPolicy {
|
||||
return this.config.destinationPolicy ?? fallbackPolicy;
|
||||
}
|
||||
|
||||
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
|
||||
if (this.config.destinationPolicy) {
|
||||
return { ...this.config.destinationPolicy };
|
||||
}
|
||||
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
|
||||
}
|
||||
|
||||
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
|
||||
const merged = new Set<string>();
|
||||
for (const list of lists) {
|
||||
for (const entry of list || []) {
|
||||
merged.add(entry);
|
||||
}
|
||||
}
|
||||
return [...merged];
|
||||
}
|
||||
|
||||
private normalizeClientRoutingSettings(
|
||||
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
|
||||
): void {
|
||||
client.useHostIp = client.useHostIp === true;
|
||||
|
||||
if (!client.useHostIp) {
|
||||
client.useDhcp = false;
|
||||
client.staticIp = undefined;
|
||||
client.forceVlan = false;
|
||||
client.vlanId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
client.useDhcp = client.useDhcp === true;
|
||||
if (client.useDhcp) {
|
||||
client.staticIp = undefined;
|
||||
}
|
||||
|
||||
client.forceVlan = client.forceVlan === true;
|
||||
if (!client.forceVlan) {
|
||||
client.vlanId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
|
||||
return {
|
||||
description: client.description,
|
||||
security: this.buildClientSecurity(client),
|
||||
useHostIp: client.useHostIp,
|
||||
useDhcp: client.useDhcp,
|
||||
staticIp: client.staticIp,
|
||||
forceVlan: client.forceVlan,
|
||||
vlanId: client.vlanId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
||||
if (!useHostIp || !this.vpnServer) return;
|
||||
if (this.getResolvedForwardingMode() !== 'socket') return;
|
||||
|
||||
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
||||
this.forwardingModeOverride = 'hybrid';
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||
await client.save();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ITargetProfile {
|
||||
domains?: string[];
|
||||
/** Specific IP:port targets this profile grants access to */
|
||||
targets?: ITargetProfileTarget[];
|
||||
/** Route references by stored route ID or route name */
|
||||
/** Route references by stored route ID. Legacy route names are normalized when unique. */
|
||||
routeRefs?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
||||
@@ -21,6 +21,30 @@ export interface IMigrationRunner {
|
||||
run(): Promise<IMigrationRunResult>;
|
||||
}
|
||||
|
||||
async function migrateTargetProfileTargetHosts(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('TargetProfileDoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((target: any) => {
|
||||
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
|
||||
const { host, ...rest } = target;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return target;
|
||||
});
|
||||
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -48,23 +72,7 @@ export async function createMigrationRunner(
|
||||
.step('rename-target-profile-host-to-ip')
|
||||
.from('13.0.11').to('13.1.0')
|
||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('targetprofiledoc');
|
||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||
let migrated = 0;
|
||||
for await (const doc of cursor) {
|
||||
const targets = ((doc as any).targets || []).map((t: any) => {
|
||||
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
|
||||
const { host, ...rest } = t;
|
||||
return { ...rest, ip: host };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||
migrated++;
|
||||
}
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
})
|
||||
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
||||
.step('rename-domain-source-manual-to-dcrouter')
|
||||
.from('13.1.0').to('13.8.1')
|
||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||
@@ -120,6 +128,12 @@ export async function createMigrationRunner(
|
||||
await db.collection('RouteOverrideDoc').drop();
|
||||
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
||||
}
|
||||
})
|
||||
.step('repair-target-profile-ip-migration')
|
||||
.from('13.16.0').to('13.17.4')
|
||||
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
|
||||
.up(async (ctx) => {
|
||||
await migrateTargetProfileTargetHosts(ctx);
|
||||
});
|
||||
|
||||
return migration;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.17.2',
|
||||
version: '13.17.5',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -227,10 +227,10 @@ export class OpsViewRoutes extends DeesElement {
|
||||
></dees-statsgrid>
|
||||
|
||||
<dees-input-multitoggle
|
||||
class="routeFilterToggle"
|
||||
.type=${'single'}
|
||||
.options=${['User Routes', 'System Routes']}
|
||||
.selectedOption=${this.routeFilter}
|
||||
@change=${(e: any) => { this.routeFilter = e.target.value || e.target.selectedOption; }}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
${warnings.length > 0
|
||||
@@ -677,5 +677,13 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
|
||||
const toggle = this.shadowRoot!.querySelector('.routeFilterToggle') as any;
|
||||
if (toggle) {
|
||||
const sub = toggle.changeSubject.subscribe(() => {
|
||||
this.routeFilter = toggle.selectedOption;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
||||
: '-',
|
||||
'Route Refs': profile.routeRefs?.length
|
||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
|
||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
|
||||
: '-',
|
||||
Created: new Date(profile.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private getRouteCandidates() {
|
||||
private getRouteChoices() {
|
||||
const routeState = appstate.routeManagementStatePart.getState();
|
||||
const routes = routeState?.mergedRoutes || [];
|
||||
return routes
|
||||
.filter((mr) => mr.route.name)
|
||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
||||
.filter((mr) => mr.route.name && mr.id)
|
||||
.map((mr) => ({
|
||||
routeId: mr.id!,
|
||||
routeName: mr.route.name!,
|
||||
label: `${mr.route.name} (${mr.id})`,
|
||||
}));
|
||||
}
|
||||
|
||||
private getRouteCandidates() {
|
||||
return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
|
||||
}
|
||||
|
||||
private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
|
||||
if (!routeRefs?.length) return undefined;
|
||||
|
||||
const routeChoices = this.getRouteChoices();
|
||||
const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
|
||||
const routeByName = new Map<string, string[]>();
|
||||
|
||||
for (const route of routeChoices) {
|
||||
const labels = routeByName.get(route.routeName) || [];
|
||||
labels.push(route.label);
|
||||
routeByName.set(route.routeName, labels);
|
||||
}
|
||||
|
||||
return routeRefs.map((routeRef) => {
|
||||
const routeLabel = routeById.get(routeRef);
|
||||
if (routeLabel) return routeLabel;
|
||||
|
||||
const labelsForName = routeByName.get(routeRef) || [];
|
||||
if (labelsForName.length === 1) return labelsForName[0];
|
||||
|
||||
return routeRef;
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
|
||||
if (!routeRefs.length) return [];
|
||||
|
||||
const labelToId = new Map(
|
||||
this.getRouteChoices().map((route) => [route.label, route.routeId]),
|
||||
);
|
||||
return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
|
||||
}
|
||||
|
||||
private formatRouteRef(routeRef: string): string {
|
||||
return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
|
||||
}
|
||||
|
||||
private async ensureRoutesLoaded() {
|
||||
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
};
|
||||
})
|
||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||
);
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||
name: String(data.name),
|
||||
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||
const currentDomains = profile.domains || [];
|
||||
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
||||
const currentRouteRefs = profile.routeRefs || [];
|
||||
const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await this.ensureRoutesLoaded();
|
||||
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
};
|
||||
})
|
||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||
);
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||
id: profile.id,
|
||||
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
|
||||
<div style="font-size: 14px; margin-top: 4px;">
|
||||
${profile.routeRefs?.length
|
||||
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
|
||||
? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
|
||||
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
||||
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
||||
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
|
||||
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
|
||||
if (hostIpGroup) hostIpGroup.style.display = show;
|
||||
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
||||
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
||||
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
||||
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
if (!data.clientId) return;
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
useHostIp,
|
||||
useDhcp,
|
||||
staticIp,
|
||||
forceVlan: forceVlan || undefined,
|
||||
forceVlan,
|
||||
vlanId,
|
||||
destinationAllowList,
|
||||
destinationBlockList,
|
||||
@@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</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.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</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">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
||||
${client.useHostIp ? html`
|
||||
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
||||
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const currentUseHostIp = client.useHostIp ?? false;
|
||||
const currentUseDhcp = client.useDhcp ?? false;
|
||||
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
useHostIp,
|
||||
useDhcp,
|
||||
staticIp,
|
||||
forceVlan: forceVlan || undefined,
|
||||
forceVlan,
|
||||
vlanId,
|
||||
destinationAllowList,
|
||||
destinationBlockList,
|
||||
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build autocomplete candidates from loaded target profiles.
|
||||
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
|
||||
* Build stable profile labels for list inputs.
|
||||
*/
|
||||
private getTargetProfileCandidates() {
|
||||
private getTargetProfileChoices() {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
|
||||
const nameCounts = new Map<string, number>();
|
||||
|
||||
for (const profile of profiles) {
|
||||
nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1);
|
||||
}
|
||||
|
||||
return profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: (nameCounts.get(profile.name) || 0) > 1
|
||||
? `${profile.name} (${profile.id})`
|
||||
: profile.name,
|
||||
}));
|
||||
}
|
||||
|
||||
private getTargetProfileCandidates() {
|
||||
return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert profile IDs to profile names (for populating edit form values).
|
||||
* Convert profile IDs to form labels (for populating edit form values).
|
||||
*/
|
||||
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
|
||||
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
|
||||
if (!ids?.length) return undefined;
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
const choices = this.getTargetProfileChoices();
|
||||
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||
return ids.map((id) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
return profile?.name || id;
|
||||
return labelsById.get(id) || id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert profile names back to IDs (for saving form data).
|
||||
* Uses the dees-input-list candidates' payload when available.
|
||||
* Convert profile form labels back to IDs.
|
||||
*/
|
||||
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
|
||||
if (!names.length) return undefined;
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
return names
|
||||
.map((name) => {
|
||||
const profile = profiles.find((p) => p.name === name);
|
||||
return profile?.id;
|
||||
})
|
||||
private resolveProfileLabelsToIds(labels: string[]): string[] {
|
||||
if (!labels.length) return [];
|
||||
|
||||
const labelsToIds = new Map(
|
||||
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
|
||||
);
|
||||
return labels
|
||||
.map((label) => labelsToIds.get(label))
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user