import * as plugins from './smartvpn.plugins.js'; import type { IVpnClientConfig, IVpnServerConfig, } from './smartvpn.interfaces.js'; /** * VPN configuration loader, saver, and validator. */ export class VpnConfig { /** * Validate a client config object. Throws on invalid config. */ public static validateClientConfig(config: IVpnClientConfig): void { if (config.transport === 'wireguard') { // WireGuard-specific validation if (!config.wgPrivateKey) { throw new Error('VpnConfig: wgPrivateKey is required for WireGuard transport'); } VpnConfig.validateBase64Key(config.wgPrivateKey, 'wgPrivateKey'); if (!config.wgAddress) { throw new Error('VpnConfig: wgAddress is required for WireGuard transport'); } if (!config.serverPublicKey) { throw new Error('VpnConfig: serverPublicKey is required for WireGuard transport'); } VpnConfig.validateBase64Key(config.serverPublicKey, 'serverPublicKey'); if (!config.wgEndpoint) { throw new Error('VpnConfig: wgEndpoint is required for WireGuard transport'); } if (config.wgPresharedKey) { VpnConfig.validateBase64Key(config.wgPresharedKey, 'wgPresharedKey'); } if (config.wgAllowedIps) { for (const cidr of config.wgAllowedIps) { if (!VpnConfig.isValidCidr(cidr)) { throw new Error(`VpnConfig: invalid allowedIp CIDR: ${cidr}`); } } } } else { if (!config.serverUrl) { throw new Error('VpnConfig: serverUrl is required'); } // For QUIC-only transport, serverUrl is a host:port address; for WebSocket/auto it must be ws:// or wss:// if (config.transport !== 'quic') { if (!config.serverUrl.startsWith('wss://') && !config.serverUrl.startsWith('ws://')) { throw new Error('VpnConfig: serverUrl must start with wss:// or ws:// (for WebSocket transport)'); } } if (!config.serverPublicKey) { throw new Error('VpnConfig: serverPublicKey is required'); } } if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) { throw new Error('VpnConfig: mtu must be between 576 and 65535'); } if (config.keepaliveIntervalSecs !== undefined && config.keepaliveIntervalSecs < 1) { throw new Error('VpnConfig: keepaliveIntervalSecs must be >= 1'); } if (config.dns) { for (const dns of config.dns) { if (!VpnConfig.isValidIp(dns)) { throw new Error(`VpnConfig: invalid DNS address: ${dns}`); } } } } /** * Validate a server config object. Throws on invalid config. */ public static validateServerConfig(config: IVpnServerConfig): void { if (config.transportMode === 'wireguard') { // WireGuard server validation if (!config.privateKey) { throw new Error('VpnConfig: privateKey is required'); } VpnConfig.validateBase64Key(config.privateKey, 'privateKey'); if (!config.wgPeers || config.wgPeers.length === 0) { throw new Error('VpnConfig: at least one wgPeers entry is required for WireGuard mode'); } for (const peer of config.wgPeers) { if (!peer.publicKey) { throw new Error('VpnConfig: peer publicKey is required'); } VpnConfig.validateBase64Key(peer.publicKey, 'peer.publicKey'); if (!peer.allowedIps || peer.allowedIps.length === 0) { throw new Error('VpnConfig: peer allowedIps is required'); } for (const cidr of peer.allowedIps) { if (!VpnConfig.isValidCidr(cidr)) { throw new Error(`VpnConfig: invalid peer allowedIp CIDR: ${cidr}`); } } if (peer.presharedKey) { VpnConfig.validateBase64Key(peer.presharedKey, 'peer.presharedKey'); } } if (config.wgListenPort !== undefined && (config.wgListenPort < 1 || config.wgListenPort > 65535)) { throw new Error('VpnConfig: wgListenPort must be between 1 and 65535'); } } else { if (!config.listenAddr) { throw new Error('VpnConfig: listenAddr is required'); } if (!config.privateKey) { throw new Error('VpnConfig: privateKey is required'); } if (!config.publicKey) { throw new Error('VpnConfig: publicKey is required'); } if (!config.subnet) { throw new Error('VpnConfig: subnet is required'); } if (!VpnConfig.isValidSubnet(config.subnet)) { throw new Error(`VpnConfig: invalid subnet: ${config.subnet}`); } } if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) { throw new Error('VpnConfig: mtu must be between 576 and 65535'); } if (config.keepaliveIntervalSecs !== undefined && config.keepaliveIntervalSecs < 1) { throw new Error('VpnConfig: keepaliveIntervalSecs must be >= 1'); } } /** * Load a config from a JSON file. */ public static async loadFromFile(filePath: string): Promise { const content = await plugins.fs.promises.readFile(filePath, 'utf-8'); return JSON.parse(content) as T; } /** * Save a config to a JSON file. */ public static async saveToFile(filePath: string, config: T): Promise { const content = JSON.stringify(config, null, 2); await plugins.fs.promises.writeFile(filePath, content, 'utf-8'); } /** * Basic IP address validation. */ private static isValidIp(ip: string): boolean { const parts = ip.split('.'); if (parts.length !== 4) return false; return parts.every((part) => { const num = parseInt(part, 10); return !isNaN(num) && num >= 0 && num <= 255 && String(num) === part; }); } /** * Basic subnet validation (CIDR notation). */ private static isValidSubnet(subnet: string): boolean { const [ip, prefix] = subnet.split('/'); if (!ip || !prefix) return false; if (!VpnConfig.isValidIp(ip)) return false; const prefixNum = parseInt(prefix, 10); return !isNaN(prefixNum) && prefixNum >= 0 && prefixNum <= 32; } /** * Validate a CIDR string (IPv4 or IPv6). */ private static isValidCidr(cidr: string): boolean { const parts = cidr.split('/'); if (parts.length !== 2) return false; const prefixNum = parseInt(parts[1], 10); if (isNaN(prefixNum) || prefixNum < 0) return false; // IPv4 if (VpnConfig.isValidIp(parts[0])) { return prefixNum <= 32; } // IPv6 (basic check) if (parts[0].includes(':')) { return prefixNum <= 128; } return false; } /** * Validate a base64-encoded 32-byte key (WireGuard X25519 format). */ private static validateBase64Key(key: string, fieldName: string): void { if (key.length !== 44) { throw new Error(`VpnConfig: ${fieldName} must be 44 characters (base64 of 32 bytes), got ${key.length}`); } try { const buf = Buffer.from(key, 'base64'); if (buf.length !== 32) { throw new Error(`VpnConfig: ${fieldName} must decode to 32 bytes, got ${buf.length}`); } } catch (e) { if (e instanceof Error && e.message.startsWith('VpnConfig:')) throw e; throw new Error(`VpnConfig: ${fieldName} is not valid base64`); } } }