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
## 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)
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))
- **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`
- **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
@@ -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)
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
4. Routes with `vpn: { required: true }` get `security.ipAllowList` dynamically injected (re-computed on every client change)
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: { 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)
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
@@ -1091,7 +1091,7 @@ const router = new DcRouter({
targets: [{ host: '192.168.1.50', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { required: true },
vpn: { enabled: true },
},
// 🔐 VPN + tag-restricted: only 'engineering' tagged clients
{
@@ -1102,10 +1102,10 @@ const router = new DcRouter({
targets: [{ host: '192.168.1.51', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
vpn: { required: true, allowedServerDefinedClientTags: ['engineering'] },
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] },
// → alice + bob can access, carol cannot
},
// 🌐 Public: no VPN required
// 🌐 Public: no VPN
{
name: 'public-site',
match: { domains: ['example.com'], ports: [443] },

View File

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

View File

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

View File

@@ -192,7 +192,7 @@ export interface IDcRouterOptions {
/**
* 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.
*/
vpnConfig?: {
@@ -2110,7 +2110,7 @@ export class DcRouter {
const domainsToResolve = new Set<string>();
for (const route of routes) {
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;
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) {

View File

@@ -255,17 +255,20 @@ export class RouteConfigManager {
const http3Config = this.getHttp3Config?.();
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 => {
if (!vpnAllowList) return route;
const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpn?.required) return route;
if (!dcRoute.vpn?.enabled) return route;
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags);
const mandatory = dcRoute.vpn.mandatory !== false; // defaults to true
return {
...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.
* When attached to a route, restricts access to VPN clients only.
* When attached to a route, controls VPN client access.
*/
export interface IRouteVpn {
/** Whether this route requires VPN access */
required: boolean;
/** Enable VPN client access for this route */
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. */
allowedServerDefinedClientTags?: string[];
}

View File

@@ -97,7 +97,7 @@ interface IIdentity {
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
| `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
| Interface | Description |

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '11.22.0',
version: '11.23.0',
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 */
private getConnectedInfo(clientId: string): interfaces.data.IVpnConnectedClient | undefined {
return this.vpnState.connectedClients?.find(c => c.clientId === clientId);
/** Look up connected client info by clientId or assignedIp */
private getConnectedInfo(client: interfaces.data.IVpnClient): interfaces.data.IVpnConnectedClient | undefined {
return this.vpnState.connectedClients?.find(
c => c.clientId === client.clientId || (client.assignedIp && c.assignedIp === client.assignedIp)
);
}
render(): TemplateResult {
@@ -277,7 +279,7 @@ export class OpsViewVpn extends DeesElement {
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
.data=${clients}
.displayFunction=${(client: interfaces.data.IVpnClient) => {
const conn = this.getConnectedInfo(client.clientId);
const conn = this.getConnectedInfo(client);
let statusHtml;
if (!client.enabled) {
statusHtml = html`<span class="statusBadge disabled">disabled</span>`;
@@ -349,7 +351,7 @@ export class OpsViewVpn extends DeesElement {
type: ['doubleClick'],
actionFunc: async (actionData: any) => {
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');
// Fetch telemetry on-demand