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; /** 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; /** Destination routing policy override. Default: forceTarget to 127.0.0.1 */ destinationPolicy?: { default: 'forceTarget' | 'block' | 'allow'; target?: string; allowList?: string[]; blockList?: string[]; }; /** Compute per-client AllowedIPs based on the client's server-defined tags. * Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs. * When not set, defaults to [subnet]. */ getClientAllowedIPs?: (clientTags: string[]) => Promise; } interface IPersistedServerKeys { noisePrivateKey: string; noisePublicKey: string; wgPrivateKey: string; wgPublicKey: string; } interface IPersistedClient { clientId: string; enabled: boolean; serverDefinedClientTags?: string[]; description?: string; assignedIp?: string; noisePublicKey: string; wgPublicKey: string; /** WireGuard private key — stored so exports and QR codes produce valid configs */ wgPrivateKey?: string; createdAt: number; updatedAt: number; expiresAt?: string; /** @deprecated Legacy field — migrated to serverDefinedClientTags on load */ tags?: 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; constructor(storageManager: StorageManager, config: IVpnManagerConfig) { this.storageManager = storageManager; this.config = config; } /** 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, serverDefinedClientTags: client.serverDefinedClientTags, 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: 'socket', transportMode: 'all', wgPrivateKey: this.serverKeys.wgPrivateKey, wgListenPort, clients: clientEntries, socketForwardProxyProtocol: true, destinationPolicy: this.config.destinationPolicy ?? { default: 'forceTarget' as const, target: '127.0.0.1' }, serverEndpoint: this.config.serverEndpoint ? `${this.config.serverEndpoint}:${wgListenPort}` : undefined, clientAllowedIPs: [subnet], }; 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: 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; serverDefinedClientTags?: string[]; description?: string; }): Promise { if (!this.vpnServer) { throw new Error('VPN server not running'); } const bundle = await this.vpnServer.createClient({ clientId: opts.clientId, serverDefinedClientTags: opts.serverDefinedClientTags, description: opts.description, }); // Override AllowedIPs with per-client values based on tag-matched routes if (this.config.getClientAllowedIPs && bundle.wireguardConfig) { const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []); bundle.wireguardConfig = bundle.wireguardConfig.replace( /AllowedIPs\s*=\s*.+/, `AllowedIPs = ${allowedIPs.join(', ')}`, ); } // Persist client entry (including WG private key for export/QR) const persisted: IPersistedClient = { clientId: bundle.entry.clientId, enabled: bundle.entry.enabled ?? true, serverDefinedClientTags: bundle.entry.serverDefinedClientTags, description: bundle.entry.description, assignedIp: bundle.entry.assignedIp, noisePublicKey: bundle.entry.publicKey, wgPublicKey: bundle.entry.wgPublicKey || '', wgPrivateKey: bundle.secrets?.wgPrivateKey || bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(), createdAt: Date.now(), updatedAt: Date.now(), expiresAt: bundle.entry.expiresAt, }; this.clients.set(persisted.clientId, persisted); await this.persistClient(persisted); this.config.onClientChanged?.(); 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}`); this.config.onClientChanged?.(); } /** * 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); } this.config.onClientChanged?.(); } /** * 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); } this.config.onClientChanged?.(); } /** * 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 persisted entry with new keys (including private key for export/QR) const client = this.clients.get(clientId); if (client) { client.noisePublicKey = bundle.entry.publicKey; client.wgPublicKey = bundle.entry.wgPublicKey || ''; client.wgPrivateKey = bundle.secrets?.wgPrivateKey || bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(); client.updatedAt = Date.now(); await this.persistClient(client); } return bundle; } /** * Export a client config. Injects stored WG private key and per-client AllowedIPs. */ 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); if (format === 'wireguard') { const persisted = this.clients.get(clientId); // Inject stored WG private key so exports produce valid, scannable configs if (persisted?.wgPrivateKey) { config = config.replace( '[Interface]\n', `[Interface]\nPrivateKey = ${persisted.wgPrivateKey}\n`, ); } // Override AllowedIPs with per-client values based on tag-matched routes if (this.config.getClientAllowedIPs) { const clientTags = persisted?.serverDefinedClientTags || []; const allowedIPs = await this.config.getClientAllowedIPs(clientTags); config = config.replace( /AllowedIPs\s*=\s*.+/, `AllowedIPs = ${allowedIPs.join(', ')}`, ); } } 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 ─────────────────────────────────────────────── /** * 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) { // 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); } } 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); } }