import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { VpnServerKeysDoc, VpnClientDoc } from '../db/index.js'; 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; } /** * Manages the SmartVPN server lifecycle and VPN client CRUD. * Persists server keys and client registrations via smartdata document classes. */ export class VpnManager { private config: IVpnManagerConfig; private vpnServer?: plugins.smartvpn.VpnServer; private clients: Map = new Map(); private serverKeys?: VpnServerKeysDoc; constructor(config: IVpnManagerConfig) { 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 doc = new VpnClientDoc(); doc.clientId = bundle.entry.clientId; doc.enabled = bundle.entry.enabled ?? true; doc.serverDefinedClientTags = bundle.entry.serverDefinedClientTags; doc.description = bundle.entry.description; doc.assignedIp = bundle.entry.assignedIp; doc.noisePublicKey = bundle.entry.publicKey; doc.wgPublicKey = bundle.entry.wgPublicKey || ''; doc.wgPrivateKey = bundle.secrets?.wgPrivateKey || bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim(); doc.createdAt = Date.now(); doc.updatedAt = Date.now(); doc.expiresAt = bundle.entry.expiresAt; this.clients.set(doc.clientId, doc); await this.persistClient(doc); 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); const doc = this.clients.get(clientId); this.clients.delete(clientId); if (doc) { await doc.delete(); } this.config.onClientChanged?.(); } /** * List all registered clients (without secrets). */ public listClients(): VpnClientDoc[] { 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?.(); } /** * Update a client's metadata (description, tags) without rotating keys. */ public async updateClient(clientId: string, update: { description?: string; serverDefinedClientTags?: string[]; }): Promise { const client = this.clients.get(clientId); if (!client) throw new Error(`Client not found: ${clientId}`); if (update.description !== undefined) client.description = update.description; if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags; 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 VpnServerKeysDoc.load(); 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 doc = stored || new VpnServerKeysDoc(); doc.noisePrivateKey = noiseKeys.privateKey; doc.noisePublicKey = noiseKeys.publicKey; doc.wgPrivateKey = wgKeys.privateKey; doc.wgPublicKey = wgKeys.publicKey; await doc.save(); logger.log('info', 'Generated and persisted new VPN server keys'); return doc; } private async loadPersistedClients(): Promise { const docs = await VpnClientDoc.findAll(); for (const doc of docs) { // Migrate legacy `tags` → `serverDefinedClientTags` if (!doc.serverDefinedClientTags && (doc as any).tags) { doc.serverDefinedClientTags = (doc as any).tags; (doc as any).tags = undefined; await doc.save(); } this.clients.set(doc.clientId, doc); } if (this.clients.size > 0) { logger.log('info', `Loaded ${this.clients.size} persisted VPN client(s)`); } } private async persistClient(client: VpnClientDoc): Promise { await client.save(); } }