Compare commits

..

5 Commits

Author SHA1 Message Date
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
10 changed files with 142 additions and 19 deletions

View File

@@ -1,5 +1,18 @@
# Changelog
## 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

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.0.6",
"version": "13.0.8",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {

View File

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

View File

@@ -2151,6 +2151,10 @@ export class DcRouter {
// Re-apply routes so profile-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();
},
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';
const ips = new Set<string>([subnet]);

View File

@@ -134,6 +134,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
// =========================================================================

View File

@@ -110,8 +110,9 @@ export class TargetProfileHandler {
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
});
// Re-apply routes to update VPN access
// Re-apply routes and refresh VPN client security to update access
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
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
// Re-apply routes and refresh VPN client security to update access
this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
}
return result;
},

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';
@@ -477,18 +480,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 +510,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> {

View File

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

View File

@@ -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({

View File

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