diff --git a/changelog.md b/changelog.md index 847ec05..ce1aaa1 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/readme.md b/readme.md index 07e4cbc..f6643b2 100644 --- a/readme.md +++ b/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)) - **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] }, diff --git a/test_watch/devserver.ts b/test_watch/devserver.ts index e96e069..c078e61 100644 --- a/test_watch/devserver.ts +++ b/test_watch/devserver.ts @@ -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[], }, diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ce31aad..9526988 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index aa645f2..ce82941 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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(); 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))) { diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 53381e6..74449ef 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -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], }, }; }; diff --git a/ts_interfaces/data/remoteingress.ts b/ts_interfaces/data/remoteingress.ts index 243ad8f..5606c27 100644 --- a/ts_interfaces/data/remoteingress.ts +++ b/ts_interfaces/data/remoteingress.ts @@ -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[]; } diff --git a/ts_interfaces/readme.md b/ts_interfaces/readme.md index 32ab3e6..bbc2765 100644 --- a/ts_interfaces/readme.md +++ b/ts_interfaces/readme.md @@ -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 | diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index ce31aad..9526988 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' } diff --git a/ts_web/elements/ops-view-vpn.ts b/ts_web/elements/ops-view-vpn.ts index cb4bb73..1c0f383 100644 --- a/ts_web/elements/ops-view-vpn.ts +++ b/ts_web/elements/ops-view-vpn.ts @@ -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`disabled`; @@ -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