Compare commits

..

4 Commits

Author SHA1 Message Date
61d856f371 v11.19.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 17:24:18 +00:00
a8d52a4709 feat(vpn): document tag-based VPN access control, declarative clients, and destination policy options 2026-03-30 17:24:17 +00:00
f685ce9928 v11.18.0
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-03-30 17:08:57 +00:00
699aa8a8e1 feat(vpn-ui): add format selection for VPN client config exports 2026-03-30 17:08:57 +00:00
7 changed files with 174 additions and 60 deletions

View File

@@ -1,5 +1,19 @@
# Changelog # Changelog
## 2026-03-30 - 11.19.0 - feat(vpn)
document tag-based VPN access control, declarative clients, and destination policy options
- Adds documentation for restricting VPN-protected routes with allowedServerDefinedClientTags.
- Documents pre-defined VPN clients in configuration via vpnConfig.clients.
- Describes destinationPolicy behavior for forceTarget, allow, and block traffic handling.
- Updates interface docs to reflect serverDefinedClientTags and revised VPN server status fields.
## 2026-03-30 - 11.18.0 - feat(vpn-ui)
add format selection for VPN client config exports
- Show an export modal that lets operators choose between WireGuard (.conf) and SmartVPN (.json) client configs.
- Update VPN client row actions to read the selected item from actionData for toggle, export, rotate keys, and delete handlers.
## 2026-03-30 - 11.17.0 - feat(vpn) ## 2026-03-30 - 11.17.0 - feat(vpn)
expand VPN operations view with client management and config export actions expand VPN operations view with client management and config export actions

View File

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

116
readme.md
View File

@@ -77,10 +77,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn)) ### 🔐 VPN Access Control (powered by [smartvpn](https://code.foss.global/push.rocks/smartvpn))
- **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels - **WireGuard + native transports** — standard WireGuard clients (iOS, Android, macOS, Windows, Linux) plus custom WebSocket/QUIC tunnels
- **Route-level VPN gating** — mark any route with `vpn: { required: true }` to restrict access to VPN clients only - **Route-level VPN gating** — mark any route with `vpn: { required: true }` to restrict access to VPN clients only
- **Rootless operation** — auto-detects privileges: kernel TUN when running as root, userspace NAT (smoltcp) when not - **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
- **Client management** — create, enable, disable, rotate keys, export WireGuard `.conf` files via OpsServer API - **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup
- **Rootless operation** — uses userspace NAT (smoltcp) with no root required
- **Destination policy** — configurable `forceTarget`, `block`, or `allow` with allowList/blockList for granular traffic control
- **Client management** — create, enable, disable, rotate keys, export WireGuard/SmartVPN configs via OpsServer API and dashboard
- **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route - **IP-based enforcement** — VPN clients get IPs from a configurable subnet; SmartProxy enforces `ipAllowList` per route
- **PROXY protocol v2** — in socket mode, the NAT engine sends PP v2 on outbound connections to preserve VPN client identity - **PROXY protocol v2** — the NAT engine sends PP v2 on outbound connections to preserve VPN client identity
### ⚡ High Performance ### ⚡ High Performance
- **Rust-powered proxy engine** via SmartProxy for maximum throughput - **Rust-powered proxy engine** via SmartProxy for maximum throughput
@@ -261,7 +264,9 @@ const router = new DcRouter({
vpnConfig: { vpnConfig: {
enabled: true, enabled: true,
serverEndpoint: 'vpn.example.com', serverEndpoint: 'vpn.example.com',
wgListenPort: 51820, clients: [
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering'] },
],
}, },
// Persistent storage // Persistent storage
@@ -456,7 +461,17 @@ interface IDcRouterOptions {
wgListenPort?: number; // default: 51820 wgListenPort?: number; // default: 51820
dns?: string[]; // DNS servers pushed to VPN clients dns?: string[]; // DNS servers pushed to VPN clients
serverEndpoint?: string; // Hostname in generated client configs serverEndpoint?: string; // Hostname in generated client configs
forwardingMode?: 'tun' | 'socket'; // default: auto-detect (root → tun, else socket) clients?: Array<{ // Pre-defined VPN clients
clientId: string;
serverDefinedClientTags?: string[];
description?: string;
}>;
destinationPolicy?: { // Traffic routing policy
default: 'forceTarget' | 'block' | 'allow';
target?: string; // IP for forceTarget (default: '127.0.0.1')
allowList?: string[]; // Pass through directly
blockList?: string[]; // Always block (overrides allowList)
};
}; };
// ── HTTP/3 (QUIC) ──────────────────────────────────────────── // ── HTTP/3 (QUIC) ────────────────────────────────────────────
@@ -1014,17 +1029,33 @@ DcRouter integrates [`@push.rocks/smartvpn`](https://code.foss.global/push.rocks
1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK) 1. **SmartVPN daemon** runs inside dcrouter with a Rust data plane (WireGuard via `boringtun`, custom protocol via Noise IK)
2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`) 2. Clients connect and get assigned an IP from the VPN subnet (e.g. `10.8.0.0/24`)
3. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected with the VPN subnet 3. Routes with `vpn: { required: true }` get `security.ipAllowList` automatically injected
4. SmartProxy enforces the allowlist — only VPN-sourced traffic is accepted on those routes 4. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
5. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
6. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
### Two Operating Modes ### Destination Policy
| Mode | Root Required? | How It Works | By default, VPN client traffic is redirected to localhost (SmartProxy) via `forceTarget`. You can customize this with a destination policy:
|------|---------------|-------------|
| **TUN** (`forwardingMode: 'tun'`) | Yes | Kernel TUN device — VPN traffic enters the network stack with real VPN IPs |
| **Socket** (`forwardingMode: 'socket'`) | No | Userspace NAT via smoltcp — outbound connections send PROXY protocol v2 to preserve VPN client IPs |
DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls back to socket mode. You can override this with the `forwardingMode` option. ```typescript
// Default: all traffic → SmartProxy
destinationPolicy: { default: 'forceTarget', target: '127.0.0.1' }
// Allow direct access to a backend subnet
destinationPolicy: {
default: 'forceTarget',
target: '127.0.0.1',
allowList: ['192.168.190.*'], // direct access to this subnet
blockList: ['192.168.190.1'], // except the gateway
}
// Block everything except specific IPs
destinationPolicy: {
default: 'block',
allowList: ['10.0.0.*', '192.168.1.*'],
}
```
### Configuration ### Configuration
@@ -1032,26 +1063,47 @@ DcRouter auto-detects: if running as root, it uses TUN mode; otherwise, it falls
const router = new DcRouter({ const router = new DcRouter({
vpnConfig: { vpnConfig: {
enabled: true, enabled: true,
subnet: '10.8.0.0/24', // VPN client IP pool (default) subnet: '10.8.0.0/24', // VPN client IP pool (default)
wgListenPort: 51820, // WireGuard UDP port (default) wgListenPort: 51820, // WireGuard UDP port (default)
serverEndpoint: 'vpn.example.com', // Hostname in generated client configs serverEndpoint: 'vpn.example.com', // Hostname in generated client configs
dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients dns: ['1.1.1.1', '8.8.8.8'], // DNS servers pushed to clients
// forwardingMode: 'socket', // Override auto-detection
// Pre-define VPN clients with server-defined tags
clients: [
{ clientId: 'alice-laptop', serverDefinedClientTags: ['engineering'], description: 'Dev laptop' },
{ clientId: 'bob-phone', serverDefinedClientTags: ['engineering', 'mobile'] },
{ clientId: 'carol-desktop', serverDefinedClientTags: ['finance'] },
],
// Optional: customize destination policy (default: forceTarget → localhost)
// destinationPolicy: { default: 'forceTarget', target: '127.0.0.1', allowList: ['192.168.1.*'] },
}, },
smartProxyConfig: { smartProxyConfig: {
routes: [ routes: [
// This route is VPN-only — non-VPN clients are blocked // 🔐 VPN-only: any VPN client can access
{ {
name: 'admin-panel', name: 'internal-app',
match: { domains: ['admin.example.com'], ports: [443] }, match: { domains: ['internal.example.com'], ports: [443] },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ host: '192.168.1.50', port: 8080 }], targets: [{ host: '192.168.1.50', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }, tls: { mode: 'terminate', certificate: 'auto' },
}, },
vpn: { required: true }, // 🔐 Only VPN clients can access this vpn: { required: true },
}, },
// This route is public — anyone can access it // 🔐 VPN + tag-restricted: only 'engineering' tagged clients
{
name: 'eng-dashboard',
match: { domains: ['eng.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.51', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] },
// → alice + bob can access, carol cannot
},
// 🌐 Public: no VPN required
{ {
name: 'public-site', name: 'public-site',
match: { domains: ['example.com'], ports: [443] }, match: { domains: ['example.com'], ports: [443] },
@@ -1066,17 +1118,29 @@ const router = new DcRouter({
}); });
``` ```
### Client Management via OpsServer API ### Client Tags
Once the VPN server is running, you can manage clients through the OpsServer dashboard or API: SmartVPN distinguishes between two types of client tags:
| Tag Type | Set By | Purpose |
|----------|--------|---------|
| `serverDefinedClientTags` | Admin (via config or API) | **Trusted** — used for route access control |
| `clientDefinedClientTags` | Connecting client | **Informational** — displayed in dashboard, never used for security |
Routes with `allowedServerDefinedClientTags` only permit VPN clients whose admin-assigned tags match. Clients cannot influence their own server-defined tags.
### Client Management via OpsServer
The OpsServer dashboard and API provide full VPN client lifecycle management:
- **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file - **Create client** — generates WireGuard keypairs, assigns IP, returns a ready-to-use `.conf` file
- **Enable / Disable** — toggle client access without deleting - **Enable / Disable** — toggle client access without deleting
- **Rotate keys** — generate fresh keypairs (invalidates old ones) - **Rotate keys** — generate fresh keypairs (invalidates old ones)
- **Export config** — re-export in WireGuard or SmartVPN format - **Export config** — download in WireGuard (`.conf`) or SmartVPN (`.json`) format
- **Telemetry** — per-client bytes sent/received, keepalives, rate limiting - **Telemetry** — per-client bytes sent/received, keepalives, rate limiting
- **Delete** — remove a client and revoke access
Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file or QR code — no custom VPN software needed. Standard WireGuard clients on any platform (iOS, Android, macOS, Windows, Linux) can connect using the generated `.conf` file — no custom VPN software needed.
## Certificate Management ## Certificate Management

View File

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

View File

@@ -97,13 +97,13 @@ interface IIdentity {
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat | | `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter | | `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties | | `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
| `IRouteVpn` | Route-level VPN config: `required` flag to restrict access to VPN clients | | `IRouteVpn` | Route-level VPN config: `required` flag and optional `allowedServerDefinedClientTags` |
#### VPN Interfaces #### VPN Interfaces
| Interface | Description | | Interface | Description |
|-----------|-------------| |-----------|-------------|
| `IVpnClient` | Client registration: clientId, enabled, tags, description, assignedIp, timestamps | | `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
| `IVpnServerStatus` | Server status: running, forwardingMode, subnet, wgListenPort, publicKeys, client counts | | `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits | | `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
### Request Interfaces (`requests`) ### Request Interfaces (`requests`)

View File

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

View File

@@ -308,7 +308,8 @@ export class OpsViewVpn extends DeesElement {
name: 'Toggle', name: 'Toggle',
iconName: 'lucide:power', iconName: 'lucide:power',
type: ['contextmenu', 'inRow'], type: ['contextmenu', 'inRow'],
actionFunc: async (client: interfaces.data.IVpnClient) => { actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, { await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
clientId: client.clientId, clientId: client.clientId,
enabled: !client.enabled, enabled: !client.enabled,
@@ -319,39 +320,73 @@ export class OpsViewVpn extends DeesElement {
name: 'Export Config', name: 'Export Config',
iconName: 'lucide:download', iconName: 'lucide:download',
type: ['contextmenu', 'inRow'], type: ['contextmenu', 'inRow'],
actionFunc: async (client: interfaces.data.IVpnClient) => { actionFunc: async (actionData: any) => {
const { DeesToast } = await import('@design.estate/dees-catalog'); const client = actionData.item as interfaces.data.IVpnClient;
try { const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ExportVpnClientConfig const exportConfig = async (format: 'wireguard' | 'smartvpn') => {
>('/typedrequest', 'exportVpnClientConfig'); try {
const response = await request.fire({ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
identity: appstate.loginStatePart.getState()!.identity!, interfaces.requests.IReq_ExportVpnClientConfig
clientId: client.clientId, >('/typedrequest', 'exportVpnClientConfig');
format: 'wireguard', const response = await request.fire({
}); identity: appstate.loginStatePart.getState()!.identity!,
if (response.success && response.config) { clientId: client.clientId,
const blob = new Blob([response.config], { type: 'text/plain' }); format,
const url = URL.createObjectURL(blob); });
const a = document.createElement('a'); if (response.success && response.config) {
a.href = url; const ext = format === 'wireguard' ? 'conf' : 'json';
a.download = `${client.clientId}.conf`; const blob = new Blob([response.config], { type: 'text/plain' });
a.click(); const url = URL.createObjectURL(blob);
URL.revokeObjectURL(url); const a = document.createElement('a');
DeesToast.createAndShow({ message: 'Config downloaded', type: 'success', duration: 3000 }); a.href = url;
} else { a.download = `${client.clientId}.${ext}`;
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 }); a.click();
URL.revokeObjectURL(url);
DeesToast.createAndShow({ message: `${format} 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 });
} }
} catch (err: any) { };
DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 });
} DeesModal.createAndShow({
heading: `Export Config: ${client.clientId}`,
content: html`<p>Choose a config format to download.</p>`,
menuOptions: [
{
name: 'WireGuard (.conf)',
iconName: 'lucide:shield',
action: async (modalArg: any) => {
await modalArg.destroy();
await exportConfig('wireguard');
},
},
{
name: 'SmartVPN (.json)',
iconName: 'lucide:braces',
action: async (modalArg: any) => {
await modalArg.destroy();
await exportConfig('smartvpn');
},
},
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
}, },
}, },
{ {
name: 'Rotate Keys', name: 'Rotate Keys',
iconName: 'lucide:rotate-cw', iconName: 'lucide:rotate-cw',
type: ['contextmenu'], type: ['contextmenu'],
actionFunc: async (client: interfaces.data.IVpnClient) => { actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: 'Rotate Client Keys', heading: 'Rotate Client Keys',
@@ -390,7 +425,8 @@ export class OpsViewVpn extends DeesElement {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
type: ['contextmenu'], type: ['contextmenu'],
actionFunc: async (client: interfaces.data.IVpnClient) => { actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: 'Delete VPN Client', heading: 'Delete VPN Client',