fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
||||||
sync route filter toggle selection via component changeSubject
|
sync route filter toggle selection via component changeSubject
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "13.17.3",
|
"version": "13.17.4",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.3',
|
version: '13.17.5',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -547,7 +547,9 @@ export class DcRouter {
|
|||||||
await this.referenceResolver.initialize();
|
await this.referenceResolver.initialize();
|
||||||
|
|
||||||
// Initialize target profile manager
|
// Initialize target profile manager
|
||||||
this.targetProfileManager = new TargetProfileManager();
|
this.targetProfileManager = new TargetProfileManager(
|
||||||
|
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||||
|
);
|
||||||
await this.targetProfileManager.initialize();
|
await this.targetProfileManager.initialize();
|
||||||
|
|
||||||
this.routeConfigManager = new RouteConfigManager(
|
this.routeConfigManager = new RouteConfigManager(
|
||||||
@@ -560,7 +562,10 @@ export class DcRouter {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.targetProfileManager.getMatchingClientIps(
|
return this.targetProfileManager.getMatchingClientIps(
|
||||||
route, routeId, this.vpnManager.listClients(),
|
route,
|
||||||
|
routeId,
|
||||||
|
this.vpnManager.listClients(),
|
||||||
|
this.routeConfigManager?.getRoutes() || new Map(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -583,6 +588,7 @@ export class DcRouter {
|
|||||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
this.seedDnsRoutes 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
|
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||||
const seeder = new DbSeeder(this.referenceResolver);
|
const seeder = new DbSeeder(this.referenceResolver);
|
||||||
@@ -2283,8 +2289,11 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Resolve DNS A records for matched domains (with caching)
|
// Resolve DNS A records for matched domains (with caching)
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const stripped = domain.replace(/^\*\./, '');
|
if (this.isWildcardVpnDomain(domain)) {
|
||||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
this.logSkippedWildcardAllowedIp(domain);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||||
for (const ip of resolvedIps) {
|
for (const ip of resolvedIps) {
|
||||||
ips.add(`${ip}/32`);
|
ips.add(`${ip}/32`);
|
||||||
}
|
}
|
||||||
@@ -2303,6 +2312,8 @@ export class DcRouter {
|
|||||||
|
|
||||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||||
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
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.
|
* 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()
|
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||||
// via the getVpnAllowList callback — no longer a separate method here.
|
// 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 {
|
export class TargetProfileManager {
|
||||||
private profiles = new Map<string, ITargetProfile>();
|
private profiles = new Map<string, ITargetProfile>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private getAllRoutes?: () => Map<string, IRoute>,
|
||||||
|
) {}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
|||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||||
const profile: ITargetProfile = {
|
const profile: ITargetProfile = {
|
||||||
id,
|
id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
domains: data.domains,
|
domains: data.domains,
|
||||||
targets: data.targets,
|
targets: data.targets,
|
||||||
routeRefs: data.routeRefs,
|
routeRefs,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
|||||||
throw new Error(`Target profile '${id}' not found`);
|
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.name !== undefined) profile.name = patch.name;
|
||||||
if (patch.description !== undefined) profile.description = patch.description;
|
if (patch.description !== undefined) profile.description = patch.description;
|
||||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||||
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||||
profile.updatedAt = Date.now();
|
profile.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.persistProfile(profile);
|
await this.persistProfile(profile);
|
||||||
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
|||||||
return this.profiles.get(id);
|
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[] {
|
public listProfiles(): ITargetProfile[] {
|
||||||
return [...this.profiles.values()];
|
return [...this.profiles.values()];
|
||||||
}
|
}
|
||||||
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
|||||||
route: IDcRouterRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
clients: VpnClientDoc[],
|
clients: VpnClientDoc[],
|
||||||
|
allRoutes: Map<string, IRoute> = new Map(),
|
||||||
): Array<string | { ip: string; domains: string[] }> {
|
): Array<string | { ip: string; domains: string[] }> {
|
||||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (!client.enabled || !client.assignedIp) continue;
|
if (!client.enabled || !client.assignedIp) continue;
|
||||||
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
|||||||
const profile = this.profiles.get(profileId);
|
const profile = this.profiles.get(profileId);
|
||||||
if (!profile) continue;
|
if (!profile) continue;
|
||||||
|
|
||||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
const matchResult = this.routeMatchesProfileDetailed(
|
||||||
|
route,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeDomains,
|
||||||
|
routeNameIndex,
|
||||||
|
);
|
||||||
if (matchResult === 'full') {
|
if (matchResult === 'full') {
|
||||||
fullAccess = true;
|
fullAccess = true;
|
||||||
break; // No need to check more profiles
|
break; // No need to check more profiles
|
||||||
@@ -224,6 +268,7 @@ export class TargetProfileManager {
|
|||||||
): { domains: string[]; targetIps: string[] } {
|
): { domains: string[]; targetIps: string[] } {
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
const targetIps = new Set<string>();
|
const targetIps = new Set<string>();
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
// Collect all access specifiers from assigned profiles
|
// Collect all access specifiers from assigned profiles
|
||||||
for (const profileId of targetProfileIds) {
|
for (const profileId of targetProfileIds) {
|
||||||
@@ -247,7 +292,12 @@ export class TargetProfileManager {
|
|||||||
// Route references: scan all routes
|
// Route references: scan all routes
|
||||||
for (const [routeId, route] of allRoutes) {
|
for (const [routeId, route] of allRoutes) {
|
||||||
if (!route.enabled) continue;
|
if (!route.enabled) continue;
|
||||||
if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
|
if (this.routeMatchesProfile(
|
||||||
|
route.route as IDcRouterRouteConfig,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeNameIndex,
|
||||||
|
)) {
|
||||||
const routeDomains = (route.route.match as any)?.domains;
|
const routeDomains = (route.route.match as any)?.domains;
|
||||||
if (Array.isArray(routeDomains)) {
|
if (Array.isArray(routeDomains)) {
|
||||||
for (const d of routeDomains) {
|
for (const d of routeDomains) {
|
||||||
@@ -275,9 +325,16 @@ export class TargetProfileManager {
|
|||||||
route: IDcRouterRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
profile: ITargetProfile,
|
profile: ITargetProfile,
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
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';
|
return result !== 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,11 +351,17 @@ export class TargetProfileManager {
|
|||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
profile: ITargetProfile,
|
profile: ITargetProfile,
|
||||||
routeDomains: string[],
|
routeDomains: string[],
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||||
// 1. Route reference match → full access
|
// 1. Route reference match → full access
|
||||||
if (profile.routeRefs?.length) {
|
if (profile.routeRefs?.length) {
|
||||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
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
|
// 2. Domain match
|
||||||
@@ -362,6 +425,66 @@ export class TargetProfileManager {
|
|||||||
return false;
|
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
|
// Private: persistence
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export class VpnManager {
|
|||||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
private clients: Map<string, VpnClientDoc> = new Map();
|
private clients: Map<string, VpnClientDoc> = new Map();
|
||||||
private serverKeys?: VpnServerKeysDoc;
|
private serverKeys?: VpnServerKeysDoc;
|
||||||
|
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
|
||||||
constructor(config: IVpnManagerConfig) {
|
constructor(config: IVpnManagerConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -88,6 +90,7 @@ export class VpnManager {
|
|||||||
if (client.useHostIp) {
|
if (client.useHostIp) {
|
||||||
anyClientUsesHostIp = true;
|
anyClientUsesHostIp = true;
|
||||||
}
|
}
|
||||||
|
this.normalizeClientRoutingSettings(client);
|
||||||
const entry: plugins.smartvpn.IClientEntry = {
|
const entry: plugins.smartvpn.IClientEntry = {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
publicKey: client.noisePublicKey,
|
publicKey: client.noisePublicKey,
|
||||||
@@ -97,13 +100,12 @@ export class VpnManager {
|
|||||||
assignedIp: client.assignedIp,
|
assignedIp: client.assignedIp,
|
||||||
expiresAt: client.expiresAt,
|
expiresAt: client.expiresAt,
|
||||||
security: this.buildClientSecurity(client),
|
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);
|
clientEntries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,13 +114,15 @@ export class VpnManager {
|
|||||||
|
|
||||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
// 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
|
// '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') {
|
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||||
configuredMode = 'hybrid';
|
configuredMode = 'hybrid';
|
||||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||||
}
|
}
|
||||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||||
const isBridge = forwardingMode === 'bridge';
|
const isBridge = forwardingMode === 'bridge';
|
||||||
|
this.resolvedForwardingMode = forwardingMode;
|
||||||
|
this.forwardingModeOverride = undefined;
|
||||||
|
|
||||||
// Create and start VpnServer
|
// Create and start VpnServer
|
||||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||||
@@ -143,7 +147,7 @@ export class VpnManager {
|
|||||||
wgListenPort,
|
wgListenPort,
|
||||||
clients: clientEntries,
|
clients: clientEntries,
|
||||||
socketForwardProxyProtocol: !isBridge,
|
socketForwardProxyProtocol: !isBridge,
|
||||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||||
serverEndpoint: this.config.serverEndpoint
|
serverEndpoint: this.config.serverEndpoint
|
||||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -189,6 +193,7 @@ export class VpnManager {
|
|||||||
this.vpnServer.stop();
|
this.vpnServer.stop();
|
||||||
this.vpnServer = undefined;
|
this.vpnServer = undefined;
|
||||||
}
|
}
|
||||||
|
this.resolvedForwardingMode = undefined;
|
||||||
logger.log('info', 'VPN server stopped');
|
logger.log('info', 'VPN server stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,14 +218,38 @@ export class VpnManager {
|
|||||||
throw new Error('VPN server not running');
|
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({
|
const bundle = await this.vpnServer.createClient({
|
||||||
clientId: opts.clientId,
|
clientId: doc.clientId,
|
||||||
description: opts.description,
|
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
|
// Override AllowedIPs with per-client values based on target profiles
|
||||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
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(
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
/AllowedIPs\s*=\s*.+/,
|
/AllowedIPs\s*=\s*.+/,
|
||||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
@@ -228,40 +257,16 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist client entry (including WG private key for export/QR)
|
// Persist client entry (including WG private key for export/QR)
|
||||||
const doc = new VpnClientDoc();
|
|
||||||
doc.clientId = bundle.entry.clientId;
|
doc.clientId = bundle.entry.clientId;
|
||||||
doc.enabled = bundle.entry.enabled ?? true;
|
doc.enabled = bundle.entry.enabled ?? true;
|
||||||
doc.targetProfileIds = opts.targetProfileIds;
|
|
||||||
doc.description = bundle.entry.description;
|
doc.description = bundle.entry.description;
|
||||||
doc.assignedIp = bundle.entry.assignedIp;
|
doc.assignedIp = bundle.entry.assignedIp;
|
||||||
doc.noisePublicKey = bundle.entry.publicKey;
|
doc.noisePublicKey = bundle.entry.publicKey;
|
||||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
doc.createdAt = Date.now();
|
|
||||||
doc.updatedAt = Date.now();
|
doc.updatedAt = Date.now();
|
||||||
doc.expiresAt = bundle.entry.expiresAt;
|
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);
|
this.clients.set(doc.clientId, doc);
|
||||||
try {
|
try {
|
||||||
await this.persistClient(doc);
|
await this.persistClient(doc);
|
||||||
@@ -276,12 +281,6 @@ export class VpnManager {
|
|||||||
throw err;
|
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?.();
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
@@ -364,13 +363,13 @@ export class VpnManager {
|
|||||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||||
|
this.normalizeClientRoutingSettings(client);
|
||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
|
|
||||||
// Sync per-client security to the running daemon
|
|
||||||
if (this.vpnServer) {
|
if (this.vpnServer) {
|
||||||
const security = this.buildClientSecurity(client);
|
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||||
await this.vpnServer.updateClient(clientId, { security });
|
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.config.onClientChanged?.();
|
this.config.onClientChanged?.();
|
||||||
@@ -478,26 +477,28 @@ export class VpnManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build per-client security settings for the smartvpn daemon.
|
* 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 extend the effective allow-list.
|
||||||
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
|
||||||
*/
|
*/
|
||||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||||
const security: 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 || []) || [];
|
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||||
|
const mergedAllowList = this.mergeDestinationLists(
|
||||||
// Merge with per-client explicit allow list
|
basePolicy.allowList,
|
||||||
const mergedAllowList = [
|
client.destinationAllowList,
|
||||||
...(client.destinationAllowList || []),
|
profileDirectTargets,
|
||||||
...profileDirectTargets,
|
);
|
||||||
];
|
const mergedBlockList = this.mergeDestinationLists(
|
||||||
|
basePolicy.blockList,
|
||||||
|
client.destinationBlockList,
|
||||||
|
);
|
||||||
|
|
||||||
security.destinationPolicy = {
|
security.destinationPolicy = {
|
||||||
default: 'forceTarget' as const,
|
default: basePolicy.default,
|
||||||
target: '127.0.0.1',
|
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||||
blockList: client.destinationBlockList,
|
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return security;
|
return security;
|
||||||
@@ -510,10 +511,7 @@ export class VpnManager {
|
|||||||
public async refreshAllClientSecurity(): Promise<void> {
|
public async refreshAllClientSecurity(): Promise<void> {
|
||||||
if (!this.vpnServer) return;
|
if (!this.vpnServer) return;
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
const security = this.buildClientSecurity(client);
|
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||||
if (security.destinationPolicy) {
|
|
||||||
await this.vpnServer.updateClient(client.clientId, { security });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +548,7 @@ export class VpnManager {
|
|||||||
private async loadPersistedClients(): Promise<void> {
|
private async loadPersistedClients(): Promise<void> {
|
||||||
const docs = await VpnClientDoc.findAll();
|
const docs = await VpnClientDoc.findAll();
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
|
this.normalizeClientRoutingSettings(doc);
|
||||||
this.clients.set(doc.clientId, doc);
|
this.clients.set(doc.clientId, doc);
|
||||||
}
|
}
|
||||||
if (this.clients.size > 0) {
|
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> {
|
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||||
await client.save();
|
await client.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface ITargetProfile {
|
|||||||
domains?: string[];
|
domains?: string[];
|
||||||
/** Specific IP:port targets this profile grants access to */
|
/** Specific IP:port targets this profile grants access to */
|
||||||
targets?: ITargetProfileTarget[];
|
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[];
|
routeRefs?: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ export interface IMigrationRunner {
|
|||||||
run(): Promise<IMigrationRunResult>;
|
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.
|
* 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')
|
.step('rename-target-profile-host-to-ip')
|
||||||
.from('13.0.11').to('13.1.0')
|
.from('13.0.11').to('13.1.0')
|
||||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||||
.up(async (ctx) => {
|
.up(async (ctx) => migrateTargetProfileTargetHosts(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)`);
|
|
||||||
})
|
|
||||||
.step('rename-domain-source-manual-to-dcrouter')
|
.step('rename-domain-source-manual-to-dcrouter')
|
||||||
.from('13.1.0').to('13.8.1')
|
.from('13.1.0').to('13.8.1')
|
||||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||||
@@ -120,6 +128,12 @@ export async function createMigrationRunner(
|
|||||||
await db.collection('RouteOverrideDoc').drop();
|
await db.collection('RouteOverrideDoc').drop();
|
||||||
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
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;
|
return migration;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.17.3',
|
version: '13.17.5',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
||||||
: '-',
|
: '-',
|
||||||
'Route Refs': profile.routeRefs?.length
|
'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(),
|
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 routeState = appstate.routeManagementStatePart.getState();
|
||||||
const routes = routeState?.mergedRoutes || [];
|
const routes = routeState?.mergedRoutes || [];
|
||||||
return routes
|
return routes
|
||||||
.filter((mr) => mr.route.name)
|
.filter((mr) => mr.route.name && mr.id)
|
||||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
.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() {
|
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));
|
.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, {
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||||
name: String(data.name),
|
name: String(data.name),
|
||||||
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||||
const currentDomains = profile.domains || [];
|
const currentDomains = profile.domains || [];
|
||||||
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
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');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
await this.ensureRoutesLoaded();
|
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));
|
.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, {
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||||
id: profile.id,
|
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: 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;">
|
<div style="font-size: 14px; margin-top: 4px;">
|
||||||
${profile.routeRefs?.length
|
${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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
|
|||||||
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
||||||
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
||||||
const aclGroup = contentEl.querySelector('.aclGroup') 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 (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
||||||
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
||||||
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
||||||
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
if (!data.clientId) return;
|
if (!data.clientId) return;
|
||||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
targetProfileIds,
|
targetProfileIds,
|
||||||
|
|
||||||
useHostIp: useHostIp || undefined,
|
useHostIp,
|
||||||
useDhcp: useDhcp || undefined,
|
useDhcp,
|
||||||
staticIp,
|
staticIp,
|
||||||
forceVlan: forceVlan || undefined,
|
forceVlan,
|
||||||
vlanId,
|
vlanId,
|
||||||
destinationAllowList,
|
destinationAllowList,
|
||||||
destinationBlockList,
|
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">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">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>
|
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
||||||
${client.useHostIp ? html`
|
${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>
|
<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 client = actionData.item as interfaces.data.IVpnClient;
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
const currentDescription = client.description ?? '';
|
const currentDescription = client.description ?? '';
|
||||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||||
const profileCandidates = this.getTargetProfileCandidates();
|
const profileCandidates = this.getTargetProfileCandidates();
|
||||||
const currentUseHostIp = client.useHostIp ?? false;
|
const currentUseHostIp = client.useHostIp ?? false;
|
||||||
const currentUseDhcp = client.useDhcp ?? false;
|
const currentUseDhcp = client.useDhcp ?? false;
|
||||||
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
targetProfileIds,
|
targetProfileIds,
|
||||||
|
|
||||||
useHostIp: useHostIp || undefined,
|
useHostIp,
|
||||||
useDhcp: useDhcp || undefined,
|
useDhcp,
|
||||||
staticIp,
|
staticIp,
|
||||||
forceVlan: forceVlan || undefined,
|
forceVlan,
|
||||||
vlanId,
|
vlanId,
|
||||||
destinationAllowList,
|
destinationAllowList,
|
||||||
destinationBlockList,
|
destinationBlockList,
|
||||||
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build autocomplete candidates from loaded target profiles.
|
* Build stable profile labels for list inputs.
|
||||||
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
|
|
||||||
*/
|
*/
|
||||||
private getTargetProfileCandidates() {
|
private getTargetProfileChoices() {
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
const profileState = appstate.targetProfilesStatePart.getState();
|
||||||
const profiles = profileState?.profiles || [];
|
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;
|
if (!ids?.length) return undefined;
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
const choices = this.getTargetProfileChoices();
|
||||||
const profiles = profileState?.profiles || [];
|
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||||
return ids.map((id) => {
|
return ids.map((id) => {
|
||||||
const profile = profiles.find((p) => p.id === id);
|
return labelsById.get(id) || id;
|
||||||
return profile?.name || id;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert profile names back to IDs (for saving form data).
|
* Convert profile form labels back to IDs.
|
||||||
* Uses the dees-input-list candidates' payload when available.
|
|
||||||
*/
|
*/
|
||||||
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
|
private resolveProfileLabelsToIds(labels: string[]): string[] {
|
||||||
if (!names.length) return undefined;
|
if (!labels.length) return [];
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
|
||||||
const profiles = profileState?.profiles || [];
|
const labelsToIds = new Map(
|
||||||
return names
|
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
|
||||||
.map((name) => {
|
);
|
||||||
const profile = profiles.find((p) => p.name === name);
|
return labels
|
||||||
return profile?.id;
|
.map((label) => labelsToIds.get(label))
|
||||||
})
|
|
||||||
.filter((id): id is string => !!id);
|
.filter((id): id is string => !!id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user