feat(vpn): support optional non-mandatory VPN route access and align route config with enabled semantics

This commit is contained in:
2026-03-31 11:19:29 +00:00
parent 95daee1d8f
commit 29687670e8
10 changed files with 39 additions and 24 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-03-31 - 11.23.0 - feat(vpn)
support optional non-mandatory VPN route access and align route config with enabled semantics
- rename route VPN configuration from `required` to `enabled` across code, docs, and examples
- add `vpn.mandatory` to control whether VPN allowlists replace or extend existing `security.ipAllowList` rules
- improve VPN client status matching in the ops view by falling back to assigned IP when client IDs differ
## 2026-03-31 - 11.22.0 - feat(vpn) ## 2026-03-31 - 11.22.0 - feat(vpn)
add VPN client editing and connected client visibility in ops server add VPN client editing and connected client visibility in ops server

View File

@@ -76,7 +76,7 @@ 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: { enabled: true }` to restrict access to VPN clients only, or `vpn: { enabled: true, mandatory: false }` to add VPN clients alongside existing access rules
- **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags` - **Tag-based access control** — assign `serverDefinedClientTags` to clients and restrict routes with `allowedServerDefinedClientTags`
- **Constructor-defined clients** — pre-define VPN clients with tags in config for declarative, code-driven setup - **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 - **Rootless operation** — uses userspace NAT (smoltcp) with no root required
@@ -1030,8 +1030,8 @@ 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. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.required` are resolved at config generation time, so clients route only the necessary traffic through the tunnel 3. **Smart split tunnel** — generated WireGuard configs auto-include the VPN subnet plus DNS-resolved IPs of VPN-gated domains. Domains from routes with `vpn.enabled` are resolved at config generation time, so clients route only the necessary traffic through the tunnel
4. Routes with `vpn: { required: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change) 4. Routes with `vpn: { enabled: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change). With `mandatory: true` (default), the allowlist is replaced; with `mandatory: false`, VPN IPs are appended to existing rules
5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet) 5. When `allowedServerDefinedClientTags` is set, only matching client IPs are injected (not the whole subnet)
6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes 6. SmartProxy enforces the allowlist — only authorized VPN clients can access protected routes
7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required 7. All VPN traffic is forced through SmartProxy via userspace NAT with PROXY protocol v2 — no root required
@@ -1091,7 +1091,7 @@ const router = new DcRouter({
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 }, vpn: { enabled: true },
}, },
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients // 🔐 VPN + tag-restricted: only 'engineering' tagged clients
{ {
@@ -1102,10 +1102,10 @@ const router = new DcRouter({
targets: [{ host: '192.168.1.51', port: 8080 }], targets: [{ host: '192.168.1.51', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }, tls: { mode: 'terminate', certificate: 'auto' },
}, },
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] }, vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
// → alice + bob can access, carol cannot // → alice + bob can access, carol cannot
}, },
// 🌐 Public: no VPN required // 🌐 Public: no VPN
{ {
name: 'public-site', name: 'public-site',
match: { domains: ['example.com'], ports: [443] }, match: { domains: ['example.com'], ports: [443] },

View File

@@ -29,13 +29,13 @@ const devRouter = new DcRouter({
name: 'vpn-internal-app', name: 'vpn-internal-app',
match: { ports: [18080], domains: ['internal.example.com'] }, match: { ports: [18080], domains: ['internal.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] }, action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
vpn: { required: true }, vpn: { enabled: true },
}, },
{ {
name: 'vpn-eng-dashboard', name: 'vpn-eng-dashboard',
match: { ports: [18080], domains: ['eng.example.com'] }, match: { ports: [18080], domains: ['eng.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] }, action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] }, vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
}, },
] as any[], ] as any[],
}, },

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.22.0', version: '11.23.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

@@ -192,7 +192,7 @@ export interface IDcRouterOptions {
/** /**
* VPN server configuration. * VPN server configuration.
* Enables VPN-based access control: routes with vpn.required are only * Enables VPN-based access control: routes with vpn.enabled are only
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports. * accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports.
*/ */
vpnConfig?: { vpnConfig?: {
@@ -2110,7 +2110,7 @@ export class DcRouter {
const domainsToResolve = new Set<string>(); const domainsToResolve = new Set<string>();
for (const route of routes) { for (const route of routes) {
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig; const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (!dcRoute.vpn?.required) continue; if (!dcRoute.vpn?.enabled) continue;
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags; const routeTags = dcRoute.vpn.allowedServerDefinedClientTags;
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) { if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {

View File

@@ -255,17 +255,20 @@ export class RouteConfigManager {
const http3Config = this.getHttp3Config?.(); const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList; const vpnAllowList = this.getVpnAllowList;
// Helper: inject VPN security into a route if vpn.required is set // Helper: inject VPN security into a route if vpn.enabled is set
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => { const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => {
if (!vpnAllowList) return route; if (!vpnAllowList) return route;
const dcRoute = route as IDcRouterRouteConfig; const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpn?.required) return route; if (!dcRoute.vpn?.enabled) return route;
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags); const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
const mandatory = dcRoute.vpn.mandatory !== false; // defaults to true
return { return {
...route, ...route,
security: { security: {
...route.security, ...route.security,
ipAllowList: [...(route.security?.ipAllowList || []), ...allowList], ipAllowList: mandatory
? allowList
: [...(route.security?.ipAllowList || []), ...allowList],
}, },
}; };
}; };

View File

@@ -53,11 +53,14 @@ export interface IRouteRemoteIngress {
/** /**
* Route-level VPN access configuration. * Route-level VPN access configuration.
* When attached to a route, restricts access to VPN clients only. * When attached to a route, controls VPN client access.
*/ */
export interface IRouteVpn { export interface IRouteVpn {
/** Whether this route requires VPN access */ /** Enable VPN client access for this route */
required: boolean; enabled: boolean;
/** When true (default), ONLY VPN clients can access this route (replaces ipAllowList).
* When false, VPN client IPs are added alongside the existing allowlist. */
mandatory?: boolean;
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */ /** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
allowedServerDefinedClientTags?: string[]; allowedServerDefinedClientTags?: string[];
} }

View File

@@ -97,7 +97,7 @@ 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 and optional `allowedServerDefinedClientTags` | | `IRouteVpn` | Route-level VPN config: `enabled`/`mandatory` flags and optional `allowedServerDefinedClientTags` |
#### VPN Interfaces #### VPN Interfaces
| Interface | Description | | Interface | Description |

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '11.22.0', version: '11.23.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

@@ -141,9 +141,11 @@ export class OpsViewVpn extends DeesElement {
`, `,
]; ];
/** Look up connected client info by clientId */ /** Look up connected client info by clientId or assignedIp */
private getConnectedInfo(clientId: string): interfaces.data.IVpnConnectedClient | undefined { private getConnectedInfo(client: interfaces.data.IVpnClient): interfaces.data.IVpnConnectedClient | undefined {
return this.vpnState.connectedClients?.find(c => c.clientId === clientId); return this.vpnState.connectedClients?.find(
c => c.clientId === client.clientId || (client.assignedIp && c.assignedIp === client.assignedIp)
);
} }
render(): TemplateResult { render(): TemplateResult {
@@ -277,7 +279,7 @@ export class OpsViewVpn extends DeesElement {
.heading2=${'Manage WireGuard and SmartVPN client registrations'} .heading2=${'Manage WireGuard and SmartVPN client registrations'}
.data=${clients} .data=${clients}
.displayFunction=${(client: interfaces.data.IVpnClient) => { .displayFunction=${(client: interfaces.data.IVpnClient) => {
const conn = this.getConnectedInfo(client.clientId); const conn = this.getConnectedInfo(client);
let statusHtml; let statusHtml;
if (!client.enabled) { if (!client.enabled) {
statusHtml = html`<span class="statusBadge disabled">disabled</span>`; statusHtml = html`<span class="statusBadge disabled">disabled</span>`;
@@ -349,7 +351,7 @@ export class OpsViewVpn extends DeesElement {
type: ['doubleClick'], type: ['doubleClick'],
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient; const client = actionData.item as interfaces.data.IVpnClient;
const conn = this.getConnectedInfo(client.clientId); const conn = this.getConnectedInfo(client);
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
// Fetch telemetry on-demand // Fetch telemetry on-demand