diff --git a/changelog.md b/changelog.md index 0de4480..9ea84b1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-03-30 - 11.15.0 - feat(vpn) +add tag-based VPN route access control and support configured initial VPN clients + +- allow VPN-protected routes to restrict access to clients with matching server-defined tags instead of always permitting the full VPN subnet +- create configured VPN clients automatically on startup and re-apply routes when VPN clients change +- rename VPN client tag fields to serverDefinedClientTags across APIs, interfaces, handlers, and UI with legacy tag migration on load +- upgrade @push.rocks/smartvpn from 1.12.0 to 1.13.0 + ## 2026-03-30 - 11.14.0 - feat(docs) document VPN access control and add OpsServer VPN navigation diff --git a/package.json b/package.json index 0db7947..4bbfb20 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.3.0", "@push.rocks/smartunique": "^3.0.9", - "@push.rocks/smartvpn": "1.12.0", + "@push.rocks/smartvpn": "1.13.0", "@push.rocks/taskbuffer": "^8.0.2", "@serve.zone/catalog": "^2.9.0", "@serve.zone/interfaces": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49d1d22..4d48169 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^3.0.9 version: 3.0.9 '@push.rocks/smartvpn': - specifier: 1.12.0 - version: 1.12.0 + specifier: 1.13.0 + version: 1.13.0 '@push.rocks/taskbuffer': specifier: ^8.0.2 version: 8.0.2 @@ -1330,8 +1330,8 @@ packages: '@push.rocks/smartversion@3.0.5': resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==} - '@push.rocks/smartvpn@1.12.0': - resolution: {integrity: sha512-lwZCK8fopkms3c6ZSrUghuVNFi7xOXMSkGDSptQM2K3tu2UbajhpdxlAVMODY8n6caQr5ZXp0kHdtwVU9WKi5Q==} + '@push.rocks/smartvpn@1.13.0': + resolution: {integrity: sha512-oQY+GIvB9OZQMFEI/f4zwKwaUWPgG8Fsz8AGhPDedvH32jYNYEb9B957yRAROf7ndyQM/LThm7mN/5cx8ALyLw==} '@push.rocks/smartwatch@6.4.0': resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} @@ -6562,7 +6562,7 @@ snapshots: '@types/semver': 7.7.1 semver: 7.7.4 - '@push.rocks/smartvpn@1.12.0': + '@push.rocks/smartvpn@1.13.0': dependencies: '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartrust': 1.3.2 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 707f3fb..0408049 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.14.0', + version: '11.15.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 dfe6201..1c02aa7 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -208,6 +208,12 @@ export interface IDcRouterOptions { serverEndpoint?: string; /** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */ forwardingMode?: 'tun' | 'socket'; + /** Pre-defined VPN clients created on startup */ + clients?: Array<{ + clientId: string; + serverDefinedClientTags?: string[]; + description?: string; + }>; }; } @@ -453,7 +459,14 @@ export class DcRouter { () => this.getConstructorRoutes(), () => this.smartProxy, () => this.options.http3, - () => this.options.vpnConfig?.enabled ? (this.options.vpnConfig.subnet || '10.8.0.0/24') : undefined, + this.options.vpnConfig?.enabled + ? (tags?: string[]) => { + if (tags?.length && this.vpnManager) { + return this.vpnManager.getClientIpsForServerDefinedTags(tags); + } + return [this.options.vpnConfig?.subnet || '10.8.0.0/24']; + } + : undefined, ); this.apiTokenManager = new ApiTokenManager(this.storageManager); await this.apiTokenManager.initialize(); @@ -2086,6 +2099,11 @@ export class DcRouter { dns: this.options.vpnConfig.dns, serverEndpoint: this.options.vpnConfig.serverEndpoint, forwardingMode: this.options.vpnConfig.forwardingMode, + initialClients: this.options.vpnConfig.clients, + onClientChanged: () => { + // Re-apply routes so tag-based ipAllowLists get updated + this.routeConfigManager?.applyRoutes(); + }, }); await this.vpnManager.start(); @@ -2104,11 +2122,23 @@ export class DcRouter { if (dcrouterRoute.vpn?.required) { injectedCount++; const existing = route.security?.ipAllowList || []; + + let vpnAllowList: string[]; + if (dcrouterRoute.vpn.allowedServerDefinedClientTags?.length && this.vpnManager) { + // Tag-based: only specific client IPs + vpnAllowList = this.vpnManager.getClientIpsForServerDefinedTags( + dcrouterRoute.vpn.allowedServerDefinedClientTags, + ); + } else { + // No tags specified: entire VPN subnet + vpnAllowList = [vpnSubnet]; + } + return { ...route, security: { ...route.security, - ipAllowList: [...existing, vpnSubnet], + ipAllowList: [...existing, ...vpnAllowList], }, }; } @@ -2116,7 +2146,7 @@ export class DcRouter { }); if (injectedCount > 0) { - logger.log('info', `VPN: Injected ipAllowList (${vpnSubnet}) into ${injectedCount} VPN-protected route(s)`); + logger.log('info', `VPN: Injected ipAllowList into ${injectedCount} VPN-protected route(s)`); } return result; diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 0e1e070..676b56a 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -23,7 +23,7 @@ export class RouteConfigManager { private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getHttp3Config?: () => IHttp3Config | undefined, - private getVpnSubnet?: () => string | undefined, + private getVpnAllowList?: (tags?: string[]) => string[], ) {} /** @@ -246,7 +246,7 @@ export class RouteConfigManager { // Private: apply merged routes to SmartProxy // ========================================================================= - private async applyRoutes(): Promise { + public async applyRoutes(): Promise { const smartProxy = this.getSmartProxy(); if (!smartProxy) return; @@ -262,9 +262,9 @@ export class RouteConfigManager { enabledRoutes.push(route); } - // Add enabled programmatic routes (with HTTP/3 augmentation if enabled) + // Add enabled programmatic routes (with HTTP/3 and VPN augmentation) const http3Config = this.getHttp3Config?.(); - const vpnSubnet = this.getVpnSubnet?.(); + const vpnAllowList = this.getVpnAllowList; for (const stored of this.storedRoutes.values()) { if (stored.enabled) { let route = stored.route; @@ -272,15 +272,16 @@ export class RouteConfigManager { route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); } // Inject VPN security for programmatic routes with vpn.required - if (vpnSubnet) { + if (vpnAllowList) { const dcRoute = route as IDcRouterRouteConfig; if (dcRoute.vpn?.required) { const existing = route.security?.ipAllowList || []; + const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags); route = { ...route, security: { ...route.security, - ipAllowList: [...existing, vpnSubnet], + ipAllowList: [...existing, ...allowList], }, }; } diff --git a/ts/opsserver/handlers/vpn.handler.ts b/ts/opsserver/handlers/vpn.handler.ts index 415d8b9..eaf7c69 100644 --- a/ts/opsserver/handlers/vpn.handler.ts +++ b/ts/opsserver/handlers/vpn.handler.ts @@ -25,7 +25,7 @@ export class VpnHandler { const clients = manager.listClients().map((c) => ({ clientId: c.clientId, enabled: c.enabled, - tags: c.tags, + serverDefinedClientTags: c.serverDefinedClientTags, description: c.description, assignedIp: c.assignedIp, createdAt: c.createdAt, @@ -89,7 +89,7 @@ export class VpnHandler { try { const bundle = await manager.createClient({ clientId: dataArg.clientId, - tags: dataArg.tags, + serverDefinedClientTags: dataArg.serverDefinedClientTags, description: dataArg.description, }); @@ -98,7 +98,7 @@ export class VpnHandler { client: { clientId: bundle.entry.clientId, enabled: bundle.entry.enabled ?? true, - tags: bundle.entry.tags, + serverDefinedClientTags: bundle.entry.serverDefinedClientTags, description: bundle.entry.description, assignedIp: bundle.entry.assignedIp, createdAt: Date.now(), diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts index 722fcd4..e4c4482 100644 --- a/ts/vpn/classes.vpn-manager.ts +++ b/ts/vpn/classes.vpn-manager.ts @@ -16,6 +16,14 @@ export interface IVpnManagerConfig { serverEndpoint?: string; /** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */ forwardingMode?: 'tun' | 'socket'; + /** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */ + initialClients?: Array<{ + clientId: string; + serverDefinedClientTags?: string[]; + description?: string; + }>; + /** Called when clients are created/deleted/toggled — triggers route re-application */ + onClientChanged?: () => void; } interface IPersistedServerKeys { @@ -28,7 +36,7 @@ interface IPersistedServerKeys { interface IPersistedClient { clientId: string; enabled: boolean; - tags?: string[]; + serverDefinedClientTags?: string[]; description?: string; assignedIp?: string; noisePublicKey: string; @@ -36,6 +44,8 @@ interface IPersistedClient { createdAt: number; updatedAt: number; expiresAt?: string; + /** @deprecated Legacy field — migrated to serverDefinedClientTags on load */ + tags?: string[]; } /** @@ -92,7 +102,7 @@ export class VpnManager { publicKey: client.noisePublicKey, wgPublicKey: client.wgPublicKey, enabled: client.enabled, - tags: client.tags, + serverDefinedClientTags: client.serverDefinedClientTags, description: client.description, assignedIp: client.assignedIp, expiresAt: client.expiresAt, @@ -122,6 +132,21 @@ export class VpnManager { }; await this.vpnServer.start(serverConfig); + + // Create initial clients from config (idempotent — skip already-persisted) + if (this.config.initialClients) { + for (const initial of this.config.initialClients) { + if (!this.clients.has(initial.clientId)) { + const bundle = await this.createClient({ + clientId: initial.clientId, + serverDefinedClientTags: initial.serverDefinedClientTags, + description: initial.description, + }); + logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`); + } + } + } + logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`); } @@ -148,7 +173,7 @@ export class VpnManager { */ public async createClient(opts: { clientId: string; - tags?: string[]; + serverDefinedClientTags?: string[]; description?: string; }): Promise { if (!this.vpnServer) { @@ -157,7 +182,7 @@ export class VpnManager { const bundle = await this.vpnServer.createClient({ clientId: opts.clientId, - tags: opts.tags, + serverDefinedClientTags: opts.serverDefinedClientTags, description: opts.description, }); @@ -174,7 +199,7 @@ export class VpnManager { const persisted: IPersistedClient = { clientId: bundle.entry.clientId, enabled: bundle.entry.enabled ?? true, - tags: bundle.entry.tags, + serverDefinedClientTags: bundle.entry.serverDefinedClientTags, description: bundle.entry.description, assignedIp: bundle.entry.assignedIp, noisePublicKey: bundle.entry.publicKey, @@ -186,6 +211,7 @@ export class VpnManager { this.clients.set(persisted.clientId, persisted); await this.persistClient(persisted); + this.config.onClientChanged?.(); return bundle; } @@ -199,6 +225,7 @@ export class VpnManager { await this.vpnServer.removeClient(clientId); this.clients.delete(clientId); await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`); + this.config.onClientChanged?.(); } /** @@ -220,6 +247,7 @@ export class VpnManager { client.updatedAt = Date.now(); await this.persistClient(client); } + this.config.onClientChanged?.(); } /** @@ -234,6 +262,7 @@ export class VpnManager { client.updatedAt = Date.now(); await this.persistClient(client); } + this.config.onClientChanged?.(); } /** @@ -283,6 +312,22 @@ export class VpnManager { return config; } + // ── Tag-based access control ─────────────────────────────────────────── + + /** + * Get assigned IPs for all enabled clients matching any of the given server-defined tags. + */ + public getClientIpsForServerDefinedTags(tags: string[]): string[] { + const ips: string[] = []; + for (const client of this.clients.values()) { + if (!client.enabled || !client.assignedIp) continue; + if (client.serverDefinedClientTags?.some(t => tags.includes(t))) { + ips.push(client.assignedIp); + } + } + return ips; + } + // ── Status and telemetry ─────────────────────────────────────────────── /** @@ -364,6 +409,12 @@ export class VpnManager { for (const key of keys) { const client = await this.storageManager.getJSON(key); if (client) { + // Migrate legacy `tags` → `serverDefinedClientTags` + if (!client.serverDefinedClientTags && client.tags) { + client.serverDefinedClientTags = client.tags; + delete client.tags; + await this.persistClient(client); + } this.clients.set(client.clientId, client); } } diff --git a/ts_interfaces/data/remoteingress.ts b/ts_interfaces/data/remoteingress.ts index bfcc240..243ad8f 100644 --- a/ts_interfaces/data/remoteingress.ts +++ b/ts_interfaces/data/remoteingress.ts @@ -58,6 +58,8 @@ export interface IRouteRemoteIngress { export interface IRouteVpn { /** Whether this route requires VPN access */ required: boolean; + /** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */ + allowedServerDefinedClientTags?: string[]; } /** diff --git a/ts_interfaces/data/vpn.ts b/ts_interfaces/data/vpn.ts index 09f99ec..75a31c2 100644 --- a/ts_interfaces/data/vpn.ts +++ b/ts_interfaces/data/vpn.ts @@ -4,7 +4,7 @@ export interface IVpnClient { clientId: string; enabled: boolean; - tags?: string[]; + serverDefinedClientTags?: string[]; description?: string; assignedIp?: string; createdAt: number; diff --git a/ts_interfaces/requests/vpn.ts b/ts_interfaces/requests/vpn.ts index 7151561..4582ab8 100644 --- a/ts_interfaces/requests/vpn.ts +++ b/ts_interfaces/requests/vpn.ts @@ -49,7 +49,7 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp request: { identity: authInterfaces.IIdentity; clientId: string; - tags?: string[]; + serverDefinedClientTags?: string[]; description?: string; }; response: { diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 707f3fb..0408049 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.14.0', + version: '11.15.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index de3cbb1..e75cb24 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -974,7 +974,7 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr export const createVpnClientAction = vpnStatePart.createAction<{ clientId: string; - tags?: string[]; + serverDefinedClientTags?: string[]; description?: string; }>(async (statePartArg, dataArg, actionContext): Promise => { const context = getActionContext(); @@ -988,7 +988,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{ const response = await request.fire({ identity: context.identity!, clientId: dataArg.clientId, - tags: dataArg.tags, + serverDefinedClientTags: dataArg.serverDefinedClientTags, description: dataArg.description, }); diff --git a/ts_web/elements/ops-view-vpn.ts b/ts_web/elements/ops-view-vpn.ts index 6ab7313..bdd5068 100644 --- a/ts_web/elements/ops-view-vpn.ts +++ b/ts_web/elements/ops-view-vpn.ts @@ -255,8 +255,8 @@ export class OpsViewVpn extends DeesElement { ? html`enabled` : html`disabled`, 'VPN IP': client.assignedIp || '-', - 'Tags': client.tags?.length - ? html`${client.tags.map(t => html`${t}`)}` + 'Tags': client.serverDefinedClientTags?.length + ? html`${client.serverDefinedClientTags.map(t => html`${t}`)}` : '-', 'Description': client.description || '-', 'Created': new Date(client.createdAt).toLocaleDateString(), @@ -312,11 +312,11 @@ export class OpsViewVpn extends DeesElement { action: async (modal: any) => { const form = modal.shadowRoot!.querySelector('dees-form') as any; const data = await form.collectFormData(); - const tags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined; + const serverDefinedClientTags = data.tags ? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined; await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, { clientId: data.clientId, description: data.description || undefined, - tags, + serverDefinedClientTags, }); modal.destroy(); },