Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad45d1b8b9 | |||
| 68473f8550 | |||
| 07cfe76cac | |||
| 3775957bf2 | |||
| 31ce18a025 | |||
| 0cccec5526 | |||
| 0373f02f86 | |||
| 52dac0339f | |||
| b6f7f5f63f | |||
| 6271bb1079 | |||
| 0fa65f31c3 |
30
changelog.md
30
changelog.md
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-06 - 13.0.11 - fix(routing)
|
||||
serialize route updates and correct VPN-gated route application
|
||||
|
||||
- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites.
|
||||
- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely.
|
||||
- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently.
|
||||
- Reference resolution now expands network targets with multiple hosts into multiple route targets.
|
||||
- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI.
|
||||
|
||||
## 2026-04-06 - 13.0.10 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-06 - 13.0.9 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-06 - 13.0.8 - fix(ops-view-vpn)
|
||||
show target profile names in VPN forms and load profile candidates for autocomplete
|
||||
|
||||
- fetch target profiles when the VPN operations view connects so profile data is available in the UI
|
||||
- replace comma-separated target profile ID inputs with a restricted autocomplete list based on available target profiles
|
||||
- map stored target profile IDs to profile names for table and detail displays, while resolving selected names back to IDs on save
|
||||
|
||||
## 2026-04-06 - 13.0.7 - fix(vpn,target-profiles)
|
||||
refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists
|
||||
|
||||
- Adds direct target IP resolution from target profiles so forced SmartProxy clients can bypass rewriting for explicit profile targets.
|
||||
- Refreshes running VPN client security policies after target profile updates or deletions to keep destination access rules in sync.
|
||||
|
||||
## 2026-04-05 - 13.0.6 - fix(certificates)
|
||||
resolve base-domain certificate lookups and route profile list inputs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.0.6",
|
||||
"version": "13.0.11",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.1",
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.11.2",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -96,8 +96,8 @@ importers:
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.9
|
||||
'@push.rocks/smartvpn':
|
||||
specifier: 1.19.1
|
||||
version: 1.19.1
|
||||
specifier: 1.19.2
|
||||
version: 1.19.2
|
||||
'@push.rocks/taskbuffer':
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
@@ -1345,8 +1345,8 @@ packages:
|
||||
'@push.rocks/smartversion@3.0.5':
|
||||
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
||||
|
||||
'@push.rocks/smartvpn@1.19.1':
|
||||
resolution: {integrity: sha512-zvC/rrba1tZcXzzzrhX97BEUN6smo1KcqcULu6ZAGpDNhR7c5PU8oWwFxIy33UdDf5NLActkS0L3dq42sGB8nw==}
|
||||
'@push.rocks/smartvpn@1.19.2':
|
||||
resolution: {integrity: sha512-ygy7jnd4lfXmsHpdL0jS2k6bQAicSSoYcz7OzRpD0jQ970ghAnq2TgC3ccDl23YT9pt0QJPQLkGbVXN5+adQVg==}
|
||||
|
||||
'@push.rocks/smartwatch@6.4.0':
|
||||
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
|
||||
@@ -6723,7 +6723,7 @@ snapshots:
|
||||
'@types/semver': 7.7.1
|
||||
semver: 7.7.4
|
||||
|
||||
'@push.rocks/smartvpn@1.19.1':
|
||||
'@push.rocks/smartvpn@1.19.2':
|
||||
dependencies:
|
||||
'@push.rocks/smartnftables': 1.1.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.0.6',
|
||||
version: '13.0.11',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -431,7 +431,15 @@ export class DcRouter {
|
||||
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
|
||||
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
|
||||
// which calls certProvisionFunction again — now with smartAcmeReady === true.
|
||||
if (this.smartProxy) {
|
||||
if (this.routeConfigManager) {
|
||||
// Go through RouteConfigManager to get the full merged route set
|
||||
// and serialize via the route-update mutex (prevents stale overwrites)
|
||||
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
|
||||
this.routeConfigManager.applyRoutes().catch((err: any) => {
|
||||
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
|
||||
});
|
||||
} else if (this.smartProxy) {
|
||||
// No RouteConfigManager (DB disabled) — re-send current routes to trigger cert provisioning
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
}
|
||||
@@ -477,7 +485,8 @@ export class DcRouter {
|
||||
this.options.vpnConfig?.enabled
|
||||
? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
|
||||
if (!this.vpnManager || !this.targetProfileManager) {
|
||||
return [this.options.vpnConfig?.subnet || '10.8.0.0/24'];
|
||||
// VPN not ready yet — deny all until re-apply after VPN starts
|
||||
return [];
|
||||
}
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route, routeId, this.vpnManager.listClients(),
|
||||
@@ -2149,7 +2158,14 @@ export class DcRouter {
|
||||
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
|
||||
onClientChanged: () => {
|
||||
// Re-apply routes so profile-based ipAllowLists get updated
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
|
||||
this.routeConfigManager?.applyRoutes().catch((err) => {
|
||||
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
|
||||
});
|
||||
},
|
||||
getClientDirectTargets: (targetProfileIds: string[]) => {
|
||||
if (!this.targetProfileManager) return [];
|
||||
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
|
||||
},
|
||||
getClientAllowedIPs: async (targetProfileIds: string[]) => {
|
||||
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
|
||||
@@ -2187,7 +2203,7 @@ export class DcRouter {
|
||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// VPN server wasn't ready yet)
|
||||
this.routeConfigManager?.applyRoutes();
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||
@@ -2205,6 +2221,11 @@ export class DcRouter {
|
||||
const { promises: dnsPromises } = await import('dns');
|
||||
const ips = await dnsPromises.resolve4(domain);
|
||||
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
|
||||
// Evict oldest entries if cache exceeds 1000 entries
|
||||
if (this.vpnDomainIpCache.size > 1000) {
|
||||
const firstKey = this.vpnDomainIpCache.keys().next().value;
|
||||
if (firstKey) this.vpnDomainIpCache.delete(firstKey);
|
||||
}
|
||||
return ips;
|
||||
} catch (err) {
|
||||
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
|
||||
|
||||
@@ -308,14 +308,15 @@ export class ReferenceResolver {
|
||||
if (resolvedMetadata.networkTargetRef) {
|
||||
const target = this.targets.get(resolvedMetadata.networkTargetRef);
|
||||
if (target) {
|
||||
const hosts = Array.isArray(target.host) ? target.host : [target.host];
|
||||
route = {
|
||||
...route,
|
||||
action: {
|
||||
...route.action,
|
||||
targets: [{
|
||||
host: target.host as string,
|
||||
targets: hosts.map((h) => ({
|
||||
host: h,
|
||||
port: target.port,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
};
|
||||
resolvedMetadata.networkTargetName = target.name;
|
||||
|
||||
@@ -12,10 +12,41 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
/**
|
||||
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
||||
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
||||
*/
|
||||
class RouteUpdateMutex {
|
||||
private locked = false;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.queue.push(resolve);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.locked = false;
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
this.locked = true;
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
private routeUpdateMutex = new RouteUpdateMutex();
|
||||
|
||||
constructor(
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
@@ -357,57 +388,60 @@ export class RouteConfigManager {
|
||||
// =========================================================================
|
||||
|
||||
public async applyRoutes(): Promise<void> {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
await this.routeUpdateMutex.runExclusive(async () => {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
|
||||
// Helper: inject VPN security into a vpnOnly route
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnCallback) return route;
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const allowList = vpnCallback(dcRoute, routeId);
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: allowList,
|
||||
},
|
||||
// Helper: inject VPN security into a vpnOnly route
|
||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
||||
if (!vpnCallback) return route;
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
const vpnIps = vpnCallback(dcRoute, routeId);
|
||||
const existingIps = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingIps, ...vpnIps],
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ export class TargetProfileManager {
|
||||
routeRefs?: string[];
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
// Enforce unique profile names
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.name === data.name) {
|
||||
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -134,6 +141,27 @@ export class TargetProfileManager {
|
||||
.map((c) => ({ clientId: c.clientId, description: c.description }));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Direct target IPs (bypass SmartProxy)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* For a set of target profile IDs, collect all explicit target host IPs.
|
||||
* These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
|
||||
* connect to them directly through the tunnel.
|
||||
*/
|
||||
public getDirectTargetIps(targetProfileIds: string[]): string[] {
|
||||
const ips = new Set<string>();
|
||||
for (const profileId of targetProfileIds) {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile?.targets?.length) continue;
|
||||
for (const t of profile.targets) {
|
||||
ips.add(t.host);
|
||||
}
|
||||
}
|
||||
return [...ips];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Core matching: route → client IPs
|
||||
// =========================================================================
|
||||
|
||||
@@ -39,10 +39,6 @@ export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourcePro
|
||||
return await SourceProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<SourceProfileDoc | null> {
|
||||
return await SourceProfileDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||
return await SourceProfileDoc.getInstances({});
|
||||
}
|
||||
|
||||
@@ -42,10 +42,6 @@ export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetPro
|
||||
return await TargetProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<TargetProfileDoc | null> {
|
||||
return await TargetProfileDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<TargetProfileDoc[]> {
|
||||
return await TargetProfileDoc.getInstances({});
|
||||
}
|
||||
|
||||
@@ -67,15 +67,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByClientId(clientId: string): Promise<VpnClientDoc | null> {
|
||||
return await VpnClientDoc.getInstance({ clientId });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +110,9 @@ export class TargetProfileHandler {
|
||||
targets: dataArg.targets,
|
||||
routeRefs: dataArg.routeRefs,
|
||||
});
|
||||
// Re-apply routes to update VPN access
|
||||
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
// Re-apply routes and refresh VPN client security to update access
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
@@ -129,8 +130,9 @@ export class TargetProfileHandler {
|
||||
}
|
||||
const result = await manager.deleteProfile(dataArg.id, dataArg.force);
|
||||
if (result.success) {
|
||||
// Re-apply routes to update VPN access
|
||||
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
// Re-apply routes and refresh VPN client security to update access
|
||||
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
|
||||
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface IVpnManagerConfig {
|
||||
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
|
||||
* When not set, defaults to [subnet]. */
|
||||
getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
|
||||
/** Resolve per-client destination allow-list IPs from target profile IDs.
|
||||
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
|
||||
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
|
||||
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
|
||||
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
|
||||
forwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
@@ -264,7 +267,18 @@ export class VpnManager {
|
||||
doc.vlanId = opts.vlanId;
|
||||
}
|
||||
this.clients.set(doc.clientId, doc);
|
||||
await this.persistClient(doc);
|
||||
try {
|
||||
await this.persistClient(doc);
|
||||
} catch (err) {
|
||||
// Rollback: remove from in-memory map and daemon to stay consistent with DB
|
||||
this.clients.delete(doc.clientId);
|
||||
try {
|
||||
await this.vpnServer!.removeClient(doc.clientId);
|
||||
} catch {
|
||||
// best-effort daemon cleanup
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
const security = this.buildClientSecurity(doc);
|
||||
@@ -477,18 +491,28 @@ export class VpnManager {
|
||||
const security: plugins.smartvpn.IClientSecurity = {};
|
||||
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
||||
|
||||
// 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,
|
||||
];
|
||||
|
||||
if (!forceSmartproxy) {
|
||||
// Client traffic goes directly — not forced to SmartProxy
|
||||
security.destinationPolicy = {
|
||||
default: 'allow' as const,
|
||||
blockList: client.destinationBlockList,
|
||||
};
|
||||
} else if (client.destinationAllowList?.length || client.destinationBlockList?.length) {
|
||||
// Client is forced to SmartProxy, but with per-client allow/block overrides
|
||||
} else if (mergedAllowList.length || client.destinationBlockList?.length) {
|
||||
// Client is forced to SmartProxy, but with allow/block overrides
|
||||
// (includes TargetProfile direct targets that bypass SmartProxy)
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
allowList: client.destinationAllowList,
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: client.destinationBlockList,
|
||||
};
|
||||
}
|
||||
@@ -497,6 +521,20 @@ export class VpnManager {
|
||||
return security;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all client security policies against the running daemon.
|
||||
* Call this when TargetProfiles change so destination allow-lists stay in sync.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.0.6',
|
||||
version: '13.0.11',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -149,7 +149,8 @@ export class OpsViewSourceProfiles extends DeesElement {
|
||||
const data = await form.collectFormData();
|
||||
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
|
||||
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
|
||||
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
|
||||
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
|
||||
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
|
||||
|
||||
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
|
||||
name: String(data.name),
|
||||
@@ -190,7 +191,8 @@ export class OpsViewSourceProfiles extends DeesElement {
|
||||
const data = await form.collectFormData();
|
||||
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
|
||||
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
|
||||
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined;
|
||||
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
|
||||
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
|
||||
|
||||
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
|
||||
id: profile.id,
|
||||
|
||||
@@ -156,8 +156,16 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
||||
}
|
||||
|
||||
private async ensureRoutesLoaded() {
|
||||
const routeState = appstate.routeManagementStatePart.getState();
|
||||
if (!routeState?.mergedRoutes?.length) {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async showCreateProfileDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await this.ensureRoutesLoaded();
|
||||
const routeCandidates = this.getRouteCandidates();
|
||||
|
||||
DeesModal.createAndShow({
|
||||
@@ -216,6 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
const currentRouteRefs = profile.routeRefs || [];
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await this.ensureRoutesLoaded();
|
||||
const routeCandidates = this.getRouteCandidates();
|
||||
|
||||
DeesModal.createAndShow({
|
||||
|
||||
@@ -60,6 +60,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
|
||||
// Ensure target profiles are loaded for autocomplete candidates
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -328,7 +330,11 @@ export class OpsViewVpn extends DeesElement {
|
||||
'Routing': routingHtml,
|
||||
'VPN IP': client.assignedIp || '-',
|
||||
'Target Profiles': client.targetProfileIds?.length
|
||||
? html`${client.targetProfileIds.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
||||
? html`${client.targetProfileIds.map(id => {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profile = profileState?.profiles.find(p => p.id === id);
|
||||
return html`<span class="tagBadge">${profile?.name || id}</span>`;
|
||||
})}`
|
||||
: '-',
|
||||
'Description': client.description || '-',
|
||||
'Created': new Date(client.createdAt).toLocaleDateString(),
|
||||
@@ -341,13 +347,14 @@ export class OpsViewVpn extends DeesElement {
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const createModal = await DeesModal.createAndShow({
|
||||
heading: 'Create VPN Client',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'}></dees-input-text>
|
||||
<dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false}></dees-input-list>
|
||||
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox>
|
||||
<div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
|
||||
@@ -383,9 +390,9 @@ export class OpsViewVpn extends DeesElement {
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
if (!data.clientId) return;
|
||||
const targetProfileIds = data.targetProfileIds
|
||||
? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
// Apply conditional logic based on checkbox states
|
||||
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
|
||||
@@ -479,7 +486,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">${client.targetProfileIds?.join(', ') || '-'}</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">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</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>
|
||||
@@ -643,7 +650,8 @@ 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 currentTargetProfileIds = client.targetProfileIds?.join(', ') ?? '';
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
||||
const currentUseHostIp = client.useHostIp ?? false;
|
||||
const currentUseDhcp = client.useDhcp ?? false;
|
||||
@@ -659,7 +667,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
|
||||
<dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'} .value=${currentTargetProfileIds}></dees-input-text>
|
||||
<dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false} .value=${currentTargetProfileNames}></dees-input-list>
|
||||
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox>
|
||||
<div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
|
||||
@@ -690,9 +698,9 @@ export class OpsViewVpn extends DeesElement {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const targetProfileIds = data.targetProfileIds
|
||||
? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
// Apply conditional logic based on checkbox states
|
||||
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
|
||||
@@ -805,4 +813,43 @@ export class OpsViewVpn extends DeesElement {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build autocomplete candidates from loaded target profiles.
|
||||
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
|
||||
*/
|
||||
private getTargetProfileCandidates() {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert profile IDs to profile names (for populating edit form values).
|
||||
*/
|
||||
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
|
||||
if (!ids?.length) return undefined;
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
return ids.map((id) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
return profile?.name || id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert profile names back to IDs (for saving form data).
|
||||
* Uses the dees-input-list candidates' payload when available.
|
||||
*/
|
||||
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;
|
||||
})
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user