feat(vpn): support optional non-mandatory VPN route access and align route config with enabled semantics
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
12
readme.md
12
readme.md
@@ -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] },
|
||||||
|
|||||||
@@ -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[],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))) {
|
||||||
|
|||||||
@@ -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],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user