diff --git a/changelog.md b/changelog.md index d15c152..47101fa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-03-30 - 11.13.0 - feat(vpn) +add VPN server management and route-based VPN access control + +- introduces a VPN manager backed by @push.rocks/smartvpn with persisted server keys and client registrations +- adds ops API handlers and typed request interfaces for VPN client lifecycle, status, config export, and telemetry +- adds ops dashboard VPN view and application state for managing VPN clients from the web UI +- supports vpn.required on routes by injecting VPN subnet allowlists into static and programmatic SmartProxy routes +- configures SmartProxy to accept proxy protocol in VPN socket forwarding mode to preserve client tunnel IPs + ## 2026-03-27 - 11.12.4 - fix(acme) use X509 certificate expiry when reporting ACME certificate validity diff --git a/package.json b/package.json index 91bfae3..078ad0f 100644 --- a/package.json +++ b/package.json @@ -59,6 +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/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 8170120..49d1d22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@push.rocks/smartunique': specifier: ^3.0.9 version: 3.0.9 + '@push.rocks/smartvpn': + specifier: 1.12.0 + version: 1.12.0 '@push.rocks/taskbuffer': specifier: ^8.0.2 version: 8.0.2 @@ -1327,6 +1330,9 @@ 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/smartwatch@6.4.0': resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==} engines: {node: '>=20.0.0'} @@ -6556,6 +6562,11 @@ snapshots: '@types/semver': 7.7.1 semver: 7.7.4 + '@push.rocks/smartvpn@1.12.0': + dependencies: + '@push.rocks/smartpath': 6.0.0 + '@push.rocks/smartrust': 1.3.2 + '@push.rocks/smartwatch@6.4.0': dependencies: '@push.rocks/lik': 6.4.0 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 06431aa..92e1eb4 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.12.4', + version: '11.13.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 adae43c..dfe6201 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js'; import { MetricsManager } from './monitoring/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; +import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { RouteConfigManager, ApiTokenManager } from './config/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; @@ -188,6 +189,26 @@ export interface IDcRouterOptions { keyPath?: string; }; }; + + /** + * VPN server configuration. + * Enables VPN-based access control: routes with vpn.required are only + * accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports. + */ + vpnConfig?: { + /** Enable VPN server (default: false) */ + enabled?: boolean; + /** VPN subnet CIDR (default: '10.8.0.0/24') */ + subnet?: string; + /** WireGuard UDP listen port (default: 51820) */ + wgListenPort?: number; + /** DNS servers pushed to VPN clients */ + dns?: string[]; + /** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */ + serverEndpoint?: string; + /** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */ + forwardingMode?: 'tun' | 'socket'; + }; } /** @@ -226,6 +247,9 @@ export class DcRouter { public remoteIngressManager?: RemoteIngressManager; public tunnelManager?: TunnelManager; + // VPN + public vpnManager?: VpnManager; + // Programmatic config API public routeConfigManager?: RouteConfigManager; public apiTokenManager?: ApiTokenManager; @@ -429,6 +453,7 @@ 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.apiTokenManager = new ApiTokenManager(this.storageManager); await this.apiTokenManager.initialize(); @@ -533,6 +558,25 @@ export class DcRouter { ); } + // VPN Server: optional, depends on SmartProxy + if (this.options.vpnConfig?.enabled) { + this.serviceManager.addService( + new plugins.taskbuffer.Service('VpnServer') + .optional() + .dependsOn('SmartProxy') + .withStart(async () => { + await this.setupVpnServer(); + }) + .withStop(async () => { + if (this.vpnManager) { + await this.vpnManager.stop(); + this.vpnManager = undefined; + } + }) + .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }), + ); + } + // Wire up aggregated events for logging this.serviceSubjectSubscription = this.serviceManager.serviceSubject.subscribe((event) => { const level = event.type === 'failed' ? 'error' : event.type === 'retrying' ? 'warn' : 'info'; @@ -616,6 +660,15 @@ export class DcRouter { logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`); } + // VPN summary + if (this.vpnManager && this.options.vpnConfig?.enabled) { + const subnet = this.vpnManager.getSubnet(); + const wgPort = this.options.vpnConfig.wgListenPort ?? 51820; + const mode = this.vpnManager.forwardingMode; + const clientCount = this.vpnManager.listClients().length; + logger.log('info', `VPN Service: mode=${mode}, subnet=${subnet}, wg=:${wgPort}, clients=${clientCount}`); + } + // Remote Ingress summary if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) { const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0; @@ -741,6 +794,11 @@ export class DcRouter { logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); } + // VPN route security injection: restrict vpn.required routes to VPN subnet + if (this.options.vpnConfig?.enabled) { + routes = this.injectVpnSecurity(routes); + } + // Cache constructor routes for RouteConfigManager this.constructorRoutes = [...routes]; @@ -892,6 +950,22 @@ export class DcRouter { smartProxyConfig.proxyIPs = ['127.0.0.1']; } + // When VPN is in socket mode, the userspace NAT engine sends PP v2 headers + // on outbound connections to SmartProxy to preserve VPN client tunnel IPs. + if (this.options.vpnConfig?.enabled) { + const vpnForwardingMode = this.options.vpnConfig.forwardingMode + ?? (process.getuid?.() === 0 ? 'tun' : 'socket'); + if (vpnForwardingMode === 'socket') { + smartProxyConfig.acceptProxyProtocol = true; + if (!smartProxyConfig.proxyIPs) { + smartProxyConfig.proxyIPs = []; + } + if (!smartProxyConfig.proxyIPs.includes('127.0.0.1')) { + smartProxyConfig.proxyIPs.push('127.0.0.1'); + } + } + } + // Create SmartProxy instance logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`); @@ -1996,6 +2070,58 @@ export class DcRouter { logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`); } + /** + * Set up VPN server for VPN-based route access control. + */ + private async setupVpnServer(): Promise { + if (!this.options.vpnConfig?.enabled) { + return; + } + + logger.log('info', 'Setting up VPN server...'); + + this.vpnManager = new VpnManager(this.storageManager, { + subnet: this.options.vpnConfig.subnet, + wgListenPort: this.options.vpnConfig.wgListenPort, + dns: this.options.vpnConfig.dns, + serverEndpoint: this.options.vpnConfig.serverEndpoint, + forwardingMode: this.options.vpnConfig.forwardingMode, + }); + + await this.vpnManager.start(); + } + + /** + * Inject VPN security into routes that have vpn.required === true. + * Adds the VPN subnet to security.ipAllowList so only VPN clients can access them. + */ + private injectVpnSecurity(routes: plugins.smartproxy.IRouteConfig[]): plugins.smartproxy.IRouteConfig[] { + const vpnSubnet = this.options.vpnConfig?.subnet || '10.8.0.0/24'; + let injectedCount = 0; + + const result = routes.map((route) => { + const dcrouterRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig; + if (dcrouterRoute.vpn?.required) { + injectedCount++; + const existing = route.security?.ipAllowList || []; + return { + ...route, + security: { + ...route.security, + ipAllowList: [...existing, vpnSubnet], + }, + }; + } + return route; + }); + + if (injectedCount > 0) { + logger.log('info', `VPN: Injected ipAllowList (${vpnSubnet}) into ${injectedCount} VPN-protected route(s)`); + } + + return result; + } + /** * Set up RADIUS server for network authentication */ diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 53e19a3..0e1e070 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -7,6 +7,7 @@ import type { IMergedRoute, IRouteWarning, } from '../../ts_interfaces/data/route-management.js'; +import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js'; const ROUTES_PREFIX = '/config-api/routes/'; @@ -22,6 +23,7 @@ export class RouteConfigManager { private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getHttp3Config?: () => IHttp3Config | undefined, + private getVpnSubnet?: () => string | undefined, ) {} /** @@ -262,13 +264,28 @@ export class RouteConfigManager { // Add enabled programmatic routes (with HTTP/3 augmentation if enabled) const http3Config = this.getHttp3Config?.(); + const vpnSubnet = this.getVpnSubnet?.(); for (const stored of this.storedRoutes.values()) { if (stored.enabled) { + let route = stored.route; if (http3Config && http3Config.enabled !== false) { - enabledRoutes.push(augmentRouteWithHttp3(stored.route, { enabled: true, ...http3Config })); - } else { - enabledRoutes.push(stored.route); + route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); } + // Inject VPN security for programmatic routes with vpn.required + if (vpnSubnet) { + const dcRoute = route as IDcRouterRouteConfig; + if (dcRoute.vpn?.required) { + const existing = route.security?.ipAllowList || []; + route = { + ...route, + security: { + ...route.security, + ipAllowList: [...existing, vpnSubnet], + }, + }; + } + } + enabledRoutes.push(route); } } diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 96c253d..d145183 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -28,6 +28,7 @@ export class OpsServer { private remoteIngressHandler!: handlers.RemoteIngressHandler; private routeManagementHandler!: handlers.RouteManagementHandler; private apiTokenHandler!: handlers.ApiTokenHandler; + private vpnHandler!: handlers.VpnHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -86,6 +87,7 @@ export class OpsServer { this.remoteIngressHandler = new handlers.RemoteIngressHandler(this); this.routeManagementHandler = new handlers.RouteManagementHandler(this); this.apiTokenHandler = new handlers.ApiTokenHandler(this); + this.vpnHandler = new handlers.VpnHandler(this); console.log('✅ OpsServer TypedRequest handlers initialized'); } diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index e961c3b..e38eec2 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -8,4 +8,5 @@ export * from './email-ops.handler.js'; export * from './certificate.handler.js'; export * from './remoteingress.handler.js'; export * from './route-management.handler.js'; -export * from './api-token.handler.js'; \ No newline at end of file +export * from './api-token.handler.js'; +export * from './vpn.handler.js'; \ No newline at end of file diff --git a/ts/opsserver/handlers/vpn.handler.ts b/ts/opsserver/handlers/vpn.handler.ts new file mode 100644 index 0000000..415d8b9 --- /dev/null +++ b/ts/opsserver/handlers/vpn.handler.ts @@ -0,0 +1,257 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class VpnHandler { + constructor(private opsServerRef: OpsServer) { + this.registerHandlers(); + } + + private registerHandlers(): void { + const viewRouter = this.opsServerRef.viewRouter; + const adminRouter = this.opsServerRef.adminRouter; + + // ---- Read endpoints (viewRouter — valid identity required via middleware) ---- + + // Get all registered VPN clients + viewRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getVpnClients', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { clients: [] }; + } + const clients = manager.listClients().map((c) => ({ + clientId: c.clientId, + enabled: c.enabled, + tags: c.tags, + description: c.description, + assignedIp: c.assignedIp, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + expiresAt: c.expiresAt, + })); + return { clients }; + }, + ), + ); + + // Get VPN server status + viewRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getVpnStatus', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig; + if (!manager) { + return { + status: { + running: false, + forwardingMode: 'socket' as const, + subnet: vpnConfig?.subnet || '10.8.0.0/24', + wgListenPort: vpnConfig?.wgListenPort ?? 51820, + serverPublicKeys: null, + registeredClients: 0, + connectedClients: 0, + }, + }; + } + + const connected = await manager.getConnectedClients(); + return { + status: { + running: manager.running, + forwardingMode: manager.forwardingMode, + subnet: manager.getSubnet(), + wgListenPort: vpnConfig?.wgListenPort ?? 51820, + serverPublicKeys: manager.getServerPublicKeys(), + registeredClients: manager.listClients().length, + connectedClients: connected.length, + }, + }; + }, + ), + ); + + // ---- Write endpoints (adminRouter — admin identity required via middleware) ---- + + // Create a new VPN client + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createVpnClient', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + const bundle = await manager.createClient({ + clientId: dataArg.clientId, + tags: dataArg.tags, + description: dataArg.description, + }); + + return { + success: true, + client: { + clientId: bundle.entry.clientId, + enabled: bundle.entry.enabled ?? true, + tags: bundle.entry.tags, + description: bundle.entry.description, + assignedIp: bundle.entry.assignedIp, + createdAt: Date.now(), + updatedAt: Date.now(), + expiresAt: bundle.entry.expiresAt, + }, + wireguardConfig: bundle.wireguardConfig, + }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Delete a VPN client + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteVpnClient', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + await manager.removeClient(dataArg.clientId); + return { success: true }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Enable a VPN client + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'enableVpnClient', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + await manager.enableClient(dataArg.clientId); + return { success: true }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Disable a VPN client + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'disableVpnClient', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + await manager.disableClient(dataArg.clientId); + return { success: true }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Rotate a VPN client's keys + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'rotateVpnClientKey', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + const bundle = await manager.rotateClientKey(dataArg.clientId); + return { + success: true, + wireguardConfig: bundle.wireguardConfig, + }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Export a VPN client config + adminRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'exportVpnClientConfig', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + const config = await manager.exportClientConfig(dataArg.clientId, dataArg.format); + return { success: true, config }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + + // Get telemetry for a specific VPN client + viewRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getVpnClientTelemetry', + async (dataArg, toolsArg) => { + const manager = this.opsServerRef.dcRouterRef.vpnManager; + if (!manager) { + return { success: false, message: 'VPN not configured' }; + } + + try { + const telemetry = await manager.getClientTelemetry(dataArg.clientId); + if (!telemetry) { + return { success: false, message: 'Client not found or not connected' }; + } + return { + success: true, + telemetry: { + clientId: telemetry.clientId, + assignedIp: telemetry.assignedIp, + bytesSent: telemetry.bytesSent, + bytesReceived: telemetry.bytesReceived, + packetsDropped: telemetry.packetsDropped, + bytesDropped: telemetry.bytesDropped, + lastKeepaliveAt: telemetry.lastKeepaliveAt, + keepalivesReceived: telemetry.keepalivesReceived, + rateLimitBytesPerSec: telemetry.rateLimitBytesPerSec, + burstBytes: telemetry.burstBytes, + }, + }; + } catch (err: unknown) { + return { success: false, message: (err as Error).message }; + } + }, + ), + ); + } +} diff --git a/ts/plugins.ts b/ts/plugins.ts index a7cb298..bf5cb50 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -58,13 +58,14 @@ import * as smartnetwork from '@push.rocks/smartnetwork'; import * as smartpath from '@push.rocks/smartpath'; import * as smartproxy from '@push.rocks/smartproxy'; import * as smartpromise from '@push.rocks/smartpromise'; +import * as smartvpn from '@push.rocks/smartvpn'; import * as smartradius from '@push.rocks/smartradius'; import * as smartrequest from '@push.rocks/smartrequest'; import * as smartrx from '@push.rocks/smartrx'; import * as smartunique from '@push.rocks/smartunique'; import * as taskbuffer from '@push.rocks/taskbuffer'; -export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, taskbuffer }; +export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer }; // Define SmartLog types for use in error handling export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug'; diff --git a/ts/vpn/classes.vpn-manager.ts b/ts/vpn/classes.vpn-manager.ts new file mode 100644 index 0000000..722fcd4 --- /dev/null +++ b/ts/vpn/classes.vpn-manager.ts @@ -0,0 +1,378 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import type { StorageManager } from '../storage/classes.storagemanager.js'; + +const STORAGE_PREFIX_KEYS = '/vpn/server-keys'; +const STORAGE_PREFIX_CLIENTS = '/vpn/clients/'; + +export interface IVpnManagerConfig { + /** VPN subnet CIDR (default: '10.8.0.0/24') */ + subnet?: string; + /** WireGuard UDP listen port (default: 51820) */ + wgListenPort?: number; + /** DNS servers pushed to VPN clients */ + dns?: string[]; + /** Server endpoint hostname for client configs (e.g. 'vpn.example.com') */ + serverEndpoint?: string; + /** Override forwarding mode. Default: auto-detect (tun if root, socket otherwise) */ + forwardingMode?: 'tun' | 'socket'; +} + +interface IPersistedServerKeys { + noisePrivateKey: string; + noisePublicKey: string; + wgPrivateKey: string; + wgPublicKey: string; +} + +interface IPersistedClient { + clientId: string; + enabled: boolean; + tags?: string[]; + description?: string; + assignedIp?: string; + noisePublicKey: string; + wgPublicKey: string; + createdAt: number; + updatedAt: number; + expiresAt?: string; +} + +/** + * Manages the SmartVPN server lifecycle and VPN client CRUD. + * Persists server keys and client registrations via StorageManager. + */ +export class VpnManager { + private storageManager: StorageManager; + private config: IVpnManagerConfig; + private vpnServer?: plugins.smartvpn.VpnServer; + private clients: Map = new Map(); + private serverKeys?: IPersistedServerKeys; + private _forwardingMode: 'tun' | 'socket'; + + constructor(storageManager: StorageManager, config: IVpnManagerConfig) { + this.storageManager = storageManager; + this.config = config; + // Auto-detect forwarding mode: tun if root, socket otherwise + this._forwardingMode = config.forwardingMode + ?? (process.getuid?.() === 0 ? 'tun' : 'socket'); + } + + /** The effective forwarding mode (tun or socket). */ + public get forwardingMode(): 'tun' | 'socket' { + return this._forwardingMode; + } + + /** The VPN subnet CIDR. */ + public getSubnet(): string { + return this.config.subnet || '10.8.0.0/24'; + } + + /** Whether the VPN server is running. */ + public get running(): boolean { + return this.vpnServer?.running ?? false; + } + + /** + * Start the VPN server. + * Loads or generates server keys, loads persisted clients, starts VpnServer. + */ + public async start(): Promise { + // Load or generate server keys + this.serverKeys = await this.loadOrGenerateServerKeys(); + + // Load persisted clients + await this.loadPersistedClients(); + + // Build client entries for the daemon + const clientEntries: plugins.smartvpn.IClientEntry[] = []; + for (const client of this.clients.values()) { + clientEntries.push({ + clientId: client.clientId, + publicKey: client.noisePublicKey, + wgPublicKey: client.wgPublicKey, + enabled: client.enabled, + tags: client.tags, + description: client.description, + assignedIp: client.assignedIp, + expiresAt: client.expiresAt, + }); + } + + const subnet = this.getSubnet(); + const wgListenPort = this.config.wgListenPort ?? 51820; + + // Create and start VpnServer + this.vpnServer = new plugins.smartvpn.VpnServer({ + transport: { transport: 'stdio' }, + }); + + const serverConfig: plugins.smartvpn.IVpnServerConfig = { + listenAddr: '0.0.0.0:0', // WS listener not strictly needed but required field + privateKey: this.serverKeys.noisePrivateKey, + publicKey: this.serverKeys.noisePublicKey, + subnet, + dns: this.config.dns, + forwardingMode: this._forwardingMode, + transportMode: 'all', + wgPrivateKey: this.serverKeys.wgPrivateKey, + wgListenPort, + clients: clientEntries, + socketForwardProxyProtocol: this._forwardingMode === 'socket', + }; + + await this.vpnServer.start(serverConfig); + logger.log('info', `VPN server started: mode=${this._forwardingMode}, subnet=${subnet}, wg=:${wgListenPort}, clients=${this.clients.size}`); + } + + /** + * Stop the VPN server. + */ + public async stop(): Promise { + if (this.vpnServer) { + try { + await this.vpnServer.stopServer(); + } catch { + // Ignore stop errors + } + this.vpnServer.stop(); + this.vpnServer = undefined; + } + logger.log('info', 'VPN server stopped'); + } + + // ── Client CRUD ──────────────────────────────────────────────────────── + + /** + * Create a new VPN client. Returns the config bundle (secrets only shown once). + */ + public async createClient(opts: { + clientId: string; + tags?: string[]; + description?: string; + }): Promise { + if (!this.vpnServer) { + throw new Error('VPN server not running'); + } + + const bundle = await this.vpnServer.createClient({ + clientId: opts.clientId, + tags: opts.tags, + description: opts.description, + }); + + // Update WireGuard config endpoint if serverEndpoint is configured + if (this.config.serverEndpoint && bundle.wireguardConfig) { + const wgPort = this.config.wgListenPort ?? 51820; + bundle.wireguardConfig = bundle.wireguardConfig.replace( + /Endpoint\s*=\s*.+/, + `Endpoint = ${this.config.serverEndpoint}:${wgPort}`, + ); + } + + // Persist client entry (without private keys) + const persisted: IPersistedClient = { + clientId: bundle.entry.clientId, + enabled: bundle.entry.enabled ?? true, + tags: bundle.entry.tags, + description: bundle.entry.description, + assignedIp: bundle.entry.assignedIp, + noisePublicKey: bundle.entry.publicKey, + wgPublicKey: bundle.entry.wgPublicKey || '', + createdAt: Date.now(), + updatedAt: Date.now(), + expiresAt: bundle.entry.expiresAt, + }; + this.clients.set(persisted.clientId, persisted); + await this.persistClient(persisted); + + return bundle; + } + + /** + * Remove a VPN client. + */ + public async removeClient(clientId: string): Promise { + if (!this.vpnServer) { + throw new Error('VPN server not running'); + } + await this.vpnServer.removeClient(clientId); + this.clients.delete(clientId); + await this.storageManager.delete(`${STORAGE_PREFIX_CLIENTS}${clientId}`); + } + + /** + * List all registered clients (without secrets). + */ + public listClients(): IPersistedClient[] { + return [...this.clients.values()]; + } + + /** + * Enable a client. + */ + public async enableClient(clientId: string): Promise { + if (!this.vpnServer) throw new Error('VPN server not running'); + await this.vpnServer.enableClient(clientId); + const client = this.clients.get(clientId); + if (client) { + client.enabled = true; + client.updatedAt = Date.now(); + await this.persistClient(client); + } + } + + /** + * Disable a client. + */ + public async disableClient(clientId: string): Promise { + if (!this.vpnServer) throw new Error('VPN server not running'); + await this.vpnServer.disableClient(clientId); + const client = this.clients.get(clientId); + if (client) { + client.enabled = false; + client.updatedAt = Date.now(); + await this.persistClient(client); + } + } + + /** + * Rotate a client's keys. Returns the new config bundle. + */ + public async rotateClientKey(clientId: string): Promise { + if (!this.vpnServer) throw new Error('VPN server not running'); + const bundle = await this.vpnServer.rotateClientKey(clientId); + + // Update endpoint in WireGuard config + if (this.config.serverEndpoint && bundle.wireguardConfig) { + const wgPort = this.config.wgListenPort ?? 51820; + bundle.wireguardConfig = bundle.wireguardConfig.replace( + /Endpoint\s*=\s*.+/, + `Endpoint = ${this.config.serverEndpoint}:${wgPort}`, + ); + } + + // Update persisted entry with new public keys + const client = this.clients.get(clientId); + if (client) { + client.noisePublicKey = bundle.entry.publicKey; + client.wgPublicKey = bundle.entry.wgPublicKey || ''; + client.updatedAt = Date.now(); + await this.persistClient(client); + } + + return bundle; + } + + /** + * Export a client config (without secrets). + */ + public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise { + if (!this.vpnServer) throw new Error('VPN server not running'); + let config = await this.vpnServer.exportClientConfig(clientId, format); + + // Update endpoint in WireGuard config + if (format === 'wireguard' && this.config.serverEndpoint) { + const wgPort = this.config.wgListenPort ?? 51820; + config = config.replace( + /Endpoint\s*=\s*.+/, + `Endpoint = ${this.config.serverEndpoint}:${wgPort}`, + ); + } + + return config; + } + + // ── Status and telemetry ─────────────────────────────────────────────── + + /** + * Get server status. + */ + public async getStatus(): Promise { + if (!this.vpnServer) return null; + return this.vpnServer.getStatus(); + } + + /** + * Get server statistics. + */ + public async getStatistics(): Promise { + if (!this.vpnServer) return null; + return this.vpnServer.getStatistics(); + } + + /** + * List currently connected clients. + */ + public async getConnectedClients(): Promise { + if (!this.vpnServer) return []; + return this.vpnServer.listClients(); + } + + /** + * Get telemetry for a specific client. + */ + public async getClientTelemetry(clientId: string): Promise { + if (!this.vpnServer) return null; + return this.vpnServer.getClientTelemetry(clientId); + } + + /** + * Get server public keys (for display/info). + */ + public getServerPublicKeys(): { noisePublicKey: string; wgPublicKey: string } | null { + if (!this.serverKeys) return null; + return { + noisePublicKey: this.serverKeys.noisePublicKey, + wgPublicKey: this.serverKeys.wgPublicKey, + }; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + private async loadOrGenerateServerKeys(): Promise { + const stored = await this.storageManager.getJSON(STORAGE_PREFIX_KEYS); + if (stored?.noisePrivateKey && stored?.wgPrivateKey) { + logger.log('info', 'Loaded VPN server keys from storage'); + return stored; + } + + // Generate new keys via the daemon + const tempServer = new plugins.smartvpn.VpnServer({ + transport: { transport: 'stdio' }, + }); + await tempServer.start(); + + const noiseKeys = await tempServer.generateKeypair(); + const wgKeys = await tempServer.generateWgKeypair(); + tempServer.stop(); + + const keys: IPersistedServerKeys = { + noisePrivateKey: noiseKeys.privateKey, + noisePublicKey: noiseKeys.publicKey, + wgPrivateKey: wgKeys.privateKey, + wgPublicKey: wgKeys.publicKey, + }; + + await this.storageManager.setJSON(STORAGE_PREFIX_KEYS, keys); + logger.log('info', 'Generated and persisted new VPN server keys'); + return keys; + } + + private async loadPersistedClients(): Promise { + const keys = await this.storageManager.list(STORAGE_PREFIX_CLIENTS); + for (const key of keys) { + const client = await this.storageManager.getJSON(key); + if (client) { + this.clients.set(client.clientId, client); + } + } + if (this.clients.size > 0) { + logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`); + } + } + + private async persistClient(client: IPersistedClient): Promise { + await this.storageManager.setJSON(`${STORAGE_PREFIX_CLIENTS}${client.clientId}`, client); + } +} diff --git a/ts/vpn/index.ts b/ts/vpn/index.ts new file mode 100644 index 0000000..fb5deca --- /dev/null +++ b/ts/vpn/index.ts @@ -0,0 +1 @@ +export * from './classes.vpn-manager.js'; diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 79deb2a..5ca5c34 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -1,4 +1,5 @@ export * from './auth.js'; export * from './stats.js'; export * from './remoteingress.js'; -export * from './route-management.js'; \ No newline at end of file +export * from './route-management.js'; +export * from './vpn.js'; \ No newline at end of file diff --git a/ts_interfaces/data/remoteingress.ts b/ts_interfaces/data/remoteingress.ts index 0f8e6f7..bfcc240 100644 --- a/ts_interfaces/data/remoteingress.ts +++ b/ts_interfaces/data/remoteingress.ts @@ -51,11 +51,21 @@ export interface IRouteRemoteIngress { edgeFilter?: string[]; } +/** + * Route-level VPN access configuration. + * When attached to a route, restricts access to VPN clients only. + */ +export interface IRouteVpn { + /** Whether this route requires VPN access */ + required: boolean; +} + /** * Extended route config used within dcrouter. - * Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig. + * Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig. * SmartProxy ignores unknown properties at runtime. */ export type IDcRouterRouteConfig = IRouteConfig & { remoteIngress?: IRouteRemoteIngress; + vpn?: IRouteVpn; }; diff --git a/ts_interfaces/data/vpn.ts b/ts_interfaces/data/vpn.ts new file mode 100644 index 0000000..09f99ec --- /dev/null +++ b/ts_interfaces/data/vpn.ts @@ -0,0 +1,45 @@ +/** + * A registered VPN client (secrets excluded from API responses). + */ +export interface IVpnClient { + clientId: string; + enabled: boolean; + tags?: string[]; + description?: string; + assignedIp?: string; + createdAt: number; + updatedAt: number; + expiresAt?: string; +} + +/** + * VPN server status. + */ +export interface IVpnServerStatus { + running: boolean; + forwardingMode: 'tun' | 'socket'; + subnet: string; + wgListenPort: number; + serverPublicKeys: { + noisePublicKey: string; + wgPublicKey: string; + } | null; + registeredClients: number; + connectedClients: number; +} + +/** + * VPN client telemetry data. + */ +export interface IVpnClientTelemetry { + clientId: string; + assignedIp: string; + bytesSent: number; + bytesReceived: number; + packetsDropped: number; + bytesDropped: number; + lastKeepaliveAt?: string; + keepalivesReceived: number; + rateLimitBytesPerSec?: number; + burstBytes?: number; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index 83a0cba..b024db1 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -8,4 +8,5 @@ export * from './email-ops.js'; export * from './certificate.js'; export * from './remoteingress.js'; export * from './route-management.js'; -export * from './api-tokens.js'; \ No newline at end of file +export * from './api-tokens.js'; +export * from './vpn.js'; \ No newline at end of file diff --git a/ts_interfaces/requests/vpn.ts b/ts_interfaces/requests/vpn.ts new file mode 100644 index 0000000..7151561 --- /dev/null +++ b/ts_interfaces/requests/vpn.ts @@ -0,0 +1,175 @@ +import * as plugins from '../plugins.js'; +import * as authInterfaces from '../data/auth.js'; +import type { IVpnClient, IVpnServerStatus, IVpnClientTelemetry } from '../data/vpn.js'; + +// ============================================================================ +// VPN Client Management +// ============================================================================ + +/** + * Get all registered VPN clients. + */ +export interface IReq_GetVpnClients extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetVpnClients +> { + method: 'getVpnClients'; + request: { + identity: authInterfaces.IIdentity; + }; + response: { + clients: IVpnClient[]; + }; +} + +/** + * Get VPN server status. + */ +export interface IReq_GetVpnStatus extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetVpnStatus +> { + method: 'getVpnStatus'; + request: { + identity: authInterfaces.IIdentity; + }; + response: { + status: IVpnServerStatus; + }; +} + +/** + * Create a new VPN client. Returns the config bundle (secrets only shown once). + */ +export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateVpnClient +> { + method: 'createVpnClient'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + tags?: string[]; + description?: string; + }; + response: { + success: boolean; + client?: IVpnClient; + /** WireGuard .conf file content (only returned at creation) */ + wireguardConfig?: string; + message?: string; + }; +} + +/** + * Delete a VPN client. + */ +export interface IReq_DeleteVpnClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteVpnClient +> { + method: 'deleteVpnClient'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Enable a VPN client. + */ +export interface IReq_EnableVpnClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_EnableVpnClient +> { + method: 'enableVpnClient'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Disable a VPN client. + */ +export interface IReq_DisableVpnClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DisableVpnClient +> { + method: 'disableVpnClient'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +/** + * Rotate a VPN client's keys. Returns the new config bundle. + */ +export interface IReq_RotateVpnClientKey extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_RotateVpnClientKey +> { + method: 'rotateVpnClientKey'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + }; + response: { + success: boolean; + /** WireGuard .conf file content with new keys */ + wireguardConfig?: string; + message?: string; + }; +} + +/** + * Export a VPN client config. + */ +export interface IReq_ExportVpnClientConfig extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ExportVpnClientConfig +> { + method: 'exportVpnClientConfig'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + format: 'smartvpn' | 'wireguard'; + }; + response: { + success: boolean; + config?: string; + message?: string; + }; +} + +/** + * Get telemetry for a specific VPN client. + */ +export interface IReq_GetVpnClientTelemetry extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetVpnClientTelemetry +> { + method: 'getVpnClientTelemetry'; + request: { + identity: authInterfaces.IIdentity; + clientId: string; + }; + response: { + success: boolean; + telemetry?: IVpnClientTelemetry; + message?: string; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 06431aa..92e1eb4 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.12.4', + version: '11.13.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 7cd4ef9..de3cbb1 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -905,6 +905,161 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{ } }); +// ============================================================================ +// VPN State +// ============================================================================ + +export interface IVpnState { + clients: interfaces.data.IVpnClient[]; + status: interfaces.data.IVpnServerStatus | null; + isLoading: boolean; + error: string | null; + lastUpdated: number; + /** WireGuard config shown after create/rotate (only shown once) */ + newClientConfig: string | null; +} + +export const vpnStatePart = await appState.getStatePart( + 'vpn', + { + clients: [], + status: null, + isLoading: false, + error: null, + lastUpdated: 0, + newClientConfig: null, + }, + 'soft' +); + +// ============================================================================ +// VPN Actions +// ============================================================================ + +export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const clientsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetVpnClients + >('/typedrequest', 'getVpnClients'); + + const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetVpnStatus + >('/typedrequest', 'getVpnStatus'); + + const [clientsResponse, statusResponse] = await Promise.all([ + clientsRequest.fire({ identity: context.identity }), + statusRequest.fire({ identity: context.identity }), + ]); + + return { + ...currentState, + clients: clientsResponse.clients, + status: statusResponse.status, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch VPN data', + }; + } +}); + +export const createVpnClientAction = vpnStatePart.createAction<{ + clientId: string; + tags?: string[]; + description?: string; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateVpnClient + >('/typedrequest', 'createVpnClient'); + + const response = await request.fire({ + identity: context.identity!, + clientId: dataArg.clientId, + tags: dataArg.tags, + description: dataArg.description, + }); + + if (!response.success) { + return { ...currentState, error: response.message || 'Failed to create client' }; + } + + const refreshed = await actionContext!.dispatch(fetchVpnAction, null); + return { + ...refreshed, + newClientConfig: response.wireguardConfig || null, + }; + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to create VPN client', + }; + } +}); + +export const deleteVpnClientAction = vpnStatePart.createAction( + async (statePartArg, clientId, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteVpnClient + >('/typedrequest', 'deleteVpnClient'); + + await request.fire({ identity: context.identity!, clientId }); + return await actionContext!.dispatch(fetchVpnAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to delete VPN client', + }; + } + }, +); + +export const toggleVpnClientAction = vpnStatePart.createAction<{ + clientId: string; + enabled: boolean; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + + try { + const method = dataArg.enabled ? 'enableVpnClient' : 'disableVpnClient'; + type TReq = interfaces.requests.IReq_EnableVpnClient | interfaces.requests.IReq_DisableVpnClient; + const request = new plugins.domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', method, + ); + + await request.fire({ identity: context.identity!, clientId: dataArg.clientId }); + return await actionContext!.dispatch(fetchVpnAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to toggle VPN client', + }; + } +}); + +export const clearNewClientConfigAction = vpnStatePart.createAction( + async (statePartArg): Promise => { + return { ...statePartArg.getState()!, newClientConfig: null }; + }, +); + // ============================================================================ // Route Management Actions // ============================================================================ @@ -1372,6 +1527,15 @@ async function dispatchCombinedRefreshActionInner() { console.error('Remote ingress refresh failed:', error); } } + + // Refresh VPN data if on vpn view + if (currentView === 'vpn') { + try { + await vpnStatePart.dispatchAction(fetchVpnAction, null); + } catch (error) { + console.error('VPN refresh failed:', error); + } + } } catch (error) { console.error('Combined refresh failed:', error); // If the error looks like an auth failure (invalid JWT), force re-login diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 67f499d..7b8a92a 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -9,4 +9,5 @@ export * from './ops-view-apitokens.js'; export * from './ops-view-security.js'; export * from './ops-view-certificates.js'; export * from './ops-view-remoteingress.js'; +export * from './ops-view-vpn.js'; export * from './shared/index.js'; \ No newline at end of file diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 8adcec9..92a7f8d 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -24,6 +24,7 @@ import { OpsViewApiTokens } from './ops-view-apitokens.js'; import { OpsViewSecurity } from './ops-view-security.js'; import { OpsViewCertificates } from './ops-view-certificates.js'; import { OpsViewRemoteIngress } from './ops-view-remoteingress.js'; +import { OpsViewVpn } from './ops-view-vpn.js'; @customElement('ops-dashboard') export class OpsDashboard extends DeesElement { @@ -92,6 +93,11 @@ export class OpsDashboard extends DeesElement { iconName: 'lucide:globe', element: OpsViewRemoteIngress, }, + { + name: 'VPN', + iconName: 'lucide:shield', + element: OpsViewVpn, + }, ]; /** diff --git a/ts_web/elements/ops-view-vpn.ts b/ts_web/elements/ops-view-vpn.ts new file mode 100644 index 0000000..6ab7313 --- /dev/null +++ b/ts_web/elements/ops-view-vpn.ts @@ -0,0 +1,330 @@ +import { + DeesElement, + html, + customElement, + type TemplateResult, + css, + state, + cssManager, +} from '@design.estate/dees-element'; +import * as appstate from '../appstate.js'; +import * as interfaces from '../../dist_ts_interfaces/index.js'; +import { viewHostCss } from './shared/css.js'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-vpn': OpsViewVpn; + } +} + +@customElement('ops-view-vpn') +export class OpsViewVpn extends DeesElement { + @state() + accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.vpnStatePart.select().subscribe((newState) => { + this.vpnState = newState; + }); + this.rxSubscriptions.push(sub); + } + + async connectedCallback() { + await super.connectedCallback(); + await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .vpnContainer { + display: flex; + flex-direction: column; + gap: 24px; + } + + .statusBadge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .statusBadge.enabled { + background: ${cssManager.bdTheme('#dcfce7', '#14532d')}; + color: ${cssManager.bdTheme('#166534', '#4ade80')}; + } + + .statusBadge.disabled { + background: ${cssManager.bdTheme('#fef2f2', '#450a0a')}; + color: ${cssManager.bdTheme('#991b1b', '#f87171')}; + } + + .configDialog { + padding: 16px; + background: ${cssManager.bdTheme('#fffbeb', '#1c1917')}; + border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')}; + border-radius: 8px; + margin-bottom: 16px; + } + + .configDialog pre { + display: block; + padding: 12px; + background: ${cssManager.bdTheme('#1f2937', '#111827')}; + color: #10b981; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + margin: 8px 0; + user-select: all; + max-height: 300px; + overflow-y: auto; + } + + .configDialog .warning { + font-size: 12px; + color: ${cssManager.bdTheme('#92400e', '#fbbf24')}; + margin-top: 8px; + } + + .tagBadge { + display: inline-flex; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + background: ${cssManager.bdTheme('#eff6ff', '#172554')}; + color: ${cssManager.bdTheme('#1e40af', '#60a5fa')}; + margin-right: 4px; + } + + .serverInfo { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + padding: 16px; + background: ${cssManager.bdTheme('#f9fafb', '#111827')}; + border-radius: 8px; + border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#1f2937')}; + } + + .serverInfo .infoItem { + display: flex; + flex-direction: column; + gap: 4px; + } + + .serverInfo .infoLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; + } + + .serverInfo .infoValue { + font-size: 14px; + font-family: monospace; + color: ${cssManager.bdTheme('#111827', '#f9fafb')}; + } + `, + ]; + + render(): TemplateResult { + const status = this.vpnState.status; + const clients = this.vpnState.clients; + const connectedCount = status?.connectedClients ?? 0; + const totalClients = clients.length; + const enabledClients = clients.filter(c => c.enabled).length; + + const statsTiles: IStatsTile[] = [ + { + id: 'totalClients', + title: 'Total Clients', + type: 'number', + value: totalClients, + icon: 'lucide:users', + description: 'Registered VPN clients', + color: '#3b82f6', + }, + { + id: 'connectedClients', + title: 'Connected', + type: 'number', + value: connectedCount, + icon: 'lucide:link', + description: 'Currently connected', + color: '#10b981', + }, + { + id: 'enabledClients', + title: 'Enabled', + type: 'number', + value: enabledClients, + icon: 'lucide:shieldCheck', + description: 'Active client registrations', + color: '#8b5cf6', + }, + { + id: 'serverStatus', + title: 'Server', + type: 'text', + value: status?.running ? 'Running' : 'Stopped', + icon: 'lucide:server', + description: status?.running ? `${status.forwardingMode} mode` : 'VPN server not running', + color: status?.running ? '#10b981' : '#ef4444', + }, + ]; + + return html` + VPN + + ${this.vpnState.newClientConfig ? html` +
+ Client created successfully! +
Copy the WireGuard config now. It contains private keys that won't be shown again.
+
${this.vpnState.newClientConfig}
+ { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(this.vpnState.newClientConfig!); + } + const { DeesToast } = await import('@design.estate/dees-catalog'); + DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 }); + }} + >Copy to Clipboard + { + const blob = new Blob([this.vpnState.newClientConfig!], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'wireguard.conf'; + a.click(); + URL.revokeObjectURL(url); + }} + >Download .conf + appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)} + >Dismiss +
+ ` : ''} + + + + ${status ? html` +
+
+ Subnet + ${status.subnet} +
+
+ WireGuard Port + ${status.wgListenPort} +
+
+ Forwarding Mode + ${status.forwardingMode} +
+ ${status.serverPublicKeys ? html` +
+ WG Public Key + ${status.serverPublicKeys.wgPublicKey} +
+ ` : ''} +
+ ` : ''} + + ({ + 'Client ID': client.clientId, + 'Status': client.enabled + ? html`enabled` + : html`disabled`, + 'VPN IP': client.assignedIp || '-', + 'Tags': client.tags?.length + ? html`${client.tags.map(t => html`${t}`)}` + : '-', + 'Description': client.description || '-', + 'Created': new Date(client.createdAt).toLocaleDateString(), + })} + .dataActions=${[ + { + name: 'Toggle', + iconName: 'lucide:power', + action: async (client: interfaces.data.IVpnClient) => { + await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, { + clientId: client.clientId, + enabled: !client.enabled, + }); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + action: async (client: interfaces.data.IVpnClient) => { + const { DeesModal } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Delete VPN Client', + content: html`

Are you sure you want to delete client "${client.clientId}"?

`, + menuOptions: [ + { name: 'Cancel', action: async (modal: any) => modal.destroy() }, + { + name: 'Delete', + action: async (modal: any) => { + await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId); + modal.destroy(); + }, + }, + ], + }); + }, + }, + ]} + .createNewItem=${async () => { + const { DeesModal, DeesForm, DeesInputText } = await import('@design.estate/dees-catalog'); + DeesModal.createAndShow({ + heading: 'Create VPN Client', + content: html` + + + + + + `, + menuOptions: [ + { name: 'Cancel', action: async (modal: any) => modal.destroy() }, + { + name: 'Create', + 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; + await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, { + clientId: data.clientId, + description: data.description || undefined, + tags, + }); + modal.destroy(); + }, + }, + ], + }); + }} + >
+ `; + } +}