Compare commits

..

4 Commits

Author SHA1 Message Date
6fa7206f86 v11.17.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-03-30 16:49:58 +00:00
11cce23e21 feat(vpn): expand VPN operations view with client management and config export actions 2026-03-30 16:49:58 +00:00
d109554134 v11.16.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-03-30 13:06:14 +00:00
cc3a7cb5b6 feat(vpn): add destination-based VPN routing policy and standardize socket proxy forwarding 2026-03-30 13:06:14 +00:00
11 changed files with 192 additions and 84 deletions

View File

@@ -1,5 +1,20 @@
# Changelog
## 2026-03-30 - 11.17.0 - feat(vpn)
expand VPN operations view with client management and config export actions
- adds predefined VPN clients to the dev server configuration for local testing
- adds table actions to create clients, export WireGuard configs, rotate client keys, toggle access, and delete clients
- updates the VPN view layout and stats grid binding to match the current component API
## 2026-03-30 - 11.16.0 - feat(vpn)
add destination-based VPN routing policy and standardize socket proxy forwarding
- replace configurable VPN forwarding mode with socket-based forwarding and always enable proxy protocol support to SmartProxy from localhost
- add destinationPolicy configuration for controlling default VPN traffic handling, including forceTarget, allow, and block rules
- remove forwarding mode reporting from VPN status APIs, logs, and ops UI to reflect the simplified VPN runtime model
- update @push.rocks/smartvpn to 1.14.0 to support the new VPN routing behavior
## 2026-03-30 - 11.15.0 - feat(vpn)
add tag-based VPN route access control and support configured initial VPN clients

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "11.15.0",
"version": "11.17.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -59,7 +59,7 @@
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.13.0",
"@push.rocks/smartvpn": "1.14.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.9.0",
"@serve.zone/interfaces": "^5.3.0",

10
pnpm-lock.yaml generated
View File

@@ -96,8 +96,8 @@ importers:
specifier: ^3.0.9
version: 3.0.9
'@push.rocks/smartvpn':
specifier: 1.13.0
version: 1.13.0
specifier: 1.14.0
version: 1.14.0
'@push.rocks/taskbuffer':
specifier: ^8.0.2
version: 8.0.2
@@ -1330,8 +1330,8 @@ packages:
'@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartvpn@1.13.0':
resolution: {integrity: sha512-oQY+GIvB9OZQMFEI/f4zwKwaUWPgG8Fsz8AGhPDedvH32jYNYEb9B957yRAROf7ndyQM/LThm7mN/5cx8ALyLw==}
'@push.rocks/smartvpn@1.14.0':
resolution: {integrity: sha512-zJmHiuLwY4OEN4jBVrJf1hAXpfO9f6Bulq/v1DrB16nR7VgE82KNqRLt0Wi/9PCsAUfmVJTvOf4yirnjBrEWQg==}
'@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -6562,7 +6562,7 @@ snapshots:
'@types/semver': 7.7.1
semver: 7.7.4
'@push.rocks/smartvpn@1.13.0':
'@push.rocks/smartvpn@1.14.0':
dependencies:
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2

View File

@@ -25,6 +25,16 @@ const devRouter = new DcRouter({
},
],
},
// VPN with pre-defined clients
vpnConfig: {
enabled: true,
serverEndpoint: 'vpn.dev.local',
clients: [
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' },
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' },
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' },
],
},
// Disable cache/mongo for dev
cacheConfig: { enabled: false },
});

View File

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

View File

@@ -206,14 +206,21 @@ export interface IDcRouterOptions {
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
forwardingMode?: 'tun' | 'socket';
/** Pre-defined VPN clients created on startup */
clients?: Array<{
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
/** Destination routing policy for VPN client traffic.
* Default in socket mode: { default: 'forceTarget', target: '127.0.0.1' } (all traffic → SmartProxy).
* Default in tun mode: not set (all traffic passes through). */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
};
}
@@ -677,9 +684,8 @@ export class DcRouter {
if (this.vpnManager && this.options.vpnConfig?.enabled) {
const subnet = this.vpnManager.getSubnet();
const wgPort = this.options.vpnConfig.wgListenPort ?? 51820;
const mode = this.vpnManager.forwardingMode;
const clientCount = this.vpnManager.listClients().length;
logger.log('info', `VPN Service: mode=${mode}, subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
logger.log('info', `VPN Service: subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`);
}
// Remote Ingress summary
@@ -963,19 +969,14 @@ export class DcRouter {
smartProxyConfig.proxyIPs = ['127.0.0.1'];
}
// When VPN is in socket mode, the userspace NAT engine sends PP v2 headers
// on outbound connections to SmartProxy to preserve VPN client tunnel IPs.
// VPN uses socket mode with PP v2 — SmartProxy must accept proxy protocol from localhost
if (this.options.vpnConfig?.enabled) {
const vpnForwardingMode = this.options.vpnConfig.forwardingMode
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
if (vpnForwardingMode === 'socket') {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
}
if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) {
smartProxyConfig.proxyIPs.push('127.0.0.1');
}
}
@@ -2098,8 +2099,8 @@ export class DcRouter {
wgListenPort: this.options.vpnConfig.wgListenPort,
dns: this.options.vpnConfig.dns,
serverEndpoint: this.options.vpnConfig.serverEndpoint,
forwardingMode: this.options.vpnConfig.forwardingMode,
initialClients: this.options.vpnConfig.clients,
destinationPolicy: this.options.vpnConfig.destinationPolicy,
onClientChanged: () => {
// Re-apply routes so tag-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes();

View File

@@ -48,7 +48,6 @@ export class VpnHandler {
return {
status: {
running: false,
forwardingMode: 'socket' as const,
subnet: vpnConfig?.subnet || '10.8.0.0/24',
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: null,
@@ -62,7 +61,6 @@ export class VpnHandler {
return {
status: {
running: manager.running,
forwardingMode: manager.forwardingMode,
subnet: manager.getSubnet(),
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
serverPublicKeys: manager.getServerPublicKeys(),

View File

@@ -14,8 +14,6 @@ export interface IVpnManagerConfig {
dns?: string[];
/** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */
serverEndpoint?: string;
/** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */
forwardingMode?: 'tun' | 'socket';
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{
clientId: string;
@@ -24,6 +22,13 @@ export interface IVpnManagerConfig {
}>;
/** Called when clients are created/deleted/toggled — triggers route re-application */
onClientChanged?: () => void;
/** Destination routing policy override. Default: forceTarget to 127.0.0.1 */
destinationPolicy?: {
default: 'forceTarget' | 'block' | 'allow';
target?: string;
allowList?: string[];
blockList?: string[];
};
}
interface IPersistedServerKeys {
@@ -58,19 +63,10 @@ export class VpnManager {
private vpnServer?: plugins.smartvpn.VpnServer;
private clients: Map<string, IPersistedClient> = new Map();
private serverKeys?: IPersistedServerKeys;
private _forwardingMode: 'tun' | 'socket';
constructor(storageManager: StorageManager, config: IVpnManagerConfig) {
this.storageManager = storageManager;
this.config = config;
// Auto-detect forwarding mode: tun if root, socket otherwise
this._forwardingMode = config.forwardingMode
?? (process.getuid?.() === 0 ? 'tun' : 'socket');
}
/** The effective forwarding mode (tun or socket). */
public get forwardingMode(): 'tun' | 'socket' {
return this._forwardingMode;
}
/** The VPN subnet CIDR. */
@@ -123,12 +119,14 @@ export class VpnManager {
publicKey: this.serverKeys.noisePublicKey,
subnet,
dns: this.config.dns,
forwardingMode: this._forwardingMode,
forwardingMode: 'socket',
transportMode: 'all',
wgPrivateKey: this.serverKeys.wgPrivateKey,
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: this._forwardingMode === 'socket',
socketForwardProxyProtocol: true,
destinationPolicy: this.config.destinationPolicy
?? { default: 'forceTarget' as const, target: '127.0.0.1' },
};
await this.vpnServer.start(serverConfig);
@@ -147,7 +145,7 @@ export class VpnManager {
}
}
logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
logger.log('info', `VPN server started: subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`);
}
/**

View File

@@ -17,7 +17,6 @@ export interface IVpnClient {
*/
export interface IVpnServerStatus {
running: boolean;
forwardingMode: 'tun' | 'socket';
subnet: string;
wgListenPort: number;
serverPublicKeys: {

View File

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

View File

@@ -7,6 +7,7 @@ import {
state,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
@@ -181,13 +182,14 @@ export class OpsViewVpn extends DeesElement {
type: 'text',
value: status?.running ? 'Running' : 'Stopped',
icon: 'lucide:server',
description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running',
description: status?.running ? 'Active' : 'VPN server not running',
color: status?.running ? '#10b981' : '#ef4444',
},
];
return html`
<ops-sectionheading>VPN</ops-sectionheading>
<div class="vpnContainer">
${this.vpnState.newClientConfig ? html`
<div class="configDialog">
@@ -220,7 +222,7 @@ export class OpsViewVpn extends DeesElement {
</div>
` : ''}
<dees-statsgrid .statsTiles=${statsTiles}></dees-statsgrid>
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
${status ? html`
<div class="serverInfo">
@@ -232,10 +234,6 @@ export class OpsViewVpn extends DeesElement {
<span class="infoLabel">WireGuard Port</span>
<span class="infoValue">${status.wgListenPort}</span>
</div>
<div class="infoItem">
<span class="infoLabel">Forwarding Mode</span>
<span class="infoValue">${status.forwardingMode}</span>
</div>
${status.serverPublicKeys ? html`
<div class="infoItem">
<span class="infoLabel">WG Public Key</span>
@@ -262,31 +260,149 @@ export class OpsViewVpn extends DeesElement {
'Created': new Date(client.createdAt).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Create Client',
iconName: 'lucide:plus',
type: ['header'],
actionFunc: async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
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=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
if (!data.clientId) return;
const serverDefinedClientTags = data.tags
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId,
description: data.description || undefined,
serverDefinedClientTags,
});
await modalArg.destroy();
},
},
],
});
},
},
{
name: 'Toggle',
iconName: 'lucide:power',
action: async (client: interfaces.data.IVpnClient) => {
type: ['contextmenu', 'inRow'],
actionFunc: async (client: interfaces.data.IVpnClient) => {
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
clientId: client.clientId,
enabled: !client.enabled,
});
},
},
{
name: 'Export Config',
iconName: 'lucide:download',
type: ['contextmenu', 'inRow'],
actionFunc: async (client: interfaces.data.IVpnClient) => {
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ExportVpnClientConfig
>('/typedrequest', 'exportVpnClientConfig');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
format: 'wireguard',
});
if (response.success && response.config) {
const blob = new Blob([response.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${client.clientId}.conf`;
a.click();
URL.revokeObjectURL(url);
DeesToast.createAndShow({ message: 'Config downloaded', type: 'success', duration: 3000 });
} else {
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
}
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 });
}
},
},
{
name: 'Rotate Keys',
iconName: 'lucide:rotate-cw',
type: ['contextmenu'],
actionFunc: async (client: interfaces.data.IVpnClient) => {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Rotate Client Keys',
content: html`<p>Generate new keys for "${client.clientId}"? The old keys will be invalidated and the client will need the new config to reconnect.</p>`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Rotate',
iconName: 'lucide:rotate-cw',
action: async (modalArg: any) => {
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RotateVpnClientKey
>('/typedrequest', 'rotateVpnClientKey');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
});
if (response.success && response.wireguardConfig) {
appstate.vpnStatePart.setState({
...appstate.vpnStatePart.getState()!,
newClientConfig: response.wireguardConfig,
});
}
await modalArg.destroy();
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'Rotate failed', type: 'error', duration: 5000 });
}
},
},
],
});
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async (client: interfaces.data.IVpnClient) => {
type: ['contextmenu'],
actionFunc: async (client: interfaces.data.IVpnClient) => {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Delete VPN Client',
content: html`<p>Are you sure you want to delete client "${client.clientId}"?</p>`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Delete',
action: async (modal: any) => {
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
modal.destroy();
await modalArg.destroy();
},
},
],
@@ -294,37 +410,8 @@ export class OpsViewVpn extends DeesElement {
},
},
]}
.createNewItem=${async () => {
const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create VPN Client',
content: html`
<dees-form>
<dees-input-text id="clientId" .label=${'Client ID'} .key=${'clientId'} required></dees-input-text>
<dees-input-text id="description" .label=${'Description'} .key=${'description'}></dees-input-text>
<dees-input-text id="tags" .label=${'Tags (comma-separated)'} .key=${'tags'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => modal.destroy() },
{
name: 'Create',
action: async (modal: any) => {
const form = modal.shadowRoot!.querySelector('dees-form') as any;
const data = await form.collectFormData();
const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined;
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId,
description: data.description || undefined,
serverDefinedClientTags,
});
modal.destroy();
},
},
],
});
}}
></dees-table>
</div>
`;
}
}