Compare commits

..

28 Commits

Author SHA1 Message Date
b28b5eea84 v13.1.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-07 22:28:22 +00:00
27d7489af9 fix(deps): bump catalog-related dependencies to newer patch and minor releases 2026-04-07 22:28:22 +00:00
940c7dc92e v13.1.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-07 21:02:37 +00:00
7fa6d82e58 feat(vpn,target-profiles,migrations): add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips 2026-04-07 21:02:37 +00:00
f29ed9757e fix(target-profile-manager): enhance domain matching to support bidirectional checks 2026-04-06 11:56:55 +00:00
ad45d1b8b9 v13.0.11
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 10:23:19 +00:00
68473f8550 fix(routing): serialize route updates and correct VPN-gated route application 2026-04-06 10:23:18 +00:00
07cfe76cac v13.0.10
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 08:08:23 +00:00
3775957bf2 fix(repo): no changes to commit 2026-04-06 08:08:23 +00:00
31ce18a025 v13.0.9
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 08:07:25 +00:00
0cccec5526 fix(repo): no changes to commit 2026-04-06 08:07:25 +00:00
0373f02f86 v13.0.8
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 08:05:07 +00:00
52dac0339f fix(ops-view-vpn): show target profile names in VPN forms and load profile candidates for autocomplete 2026-04-06 08:05:07 +00:00
b6f7f5f63f v13.0.7
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-06 07:51:25 +00:00
6271bb1079 fix(vpn,target-profiles): refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists 2026-04-06 07:51:25 +00:00
0fa65f31c3 fix(ops-view-targetprofiles): ensure routes are loaded before showing profile dialogs 2026-04-05 13:48:08 +00:00
93d6c7d341 v13.0.6
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-05 11:29:47 +00:00
b2ccd54079 fix(certificates): resolve base-domain certificate lookups and route profile list inputs 2026-04-05 11:29:47 +00:00
4e9b09616d v13.0.5
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-05 10:13:09 +00:00
ddb420835e fix(ts_web): replace custom section heading component with dees-heading across ops views 2026-04-05 10:13:09 +00:00
505fd044c0 v13.0.4
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-05 03:54:39 +00:00
7711204fef fix(deps): bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases 2026-04-05 03:54:39 +00:00
d7b6fbb241 v13.0.3
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-05 03:28:40 +00:00
a670b27a1c fix(deps): bump @push.rocks/smartdb to ^2.5.2 2026-04-05 03:28:40 +00:00
c2f57b086f v13.0.2
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-05 02:50:56 +00:00
083f16d7b4 fix(deps): bump smartdata, smartdb, and catalog dependencies 2026-04-05 02:50:56 +00:00
2994b6e686 v13.0.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-05 01:33:27 +00:00
ba15c169d7 fix(deps): bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies 2026-04-05 01:33:27 +00:00
41 changed files with 1969 additions and 1769 deletions

View File

@@ -1,5 +1,88 @@
# Changelog
## 2026-04-07 - 13.1.1 - fix(deps)
bump catalog-related dependencies to newer patch and minor releases
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips
- runs smartmigration at startup before configuration is loaded and adds a migration for target profile targets from host to ip
- changes VPN client routing to always force traffic through SmartProxy while allowing direct target bypasses from target profiles
- supports domain-scoped VPN ipAllowList entries for vpnOnly routes based on matching target profile domains
- updates certificate reprovisioning to reapply routes so renewed certificates are loaded into the running proxy
- removes the forceDestinationSmartproxy VPN client option from API, persistence, manager, and web UI
## 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
- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably.
- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling.
- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references.
## 2026-04-05 - 13.0.5 - fix(ts_web)
replace custom section heading component with dees-heading across ops views
- updates all operations dashboard views to use <dees-heading level="2"> for section titles
- removes the unused shared ops-sectionheading component export and source file
- bumps UI and data layer dependencies to compatible patch/minor releases
## 2026-04-05 - 13.0.4 - fix(deps)
bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases
- Updates @push.rocks/smartdata from ^7.1.4 to ^7.1.5
- Updates @push.rocks/smartdb from ^2.5.2 to ^2.5.4
## 2026-04-05 - 13.0.3 - fix(deps)
bump @push.rocks/smartdb to ^2.5.2
- Updates @push.rocks/smartdb from ^2.5.1 to ^2.5.2 in package.json.
## 2026-04-05 - 13.0.2 - fix(deps)
bump smartdata, smartdb, and catalog dependencies
- updates @push.rocks/smartdata from ^7.1.3 to ^7.1.4
- updates @push.rocks/smartdb from ^2.4.1 to ^2.5.1
- updates @serve.zone/catalog from ^2.11.1 to ^2.11.2
## 2026-04-05 - 13.0.1 - fix(deps)
bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies
- updates @design.estate/dees-catalog from ^3.55.6 to ^3.59.1
- updates @push.rocks/smartdb from ^2.3.1 to ^2.4.1
## 2026-04-05 - 13.0.0 - BREAKING CHANGE(vpn)
replace tag-based VPN access control with source and target profiles

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.0.0",
"version": "13.1.1",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -35,38 +35,39 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.55.6",
"@design.estate/dees-catalog": "^3.67.1",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.4.0",
"@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdb": "^2.3.1",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.6.2",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.1.1",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.4.0",
"@push.rocks/smartproxy": "^27.5.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@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.1",
"@serve.zone/catalog": "^2.12.0",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.2.7",
"lru-cache": "^11.3.2",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
},

2475
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.0.0',
version: '13.1.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -15,6 +15,9 @@ import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
// Import unified database
import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc } from './db/index.js';
// Import migration runner and app version
import { createMigrationRunner } from '../ts_migrations/index.js';
import { commitinfo } from './00_commitinfo_data.js';
import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js';
@@ -431,7 +434,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 +488,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(),
@@ -766,6 +778,19 @@ export class DcRouter {
await this.dcRouterDb.start();
// Run any pending data migrations before anything else reads from the DB.
// This must complete before ConfigManagers loads profiles.
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version);
const migrationResult = await migration.run();
if (migrationResult.stepsApplied.length > 0) {
logger.log('info',
`smartmigration: ${migrationResult.currentVersionBefore ?? 'fresh'}${migrationResult.currentVersionAfter} ` +
`(${migrationResult.stepsApplied.length} step(s) applied in ${migrationResult.totalDurationMs}ms)`,
);
} else if (migrationResult.wasFreshInstall) {
logger.log('info', `smartmigration: fresh install stamped to ${migrationResult.currentVersionAfter}`);
}
// Start the cache cleaner for TTL-based document cleanup
const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
@@ -1033,15 +1058,9 @@ export class DcRouter {
});
});
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
const routeNames = this.findRouteNamesForDomain(event.domain);
this.certificateStatusMap.set(event.domain, {
status: 'valid', routeNames,
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
source: event.source,
});
});
// Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'.
// Renewals come through 'certificate-issued' (with optional isRenewal? in the payload).
// The vestigial 'certificate-renewed' event from common-types.ts is never emitted.
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
@@ -1076,7 +1095,10 @@ export class DcRouter {
if (!expiryDate) {
try {
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
const certDoc = await AcmeCertDoc.findByDomain(cleanDomain);
const domParts = cleanDomain.split('.');
const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain;
const certDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (certDoc?.validUntil) {
expiryDate = new Date(certDoc.validUntil).toISOString();
}
@@ -2146,7 +2168,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';
@@ -2184,7 +2213,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. */
@@ -2202,6 +2231,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}`);

View File

@@ -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;

View File

@@ -12,16 +12,50 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] };
/**
* 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[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => string[],
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
) {}
@@ -357,57 +391,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 vpnEntries = vpnCallback(dcRoute, routeId);
const existingEntries = route.security?.ipAllowList || [];
return {
...route,
security: {
...route.security,
ipAllowList: [...existingEntries, ...vpnEntries],
},
};
};
};
// 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)`);
});
}
}

View File

@@ -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,38 +141,77 @@ 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 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.ip);
}
}
return [...ips];
}
// =========================================================================
// Core matching: route → client IPs
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns their assigned IPs for injection into ipAllowList.
* matches the route. Returns IP allow entries for injection into ipAllowList.
*
* Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains.
*/
public getMatchingClientIps(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
): string[] {
const ips: string[] = [];
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
if (!client.targetProfileIds?.length) continue;
// Check if any of the client's profiles match this route
const matches = client.targetProfileIds.some((profileId) => {
const profile = this.profiles.get(profileId);
if (!profile) return false;
return this.routeMatchesProfile(route, routeId, profile);
});
// Collect scoped domains from all matching profiles for this client
let fullAccess = false;
const scopedDomains = new Set<string>();
if (matches) {
ips.push(client.assignedIp);
for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
if (matchResult === 'full') {
fullAccess = true;
break; // No need to check more profiles
}
if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d);
}
}
if (fullAccess) {
entries.push(client.assignedIp);
} else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
}
}
return ips;
return entries;
}
/**
@@ -195,7 +241,7 @@ export class TargetProfileManager {
// Direct target IP entries
if (profile.targets?.length) {
for (const t of profile.targets) {
targetIps.add(t.host);
targetIps.add(t.ip);
}
}
@@ -236,33 +282,67 @@ export class TargetProfileManager {
// =========================================================================
/**
* Check if a route matches a profile. A profile matches if ANY condition is true:
* 1. Profile's routeRefs contains the route's name or stored route id
* 2. Profile's domains overlaps with route.match.domains (wildcard matching)
* 3. Profile's targets overlaps with route.action.targets (host + port match)
* Check if a route matches a profile (boolean convenience wrapper).
*/
private routeMatchesProfile(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
): boolean {
// 1. Route reference match
const routeDomains: string[] = (route.match as any)?.domains || [];
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
return result !== 'none';
}
/**
* Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
* or 'none' (no match).
*
* - routeRefs / target matches → 'full' (explicit reference = full access)
* - domain match where profile domains are a subset of route wildcard → 'scoped'
* - domain match where domains are identical or profile is a wildcard → 'full'
*/
private routeMatchesProfileDetailed(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
routeDomains: string[],
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
// 1. Route reference match → full access
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return true;
if (route.name && profile.routeRefs.includes(route.name)) return true;
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
}
// 2. Domain match
if (profile.domains?.length) {
const routeDomains: string[] = (route.match as any)?.domains || [];
if (profile.domains?.length && routeDomains.length) {
const matchedProfileDomains: string[] = [];
for (const profileDomain of profile.domains) {
for (const routeDomain of routeDomains) {
if (this.domainMatchesPattern(routeDomain, profileDomain)) return true;
if (this.domainMatchesPattern(routeDomain, profileDomain) ||
this.domainMatchesPattern(profileDomain, routeDomain)) {
matchedProfileDomains.push(profileDomain);
break; // This profileDomain matched, move to the next
}
}
}
if (matchedProfileDomains.length > 0) {
// Check if profile domains cover the route entirely (same wildcards = full access)
const isFullCoverage = routeDomains.every((rd) =>
matchedProfileDomains.some((pd) =>
rd === pd || this.domainMatchesPattern(rd, pd),
),
);
if (isFullCoverage) return 'full';
// Profile domains are a subset → scoped access to those specific domains
return { type: 'scoped', domains: matchedProfileDomains };
}
}
// 3. Target match (host + port)
// 3. Target match (host + port) → full access (precise by nature)
if (profile.targets?.length) {
const routeTargets = (route.action as any)?.targets;
if (Array.isArray(routeTargets)) {
@@ -270,15 +350,15 @@ export class TargetProfileManager {
for (const routeTarget of routeTargets) {
const routeHost = routeTarget.host;
const routePort = routeTarget.port;
if (routeHost === profileTarget.host && routePort === profileTarget.port) {
return true;
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
return 'full';
}
}
}
}
}
return false;
return 'none';
}
/**

View File

@@ -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({});
}

View File

@@ -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({});
}

View File

@@ -39,9 +39,6 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
@plugins.smartdata.svDb()
public expiresAt?: string;
@plugins.smartdata.svDb()
public forceDestinationSmartproxy: boolean = true;
@plugins.smartdata.svDb()
public destinationAllowList?: string[];
@@ -67,15 +64,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 });
}
}

View File

@@ -191,7 +191,11 @@ export class CertificateHandler {
// Check persisted cert data from smartdata document classes
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
// SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
if (acmeDoc?.validUntil) {
@@ -291,7 +295,12 @@ export class CertificateHandler {
}
/**
* Legacy route-based reprovisioning
* Legacy route-based reprovisioning. Kept for backward compatibility with
* older clients that send `reprovisionCertificate` typed-requests.
*
* Like reprovisionCertificateDomain, this triggers the full route apply
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
*/
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
@@ -301,13 +310,19 @@ export class CertificateHandler {
return { success: false, message: 'SmartProxy is not running' };
}
// Clear event-based status for domains in this route so the
// certificate-issued event can refresh them
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
try {
await smartProxy.provisionCertificate(routeName);
// Clear event-based status for domains in this route
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err: unknown) {
@@ -316,7 +331,16 @@ export class CertificateHandler {
}
/**
* Domain-based reprovisioning — clears backoff first, then triggers provision
* Domain-based reprovisioning — clears backoff first, refreshes the smartacme
* cert (when forceRenew is set), then re-applies routes so the running Rust
* proxy actually picks up the new cert.
*
* Why applyRoutes (not smartProxy.provisionCertificate)?
* smartProxy.provisionCertificate(routeName) routes through the Rust ACME
* path, which is forcibly disabled whenever certProvisionFunction is set
* (smart-proxy.ts:168-171). The only path that re-invokes
* certProvisionFunction → bridge.loadCertificate is updateRoutes(), which
* we trigger via routeConfigManager.applyRoutes().
*/
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
@@ -331,31 +355,41 @@ export class CertificateHandler {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
// Clear status map entry so it gets refreshed
// Find routes matching this domain — fail early if none exist
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length === 0) {
return { success: false, message: `No routes found for domain '${domain}'` };
}
// If forceRenew, order a fresh cert from ACME now so it's already in
// AcmeCertDoc by the time certProvisionFunction is invoked below.
if (forceRenew && dcRouter.smartAcme) {
try {
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true });
} catch (err: unknown) {
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
}
}
// Clear status map entry so it gets refreshed by the certificate-issued event
dcRouter.certificateStatusMap.delete(domain);
// Try to provision via SmartAcme directly
if (dcRouter.smartAcme) {
try {
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: forceRenew ?? false });
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
// Trigger the full route apply pipeline:
// applyRoutes → updateRoutes → provisionCertificatesViaCallback →
// certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
// bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
// certificate-issued event → certificateStatusMap updated
try {
if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
// Fallback when DB is disabled and there is no RouteConfigManager
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
// Fallback: try provisioning via the first matching route
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length > 0) {
try {
await smartProxy.provisionCertificate(routeNames[0]);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
return { success: false, message: `No routes found for domain '${domain}'` };
}
/**
@@ -364,9 +398,12 @@ export class CertificateHandler {
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
// Delete from smartdata document classes
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
// Delete from smartdata document classes (try base domain first, then exact)
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (acmeDoc) {
await acmeDoc.delete();
}

View File

@@ -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;
},

View File

@@ -31,7 +31,6 @@ export class VpnHandler {
createdAt: c.createdAt,
updatedAt: c.updatedAt,
expiresAt: c.expiresAt,
forceDestinationSmartproxy: c.forceDestinationSmartproxy ?? true,
destinationAllowList: c.destinationAllowList,
destinationBlockList: c.destinationBlockList,
useHostIp: c.useHostIp,
@@ -122,7 +121,6 @@ export class VpnHandler {
clientId: dataArg.clientId,
targetProfileIds: dataArg.targetProfileIds,
description: dataArg.description,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,
@@ -148,7 +146,6 @@ export class VpnHandler {
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt,
forceDestinationSmartproxy: persistedClient?.forceDestinationSmartproxy ?? true,
destinationAllowList: persistedClient?.destinationAllowList,
destinationBlockList: persistedClient?.destinationBlockList,
useHostIp: persistedClient?.useHostIp,
@@ -180,7 +177,6 @@ export class VpnHandler {
await manager.updateClient(dataArg.clientId, {
description: dataArg.description,
targetProfileIds: dataArg.targetProfileIds,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,

View File

@@ -1,3 +1,3 @@
{
"order": 2
"order": 3
}

View File

@@ -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';
@@ -198,7 +201,6 @@ export class VpnManager {
clientId: string;
targetProfileIds?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
@@ -239,9 +241,6 @@ export class VpnManager {
doc.createdAt = Date.now();
doc.updatedAt = Date.now();
doc.expiresAt = bundle.entry.expiresAt;
if (opts.forceDestinationSmartproxy !== undefined) {
doc.forceDestinationSmartproxy = opts.forceDestinationSmartproxy;
}
if (opts.destinationAllowList !== undefined) {
doc.destinationAllowList = opts.destinationAllowList;
}
@@ -264,7 +263,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);
@@ -335,7 +345,6 @@ export class VpnManager {
public async updateClient(clientId: string, update: {
description?: string;
targetProfileIds?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
@@ -348,7 +357,6 @@ export class VpnManager {
if (!client) throw new Error(`Client not found: ${clientId}`);
if (update.description !== undefined) client.description = update.description;
if (update.targetProfileIds !== undefined) client.targetProfileIds = update.targetProfileIds;
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
@@ -470,33 +478,45 @@ export class VpnManager {
/**
* Build per-client security settings for the smartvpn daemon.
* Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists)
* to smartvpn's IClientSecurity with a destinationPolicy.
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
*/
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
const security: plugins.smartvpn.IClientSecurity = {};
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
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
security.destinationPolicy = {
default: 'forceTarget' as const,
target: '127.0.0.1',
allowList: client.destinationAllowList,
blockList: client.destinationBlockList,
};
}
// else: no per-client policy, server-wide applies
// 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,
];
security.destinationPolicy = {
default: 'forceTarget' as const,
target: '127.0.0.1',
allowList: mergedAllowList.length ? mergedAllowList : undefined,
blockList: client.destinationBlockList,
};
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> {

View File

@@ -1,3 +1,3 @@
{
"order": 4
"order": 5
}

View File

@@ -2,7 +2,7 @@
* A specific IP:port target within a TargetProfile.
*/
export interface ITargetProfileTarget {
host: string;
ip: string;
port: number;
}

View File

@@ -11,7 +11,6 @@ export interface IVpnClient {
createdAt: number;
updatedAt: number;
expiresAt?: string;
forceDestinationSmartproxy: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;

View File

@@ -51,7 +51,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
clientId: string;
targetProfileIds?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
@@ -82,7 +82,7 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
clientId: string;
description?: string;
targetProfileIds?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;

70
ts_migrations/index.ts Normal file
View File

@@ -0,0 +1,70 @@
/// <reference types="node" />
/**
* dcrouter migration runner.
*
* Uses @push.rocks/smartmigration via dynamic import so smartmigration's type
* chain (which pulls in mongodb 7.x and related types) doesn't leak into
* compile-time type checking for this folder.
*/
/** Matches the subset of IMigrationRunResult we actually log. */
export interface IMigrationRunResult {
stepsApplied: Array<unknown>;
wasFreshInstall: boolean;
currentVersionBefore: string | null;
currentVersionAfter: string;
totalDurationMs: number;
}
export interface IMigrationRunner {
run(): Promise<IMigrationRunResult>;
}
/**
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
*
* Call `.run()` on the returned instance at startup (after DcRouterDb is ready,
* before any service that reads migrated collections).
*
* @param db - The initialized SmartdataDb instance from DcRouterDb.getDb()
* @param targetVersion - The current app version (from commitinfo.version)
*/
export async function createMigrationRunner(
db: unknown,
targetVersion: string,
): Promise<IMigrationRunner> {
const sm = await import('@push.rocks/smartmigration');
const migration = new sm.SmartMigration({
targetVersion,
db: db as any,
// Brand-new installs skip all migrations and stamp directly to the current version.
freshInstallVersion: targetVersion,
});
// Register steps in execution order. Each step's .from() must match the
// previous step's .to() to form a contiguous chain.
migration
.step('rename-target-profile-host-to-ip')
.from('13.0.11').to('13.1.0')
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
.up(async (ctx) => {
const collection = ctx.mongo!.collection('targetprofiledoc');
const cursor = collection.find({ 'targets.host': { $exists: true } });
let migrated = 0;
for await (const doc of cursor) {
const targets = ((doc as any).targets || []).map((t: any) => {
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
const { host, ...rest } = t;
return { ...rest, ip: host };
}
return t;
});
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
migrated++;
}
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
});
return migration;
}

View File

@@ -0,0 +1,3 @@
{
"order": 2
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.0.0',
version: '13.1.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -1015,7 +1015,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string;
targetProfileIds?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
@@ -1037,7 +1037,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
clientId: dataArg.clientId,
targetProfileIds: dataArg.targetProfileIds,
description: dataArg.description,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,
@@ -1113,7 +1113,7 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
clientId: string;
description?: string;
targetProfileIds?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
useHostIp?: boolean;
@@ -1135,7 +1135,7 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
clientId: dataArg.clientId,
description: dataArg.description,
targetProfileIds: dataArg.targetProfileIds,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp,
@@ -1223,7 +1223,7 @@ export const createTargetProfileAction = targetProfilesStatePart.createAction<{
name: string;
description?: string;
domains?: string[];
targets?: Array<{ host: string; port: number }>;
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
@@ -1259,7 +1259,7 @@ export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
name?: string;
description?: string;
domains?: string[];
targets?: Array<{ host: string; port: number }>;
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();

View File

@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
const { apiTokens } = this.routeState;
return html`
<ops-sectionheading>API Tokens</ops-sectionheading>
<dees-heading level="2">API Tokens</dees-heading>
<div class="apiTokensContainer">
<dees-table

View File

@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
const { summary } = this.certState;
return html`
<ops-sectionheading>Certificates</ops-sectionheading>
<dees-heading level="2">Certificates</dees-heading>
<div class="certificatesContainer">
${this.renderStatsTiles(summary)}

View File

@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
public render() {
return html`
<ops-sectionheading>Configuration</ops-sectionheading>
<dees-heading level="2">Configuration</dees-heading>
${this.configState.isLoading
? html`

View File

@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
public render() {
return html`
<ops-sectionheading>Email Operations</ops-sectionheading>
<dees-heading level="2">Email Operations</dees-heading>
<div class="viewContainer">
${this.currentView === 'detail' && this.selectedEmail
? html`

View File

@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
public render() {
return html`
<ops-sectionheading>Logs</ops-sectionheading>
<dees-heading level="2">Logs</dees-heading>
<dees-chart-log
.label=${'Application Logs'}

View File

@@ -285,7 +285,7 @@ export class OpsViewNetwork extends DeesElement {
public render() {
return html`
<ops-sectionheading>Network Activity</ops-sectionheading>
<dees-heading level="2">Network Activity</dees-heading>
<div class="networkContainer">
<!-- Stats Grid -->

View File

@@ -64,7 +64,7 @@ export class OpsViewNetworkTargets extends DeesElement {
];
return html`
<ops-sectionheading>Network Targets</ops-sectionheading>
<dees-heading level="2">Network Targets</dees-heading>
<div class="targetsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table

View File

@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
public render() {
return html`
<ops-sectionheading>Overview</ops-sectionheading>
<dees-heading level="2">Overview</dees-heading>
${this.statsState.isLoading ? html`
<div class="loadingMessage">

View File

@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
];
return html`
<ops-sectionheading>Remote Ingress</ops-sectionheading>
<dees-heading level="2">Remote Ingress</dees-heading>
${this.riState.newEdgeId ? html`
<div class="secretDialog">

View File

@@ -200,7 +200,7 @@ export class OpsViewRoutes extends DeesElement {
});
return html`
<ops-sectionheading>Route Management</ops-sectionheading>
<dees-heading level="2">Route Management</dees-heading>
<div class="routesContainer">
<dees-statsgrid

View File

@@ -30,6 +30,20 @@ export class OpsViewSecurity extends DeesElement {
@state()
accessor selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview';
private tabLabelMap: Record<string, string> = {
'overview': 'Overview',
'blocked': 'Blocked IPs',
'authentication': 'Authentication',
'email-security': 'Email Security',
};
private labelToTab: Record<string, 'overview' | 'blocked' | 'authentication' | 'email-security'> = {
'Overview': 'overview',
'Blocked IPs': 'blocked',
'Authentication': 'authentication',
'Email Security': 'email-security',
};
constructor() {
super();
const subscription = appstate.statsStatePart
@@ -40,35 +54,23 @@ export class OpsViewSecurity extends DeesElement {
this.rxSubscriptions.push(subscription);
}
async firstUpdated() {
const toggle = this.shadowRoot!.querySelector('dees-input-multitoggle') as any;
if (toggle) {
const sub = toggle.changeSubject.subscribe(() => {
const tab = this.labelToTab[toggle.selectedOption];
if (tab) this.selectedTab = tab;
});
this.rxSubscriptions.push(sub);
}
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css`
.tabs {
display: flex;
gap: 8px;
dees-input-multitoggle {
margin-bottom: 24px;
border-bottom: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 16px;
color: ${cssManager.bdTheme('#666', '#999')};
transition: all 0.2s ease;
}
.tab:hover {
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.tab.active {
color: ${cssManager.bdTheme('#2196F3', '#4a90e2')};
border-bottom-color: ${cssManager.bdTheme('#2196F3', '#4a90e2')};
}
h2 {
@@ -91,135 +93,22 @@ export class OpsViewSecurity extends DeesElement {
overflow: hidden;
}
.securityCard.alert {
border-color: ${cssManager.bdTheme('#f44336', '#ff6666')};
background: ${cssManager.bdTheme('#ffebee', '#4a1f1f')};
}
.securityCard.warning {
border-color: ${cssManager.bdTheme('#ff9800', '#ffaa33')};
background: ${cssManager.bdTheme('#fff3e0', '#4a3a1f')};
}
.securityCard.success {
border-color: ${cssManager.bdTheme('#4caf50', '#66cc66')};
background: ${cssManager.bdTheme('#e8f5e9', '#1f3f1f')};
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.cardTitle {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.cardStatus {
font-size: 14px;
padding: 4px 12px;
border-radius: 16px;
font-weight: 500;
}
.status-critical {
background: ${cssManager.bdTheme('#f44336', '#ff6666')};
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.status-warning {
background: ${cssManager.bdTheme('#ff9800', '#ffaa33')};
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.status-good {
background: ${cssManager.bdTheme('#4caf50', '#66cc66')};
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.metricValue {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
}
.metricLabel {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.actionButton {
margin-top: 16px;
}
.blockedIpList {
max-height: 400px;
overflow-y: auto;
}
.blockedIpItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.blockedIpItem:last-child {
border-bottom: none;
}
.ipAddress {
font-family: 'Consolas', 'Monaco', monospace;
font-weight: 600;
}
.blockReason {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.blockTime {
font-size: 12px;
color: ${cssManager.bdTheme('#999', '#666')};
}
`,
];
public render() {
return html`
<ops-sectionheading>Security</ops-sectionheading>
<div class="tabs">
<button
class="tab ${this.selectedTab === 'overview' ? 'active' : ''}"
@click=${() => this.selectedTab = 'overview'}
>
Overview
</button>
<button
class="tab ${this.selectedTab === 'blocked' ? 'active' : ''}"
@click=${() => this.selectedTab = 'blocked'}
>
Blocked IPs
</button>
<button
class="tab ${this.selectedTab === 'authentication' ? 'active' : ''}"
@click=${() => this.selectedTab = 'authentication'}
>
Authentication
</button>
<button
class="tab ${this.selectedTab === 'email-security' ? 'active' : ''}"
@click=${() => this.selectedTab = 'email-security'}
>
Email Security
</button>
</div>
<dees-heading level="2">Security</dees-heading>
<dees-input-multitoggle
.type=${'single'}
.options=${['Overview', 'Blocked IPs', 'Authentication', 'Email Security']}
.selectedOption=${this.tabLabelMap[this.selectedTab]}
></dees-input-multitoggle>
${this.renderTabContent()}
`;
@@ -328,32 +217,53 @@ export class OpsViewSecurity extends DeesElement {
}
private renderBlockedIPs(metrics: any) {
const blockedIPs: string[] = metrics.blockedIPs || [];
const tiles: IStatsTile[] = [
{
id: 'totalBlocked',
title: 'Blocked IPs',
value: blockedIPs.length,
type: 'number',
icon: 'lucide:ShieldBan',
color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e',
description: 'Currently blocked addresses',
},
];
return html`
<div class="securityCard">
<div class="cardHeader">
<h3 class="cardTitle">Blocked IP Addresses</h3>
<dees-button @click=${() => this.clearBlockedIPs()}>
Clear All
</dees-button>
</div>
<div class="blockedIpList">
${metrics.blockedIPs && metrics.blockedIPs.length > 0 ? metrics.blockedIPs.map((ipAddress, index) => html`
<div class="blockedIpItem">
<div>
<div class="ipAddress">${ipAddress}</div>
<div class="blockReason">Suspicious activity</div>
<div class="blockTime">Blocked</div>
</div>
<dees-button @click=${() => this.unblockIP(ipAddress)}>
Unblock
</dees-button>
</div>
`) : html`
<p>No blocked IPs</p>
`}
</div>
</div>
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<dees-table
.heading1=${'Blocked IP Addresses'}
.heading2=${'IPs blocked due to suspicious activity'}
.data=${blockedIPs.map((ip) => ({ ip }))}
.displayFunction=${(item) => ({
'IP Address': item.ip,
'Reason': 'Suspicious activity',
})}
.dataActions=${[
{
name: 'Unblock',
iconName: 'lucide:shield-off',
type: ['contextmenu' as const],
actionFunc: async (item) => {
await this.unblockIP(item.ip);
},
},
{
name: 'Clear All',
iconName: 'lucide:trash-2',
type: ['header' as const],
actionFunc: async () => {
await this.clearBlockedIPs();
},
},
]}
></dees-table>
`;
}

View File

@@ -64,7 +64,7 @@ export class OpsViewSourceProfiles extends DeesElement {
];
return html`
<ops-sectionheading>Source Profiles</ops-sectionheading>
<dees-heading level="2">Source Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
@@ -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,

View File

@@ -77,7 +77,7 @@ export class OpsViewTargetProfiles extends DeesElement {
];
return html`
<ops-sectionheading>Target Profiles</ops-sectionheading>
<dees-heading level="2">Target Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
@@ -91,7 +91,7 @@ export class OpsViewTargetProfiles extends DeesElement {
? html`${profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)}`
: '-',
Targets: profile.targets?.length
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)}`
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
: '-',
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
@@ -148,17 +148,35 @@ export class OpsViewTargetProfiles extends DeesElement {
`;
}
private getRouteCandidates() {
const routeState = appstate.routeManagementStatePart.getState();
const routes = routeState?.mergedRoutes || [];
return routes
.filter((mr) => mr.route.name)
.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({
heading: 'Create Target Profile',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} ></dees-input-text>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'}></dees-input-text>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</dees-form>
`,
menuOptions: [
@@ -172,30 +190,26 @@ export class OpsViewTargetProfiles extends DeesElement {
const data = await form.collectFormData();
if (!data.name) return;
const domains = data.domains
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
const targets = data.targets
? String(data.targets).split(',').map((s: string) => {
const trimmed = s.trim();
const lastColon = trimmed.lastIndexOf(':');
if (lastColon === -1) return null;
return {
host: trimmed.substring(0, lastColon),
port: parseInt(trimmed.substring(lastColon + 1), 10),
};
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
: undefined;
const routeRefs = data.routeRefs
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
const targets = targetStrings
.map((s: string) => {
const lastColon = s.lastIndexOf(':');
if (lastColon === -1) return null;
return {
ip: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains,
targets,
routeRefs,
domains: domains.length > 0 ? domains : undefined,
targets: targets.length > 0 ? targets : undefined,
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
});
modalArg.destroy();
},
@@ -205,20 +219,23 @@ export class OpsViewTargetProfiles extends DeesElement {
}
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains?.join(', ') ?? '';
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? '';
const currentRouteRefs = profile.routeRefs?.join(', ') ?? '';
const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
const currentRouteRefs = profile.routeRefs || [];
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
const routeCandidates = this.getRouteCandidates();
DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} .value=${currentDomains}></dees-input-text>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'} .value=${currentTargets}></dees-input-text>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'} .value=${currentRouteRefs}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</dees-form>
`,
menuOptions: [
@@ -231,24 +248,19 @@ export class OpsViewTargetProfiles extends DeesElement {
if (!form) return;
const data = await form.collectFormData();
const domains = data.domains
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const targets = data.targets
? String(data.targets).split(',').map((s: string) => {
const trimmed = s.trim();
if (!trimmed) return null;
const lastColon = trimmed.lastIndexOf(':');
if (lastColon === -1) return null;
return {
host: trimmed.substring(0, lastColon),
port: parseInt(trimmed.substring(lastColon + 1), 10),
};
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
: [];
const routeRefs = data.routeRefs
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
const targets = targetStrings
.map((s: string) => {
const lastColon = s.lastIndexOf(':');
if (lastColon === -1) return null;
return {
ip: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,
@@ -315,7 +327,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')};">Targets</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.targets?.length
? profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)
? profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)
: '-'}
</div>
</div>

View File

@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
if (hostIpGroup) hostIpGroup.style.display = data.forceDestinationSmartproxy ? 'none' : show;
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
@@ -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 = [
@@ -221,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
];
return html`
<ops-sectionheading>VPN</ops-sectionheading>
<dees-heading level="2">VPN</dees-heading>
<div class="vpnContainer">
${this.vpnState.newClientConfig ? html`
@@ -315,9 +317,7 @@ export class OpsViewVpn extends DeesElement {
statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
}
let routingHtml;
if (client.forceDestinationSmartproxy !== false) {
routingHtml = html`<span class="statusBadge enabled">SmartProxy</span>`;
} else if (client.useHostIp) {
if (client.useHostIp) {
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#f3e8ff', '#3b0764')}; color: ${cssManager.bdTheme('#7c3aed', '#c084fc')};">Host IP</span>`;
} else {
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">Direct</span>`;
@@ -328,7 +328,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,15 +345,15 @@ 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-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-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false}></dees-input-list>
<div class="hostIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
<div class="hostIpDetails" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${false}></dees-input-checkbox>
@@ -383,13 +387,12 @@ 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;
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
const useHostIp = data.useHostIp ?? false;
const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false);
@@ -407,7 +410,7 @@ export class OpsViewVpn extends DeesElement {
clientId: data.clientId,
description: data.description || undefined,
targetProfileIds,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
staticIp,
@@ -479,8 +482,8 @@ 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">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</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.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
${client.useHostIp ? html`
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
<div class="infoItem"><span class="infoLabel">VLAN</span><span class="infoValue">${client.forceVlan && client.vlanId != null ? `VLAN ${client.vlanId}` : 'No VLAN'}</span></div>
@@ -643,8 +646,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 currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
const profileCandidates = this.getTargetProfileCandidates();
const currentUseHostIp = client.useHostIp ?? false;
const currentUseDhcp = client.useDhcp ?? false;
const currentStaticIp = client.staticIp ?? '';
@@ -659,9 +662,8 @@ 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-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-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false} .value=${currentTargetProfileNames}></dees-input-list>
<div class="hostIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
<div class="hostIpDetails" style="display: ${currentUseHostIp ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${currentUseDhcp}></dees-input-checkbox>
@@ -690,13 +692,12 @@ 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;
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
const useHostIp = data.useHostIp ?? false;
const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false);
@@ -714,7 +715,7 @@ export class OpsViewVpn extends DeesElement {
clientId: client.clientId,
description: data.description || undefined,
targetProfileIds,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
staticIp,
@@ -805,4 +806,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);
}
}

View File

@@ -1,2 +1 @@
export * from './css.js';
export * from './ops-sectionheading.js';
export * from './css.js';

View File

@@ -1,38 +0,0 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
type TemplateResult
} from '@design.estate/dees-element';
@customElement('ops-sectionheading')
export class OpsSectionHeading extends DeesElement {
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
margin-bottom: 24px;
}
.heading {
font-family: 'Cal Sans', 'Inter', sans-serif;
font-size: 28px;
font-weight: 600;
color: ${cssManager.bdTheme('#111', '#fff')};
margin: 0;
padding: 0;
}
`,
];
public render(): TemplateResult {
return html`
<h1 class="heading">
<slot></slot>
</h1>
`;
}
}

View File

@@ -1,3 +1,3 @@
{
"order": 3
"order": 4
}