import fs from 'node:fs/promises'; import path from 'path'; import debug_logger from 'debug'; import crypto from 'node:crypto'; import QRCode from 'qrcode'; import CRC32 from 'crc-32'; const debug = debug_logger('WireGuard'); type Server = { privateKey: string; publicKey: string; address: string; }; type Client = { id: string; name: string; address: string; privateKey: string; publicKey: string; preSharedKey: string; createdAt: string; updatedAt: string; expireAt: string | null; endpoint: string | null; enabled: boolean; allowedIPs?: never; oneTimeLink: string | null; oneTimeLinkExpiresAt: string | null; }; type Config = { server: Server; clients: Record; }; class WireGuard { #configCache: Config | null = null; async __buildConfig() { if (!WG_HOST) { throw new Error('WG_HOST Environment Variable Not Set!'); } debug('Loading configuration...'); // TODO: Better way to invalidate cache this.#configCache = null; try { const config = await fs.readFile(path.join(WG_PATH, 'wg0.json'), 'utf8'); const parsedConfig = JSON.parse(config); debug('Configuration loaded.'); return parsedConfig as Config; } catch { const privateKey = await exec('wg genkey'); const publicKey = await exec(`echo ${privateKey} | wg pubkey`, { log: 'echo ***hidden*** | wg pubkey', }); const address = WG_DEFAULT_ADDRESS.replace('x', '1'); const config: Config = { server: { privateKey, publicKey, address, }, clients: {}, }; debug('Configuration generated.'); return config; } } async getConfig(): Promise { if (this.#configCache !== null) { return this.#configCache; } const config = await this.__buildConfig(); await this.__saveConfig(config); await exec('wg-quick down wg0').catch(() => {}); await exec('wg-quick up wg0').catch((err) => { if ( err && err.message && err.message.includes('Cannot find device "wg0"') ) { throw new Error( 'WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!' ); } throw err; }); // await Util.exec(`iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ' + WG_DEVICE + ' -j MASQUERADE`); // await Util.exec('iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT'); // await Util.exec('iptables -A FORWARD -i wg0 -j ACCEPT'); // await Util.exec('iptables -A FORWARD -o wg0 -j ACCEPT'); await this.__syncConfig(); this.#configCache = config; return this.#configCache; } async saveConfig() { const config = await this.getConfig(); await this.__saveConfig(config); await this.__syncConfig(); } async __saveConfig(config: Config) { let result = ` # Note: Do not edit this file directly. # Your changes will be overwritten! # Server [Interface] PrivateKey = ${config.server.privateKey} Address = ${config.server.address}/24 ListenPort = ${WG_PORT} PreUp = ${WG_PRE_UP} PostUp = ${WG_POST_UP} PreDown = ${WG_PRE_DOWN} PostDown = ${WG_POST_DOWN} `; for (const [clientId, client] of Object.entries(config.clients)) { if (!client.enabled) continue; result += ` # Client: ${client.name} (${clientId}) [Peer] PublicKey = ${client.publicKey} ${ client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : '' }AllowedIPs = ${client.address}/32`; } debug('Config saving...'); await fs.writeFile( path.join(WG_PATH, 'wg0.json'), JSON.stringify(config, undefined, 2), { mode: 0o660, } ); await fs.writeFile(path.join(WG_PATH, 'wg0.conf'), result, { mode: 0o600, }); debug('Config saved.'); } async __syncConfig() { debug('Config syncing...'); await exec('wg syncconf wg0 <(wg-quick strip wg0)'); debug('Config synced.'); } async getClients() { const config = await this.getConfig(); const clients = Object.entries(config.clients).map( ([clientId, client]) => ({ id: clientId, name: client.name, enabled: client.enabled, address: client.address, publicKey: client.publicKey, createdAt: new Date(client.createdAt), updatedAt: new Date(client.updatedAt), expireAt: client.expireAt !== null ? new Date(client.expireAt) : null, allowedIPs: client.allowedIPs, oneTimeLink: client.oneTimeLink ?? null, oneTimeLinkExpiresAt: client.oneTimeLinkExpiresAt ?? null, downloadableConfig: 'privateKey' in client, persistentKeepalive: null as string | null, latestHandshakeAt: null as Date | null, endpoint: null as string | null, transferRx: null as number | null, transferTx: null as number | null, }) ); // Loop WireGuard status const dump = await exec('wg show wg0 dump', { log: false, }); dump .trim() .split('\n') .slice(1) .forEach((line) => { const [ publicKey, _preSharedKey, endpoint, _allowedIps, latestHandshakeAt, transferRx, transferTx, persistentKeepalive, ] = line.split('\t'); const client = clients.find((client) => client.publicKey === publicKey); if (!client) return; client.latestHandshakeAt = latestHandshakeAt === '0' ? null : new Date(Number(`${latestHandshakeAt}000`)); client.endpoint = endpoint === '(none)' ? null : (endpoint ?? null); client.transferRx = Number(transferRx); client.transferTx = Number(transferTx); client.persistentKeepalive = persistentKeepalive ?? null; }); return clients; } async getClient({ clientId }: { clientId: string }) { const config = await this.getConfig(); const client = config.clients[clientId]; if (!client) { throw createError({ statusCode: 404, statusMessage: `Client Not Found: ${clientId}`, }); } return client; } async getClientConfiguration({ clientId }: { clientId: string }) { const config = await this.getConfig(); const client = await this.getClient({ clientId }); return ` [Interface] PrivateKey = ${client.privateKey ? `${client.privateKey}` : 'REPLACE_ME'} Address = ${client.address}/24 ${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\ ${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\ [Peer] PublicKey = ${config.server.publicKey} ${ client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : '' }AllowedIPs = ${WG_ALLOWED_IPS} PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE} Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; } async getClientQRCodeSVG({ clientId }: { clientId: string }) { const config = await this.getClientConfiguration({ clientId }); return QRCode.toString(config, { type: 'svg', width: 512, }); } async createClient({ name, expireDate, }: { name: string; expireDate: string; }) { if (!name) { throw new Error('Missing: Name'); } const config = await this.getConfig(); const privateKey = await exec('wg genkey'); const publicKey = await exec(`echo ${privateKey} | wg pubkey`, { log: 'echo ***hidden*** | wg pubkey', }); const preSharedKey = await exec('wg genpsk'); // Calculate next IP let address; for (let i = 2; i < 255; i++) { const client = Object.values(config.clients).find((client) => { return client.address === WG_DEFAULT_ADDRESS.replace('x', i.toString()); }); if (!client) { address = WG_DEFAULT_ADDRESS.replace('x', i.toString()); break; } } if (!address) { throw new Error('Maximum number of clients reached.'); } // Create Client const id = crypto.randomUUID(); const client: Client = { id, name, address, privateKey, publicKey, preSharedKey, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), endpoint: null, oneTimeLink: null, oneTimeLinkExpiresAt: null, expireAt: null, enabled: true, }; if (expireDate) { const date = new Date(expireDate); date.setHours(23); date.setMinutes(59); date.setSeconds(59); client.expireAt = date.toISOString(); } config.clients[id] = client; await this.saveConfig(); return client; } async deleteClient({ clientId }: { clientId: string }) { const config = await this.getConfig(); if (config.clients[clientId]) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete config.clients[clientId]; await this.saveConfig(); } } async enableClient({ clientId }: { clientId: string }) { const client = await this.getClient({ clientId }); client.enabled = true; client.updatedAt = new Date().toISOString(); await this.saveConfig(); } async generateOneTimeLink({ clientId }: { clientId: string }) { const client = await this.getClient({ clientId }); const key = `${clientId}-${Math.floor(Math.random() * 1000)}`; client.oneTimeLink = Math.abs(CRC32.str(key)).toString(16); client.oneTimeLinkExpiresAt = new Date( Date.now() + 5 * 60 * 1000 ).toISOString(); client.updatedAt = new Date().toISOString(); await this.saveConfig(); } async eraseOneTimeLink({ clientId }: { clientId: string }) { const client = await this.getClient({ clientId }); client.oneTimeLink = null; client.oneTimeLinkExpiresAt = null; client.updatedAt = new Date().toISOString(); await this.saveConfig(); } async disableClient({ clientId }: { clientId: string }) { const client = await this.getClient({ clientId }); client.enabled = false; client.updatedAt = new Date().toISOString(); await this.saveConfig(); } async updateClientName({ clientId, name, }: { clientId: string; name: string; }) { const client = await this.getClient({ clientId }); client.name = name; client.updatedAt = new Date().toISOString(); await this.saveConfig(); } async updateClientAddress({ clientId, address, }: { clientId: string; address: string; }) { const client = await this.getClient({ clientId }); if (!isValidIPv4(address)) { throw createError({ statusCode: 400, statusMessage: `Invalid Address: ${address}`, }); } client.address = address; client.updatedAt = new Date().toISOString(); await this.saveConfig(); } async updateClientExpireDate({ clientId, expireDate, }: { clientId: string; expireDate: string; }) { const client = await this.getClient({ clientId }); if (expireDate) { const date = new Date(expireDate); date.setHours(23); date.setMinutes(59); date.setSeconds(59); client.expireAt = date.toISOString(); } else { client.expireAt = null; } client.updatedAt = new Date().toISOString(); await this.saveConfig(); } async __reloadConfig() { await this.__buildConfig(); await this.__syncConfig(); } async restoreConfiguration(config: string) { debug('Starting configuration restore process.'); // TODO: sanitize config const _config = JSON.parse(config); await this.__saveConfig(_config); await this.__reloadConfig(); debug('Configuration restore process completed.'); } async backupConfiguration() { debug('Starting configuration backup.'); const config = await this.getConfig(); const backup = JSON.stringify(config, null, 2); debug('Configuration backup completed.'); return backup; } // Shutdown wireguard async Shutdown() { await exec('wg-quick down wg0').catch(() => {}); } async cronJobEveryMinute() { const config = await this.getConfig(); let needSaveConfig = false; // Expires Feature if (WG_ENABLE_EXPIRES_TIME === 'true') { for (const client of Object.values(config.clients)) { if (client.enabled !== true) continue; if ( client.expireAt !== null && new Date() > new Date(client.expireAt) ) { debug(`Client ${client.id} expired.`); needSaveConfig = true; client.enabled = false; client.updatedAt = new Date().toISOString(); } } } // One Time Link Feature if (WG_ENABLE_ONE_TIME_LINKS === 'true') { for (const client of Object.values(config.clients)) { if ( client.oneTimeLink !== null && client.oneTimeLinkExpiresAt !== null && new Date() > new Date(client.oneTimeLinkExpiresAt) ) { debug(`Client ${client.id} One Time Link expired.`); needSaveConfig = true; client.oneTimeLink = null; client.oneTimeLinkExpiresAt = null; client.updatedAt = new Date().toISOString(); } } } if (needSaveConfig) { await this.saveConfig(); } } async getMetrics() { const clients = await this.getClients(); let wireguardPeerCount = 0; let wireguardEnabledPeersCount = 0; let wireguardConnectedPeersCount = 0; let wireguardSentBytes = ''; let wireguardReceivedBytes = ''; let wireguardLatestHandshakeSeconds = ''; for (const client of Object.values(clients)) { wireguardPeerCount++; if (client.enabled === true) { wireguardEnabledPeersCount++; } if (client.endpoint !== null) { wireguardConnectedPeersCount++; } wireguardSentBytes += `wireguard_sent_bytes{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${Number(client.transferTx)}\n`; wireguardReceivedBytes += `wireguard_received_bytes{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${Number(client.transferRx)}\n`; wireguardLatestHandshakeSeconds += `wireguard_latest_handshake_seconds{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${client.latestHandshakeAt ? (new Date().getTime() - new Date(client.latestHandshakeAt).getTime()) / 1000 : 0}\n`; } let returnText = '# HELP wg-easy and wireguard metrics\n'; returnText += '\n# HELP wireguard_configured_peers\n'; returnText += '# TYPE wireguard_configured_peers gauge\n'; returnText += `wireguard_configured_peers{interface="wg0"} ${Number(wireguardPeerCount)}\n`; returnText += '\n# HELP wireguard_enabled_peers\n'; returnText += '# TYPE wireguard_enabled_peers gauge\n'; returnText += `wireguard_enabled_peers{interface="wg0"} ${Number(wireguardEnabledPeersCount)}\n`; returnText += '\n# HELP wireguard_connected_peers\n'; returnText += '# TYPE wireguard_connected_peers gauge\n'; returnText += `wireguard_connected_peers{interface="wg0"} ${Number(wireguardConnectedPeersCount)}\n`; returnText += '\n# HELP wireguard_sent_bytes Bytes sent to the peer\n'; returnText += '# TYPE wireguard_sent_bytes counter\n'; returnText += `${wireguardSentBytes}`; returnText += '\n# HELP wireguard_received_bytes Bytes received from the peer\n'; returnText += '# TYPE wireguard_received_bytes counter\n'; returnText += `${wireguardReceivedBytes}`; returnText += '\n# HELP wireguard_latest_handshake_seconds UNIX timestamp seconds of the last handshake\n'; returnText += '# TYPE wireguard_latest_handshake_seconds gauge\n'; returnText += `${wireguardLatestHandshakeSeconds}`; return returnText; } async getMetricsJSON() { const clients = await this.getClients(); let wireguardPeerCount = 0; let wireguardEnabledPeersCount = 0; let wireguardConnectedPeersCount = 0; for (const client of Object.values(clients)) { wireguardPeerCount++; if (client.enabled === true) { wireguardEnabledPeersCount++; } if (client.endpoint !== null) { wireguardConnectedPeersCount++; } } return { wireguard_configured_peers: Number(wireguardPeerCount), wireguard_enabled_peers: Number(wireguardEnabledPeersCount), wireguard_connected_peers: Number(wireguardConnectedPeersCount), }; } } const inst = new WireGuard(); inst.getConfig().catch((err) => { console.error(err); process.exit(1); }); async function cronJobEveryMinute() { await inst.cronJobEveryMinute(); setTimeout(cronJobEveryMinute, 60 * 1000); } cronJobEveryMinute(); export default inst;