diff --git a/src/server/utils/WireGuard.ts b/src/server/utils/WireGuard.ts index e72c2d95..7804c1bd 100644 --- a/src/server/utils/WireGuard.ts +++ b/src/server/utils/WireGuard.ts @@ -1,127 +1,39 @@ import fs from 'node:fs/promises'; import path from 'path'; -import debug_logger from 'debug'; +import debug 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; -}; +import type { NewClient } from '~~/services/database/repositories/client'; -class WireGuard { - #configCache: Config | null = null; - async __buildConfig() { - if (!WG_HOST) { - throw new Error('WG_HOST Environment Variable Not Set!'); - } - - debug('Loading configuration...'); - 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; - } +const DEBUG = debug('WireGuard'); +class WireGuard { async saveConfig() { - const config = await this.getConfig(); - await this.__saveConfig(config); - await this.__syncConfig(); + await this.#saveWireguardConfig(); + await this.#syncWireguardConfig(); } - async __saveConfig(config: Config) { + async #saveWireguardConfig() { + const system = await Database.getSystem(); + const clients = await Database.getClients(); 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} +PrivateKey = ${system.interface.privateKey} +Address = ${system.interface.address}/24 +ListenPort = ${system.wgPort} +PreUp = ${system.iptables.PreUp} +PostUp = ${system.iptables.PostUp} +PreDown = ${system.iptables.PreDown} +PostDown = ${system.iptables.PostDown} `; - for (const [clientId, client] of Object.entries(config.clients)) { + for (const [clientId, client] of Object.entries(clients)) { if (!client.enabled) continue; result += ` @@ -134,49 +46,39 @@ ${ }AllowedIPs = ${client.address}/32`; } - debug('Config saving...'); - await fs.writeFile( - path.join(WG_PATH, 'wg0.json'), - JSON.stringify(config, undefined, 2), - { - mode: 0o660, - } - ); + DEBUG('Config saving...'); await fs.writeFile(path.join(WG_PATH, 'wg0.conf'), result, { mode: 0o600, }); - debug('Config saved.'); + DEBUG('Config saved.'); } - async __syncConfig() { - debug('Config syncing...'); + async #syncWireguardConfig() { + DEBUG('Config syncing...'); await exec('wg syncconf wg0 <(wg-quick strip wg0)'); - debug('Config synced.'); + 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, - }) - ); + const dbClients = await Database.getClients(); + const clients = Object.entries(dbClients).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), + expiresAt: client.expiresAt, + allowedIPs: client.allowedIPs, + oneTimeLink: client.oneTimeLink, + 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', { @@ -215,8 +117,7 @@ ${ } async getClient({ clientId }: { clientId: string }) { - const config = await this.getConfig(); - const client = config.clients[clientId]; + const client = await Database.getClient(clientId); if (!client) { throw createError({ statusCode: 404, @@ -228,7 +129,7 @@ ${ } async getClientConfiguration({ clientId }: { clientId: string }) { - const config = await this.getConfig(); + const system = await Database.getSystem(); const client = await this.getClient({ clientId }); return ` @@ -239,11 +140,11 @@ ${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\ ${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\ [Peer] -PublicKey = ${config.server.publicKey} +PublicKey = ${system.interface.publicKey} ${ client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : '' -}AllowedIPs = ${WG_ALLOWED_IPS} -PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE} +}AllowedIPs = ${client.allowedIPs} +PersistentKeepalive = ${client.persistentKeepalive} Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; } @@ -266,7 +167,7 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; throw new Error('Missing: Name'); } - const config = await this.getConfig(); + const clients = await Database.getClients(); const privateKey = await exec('wg genkey'); const publicKey = await exec(`echo ${privateKey} | wg pubkey`, { @@ -274,10 +175,11 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; }); const preSharedKey = await exec('wg genpsk'); + // TODO: cidr // Calculate next IP let address; for (let i = 2; i < 255; i++) { - const client = Object.values(config.clients).find((client) => { + const client = Object.values(clients).find((client) => { return client.address === WG_DEFAULT_ADDRESS.replace('x', i.toString()); }); @@ -293,22 +195,20 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; // Create Client const id = crypto.randomUUID(); - const client: Client = { + + const client: NewClient = { id, name, address, privateKey, publicKey, preSharedKey, - - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - endpoint: null, oneTimeLink: null, - oneTimeLinkExpiresAt: null, - expireAt: null, + expiresAt: null, enabled: true, + allowedIPs: WG_ALLOWED_IPS.split(', '), + persistentKeepalive: Number(WG_PERSISTENT_KEEPALIVE), }; if (expireDate) { @@ -316,10 +216,10 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; date.setHours(23); date.setMinutes(59); date.setSeconds(59); - client.expireAt = date.toISOString(); + client.expiresAt = date; } - config.clients[id] = client; + await Database.createClient(client); await this.saveConfig(); @@ -327,48 +227,34 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; } 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(); - } + await Database.deleteClient(clientId); + await this.saveConfig(); } async enableClient({ clientId }: { clientId: string }) { - const client = await this.getClient({ clientId }); - - client.enabled = true; - client.updatedAt = new Date().toISOString(); + await Database.toggleClient(clientId, true); 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(); + const oneTimeLink = Math.abs(CRC32.str(key)).toString(16); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + await Database.createOneTimeLink(clientId, { + oneTimeLink, + expiresAt, + }); 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 Database.deleteOneTimeLink(clientId); await this.saveConfig(); } async disableClient({ clientId }: { clientId: string }) { - const client = await this.getClient({ clientId }); - - client.enabled = false; - client.updatedAt = new Date().toISOString(); + await Database.toggleClient(clientId, false); await this.saveConfig(); } @@ -380,10 +266,7 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; clientId: string; name: string; }) { - const client = await this.getClient({ clientId }); - - client.name = name; - client.updatedAt = new Date().toISOString(); + await Database.updateClientName(clientId, name); await this.saveConfig(); } @@ -395,8 +278,6 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; clientId: string; address: string; }) { - const client = await this.getClient({ clientId }); - if (!isValidIPv4(address)) { throw createError({ statusCode: 400, @@ -404,8 +285,7 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; }); } - client.address = address; - client.updatedAt = new Date().toISOString(); + await Database.updateClientAddress(clientId, address); await this.saveConfig(); } @@ -417,42 +297,38 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; clientId: string; expireDate: string | null; }) { - const client = await this.getClient({ clientId }); + let updatedDate: Date | null = null; if (expireDate) { const date = new Date(expireDate); date.setHours(23); date.setMinutes(59); date.setSeconds(59); - client.expireAt = date.toISOString(); - } else { - client.expireAt = null; + updatedDate = date; } - client.updatedAt = new Date().toISOString(); - await this.saveConfig(); - } + await Database.updateClientExpirationDate(clientId, updatedDate); - async __reloadConfig() { - await this.__buildConfig(); - await this.__syncConfig(); + await this.saveConfig(); } - async restoreConfiguration(config: string) { - debug('Starting configuration restore process.'); + // TODO: reimplement database restore + 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.'); + DEBUG('Configuration restore process completed.'); */ } + // TODO: reimplement database restore async backupConfiguration() { - debug('Starting configuration backup.'); + /* DEBUG('Starting configuration backup.'); const config = await this.getConfig(); const backup = JSON.stringify(config, null, 2); - debug('Configuration backup completed.'); - return backup; + DEBUG('Configuration backup completed.'); + return backup; */ } // Shutdown wireguard @@ -461,46 +337,30 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`; } async cronJobEveryMinute() { - const config = await this.getConfig(); + const clients = await Database.getClients(); const system = await Database.getSystem(); - if (!system) { - throw new Error('Invalid Database'); - } - let needSaveConfig = false; // Expires Feature if (system.clientExpiration.enabled) { - for (const client of Object.values(config.clients)) { + for (const client of Object.values(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(); + if (client.expiresAt !== null && new Date() > client.expiresAt) { + DEBUG(`Client ${client.id} expired.`); + await Database.toggleClient(client.id, false); } } } // One Time Link Feature if (system.oneTimeLinks.enabled) { - for (const client of Object.values(config.clients)) { + for (const client of Object.values(clients)) { if ( client.oneTimeLink !== null && - client.oneTimeLinkExpiresAt !== null && - new Date() > new Date(client.oneTimeLinkExpiresAt) + new Date() > client.oneTimeLink.expiresAt ) { - debug(`Client ${client.id} One Time Link expired.`); - needSaveConfig = true; - client.oneTimeLink = null; - client.oneTimeLinkExpiresAt = null; - client.updatedAt = new Date().toISOString(); + DEBUG(`Client ${client.id} One Time Link expired.`); + await Database.deleteOneTimeLink(client.id); } } } - if (needSaveConfig) { - await this.saveConfig(); - } } async getMetrics() { @@ -582,7 +442,7 @@ const inst = new WireGuard(); // This also has to also start the WireGuard Server async function cronJobEveryMinute() { await inst.cronJobEveryMinute().catch((err) => { - debug('Running Cron Job failed.'); + DEBUG('Running Cron Job failed.'); console.error(err); }); setTimeout(cronJobEveryMinute, 60 * 1000); diff --git a/src/services/database/lowdb.ts b/src/services/database/lowdb.ts index 7ed7fbbb..b155a3de 100644 --- a/src/services/database/lowdb.ts +++ b/src/services/database/lowdb.ts @@ -57,6 +57,7 @@ export default class LowDB extends DatabaseProvider { return system; } + // TODO: return copy to avoid mutation (everywhere) async getUsers() { return this.#db.data.users; } diff --git a/src/services/database/migrations/1.ts b/src/services/database/migrations/1.ts index 7da1a511..b9fbbe1c 100644 --- a/src/services/database/migrations/1.ts +++ b/src/services/database/migrations/1.ts @@ -83,6 +83,7 @@ export async function run1(db: Low) { }, }, users: [], + clients: [], }; db.data = database; diff --git a/src/services/database/repositories/client.ts b/src/services/database/repositories/client.ts new file mode 100644 index 00000000..a84faa72 --- /dev/null +++ b/src/services/database/repositories/client.ts @@ -0,0 +1,43 @@ +export type OneTimeLink = { + oneTimeLink: string; + expiresAt: Date; +}; + +export type Client = { + id: string; + name: string; + address: string; + privateKey: string; + publicKey: string; + preSharedKey: string; + expiresAt: Date | null; + endpoint: string | null; + allowedIPs: string[]; + oneTimeLink: OneTimeLink | null; + createdAt: Date; + updatedAt: Date; + enabled: boolean; + persistentKeepalive: number; +}; + +export type NewClient = Omit; + +/** + * Interface for client-related database operations. + * This interface provides methods for managing client data. + */ +export interface ClientRepository { + getClients(): Promise>; + getClient(id: string): Promise; + createClient(client: NewClient): Promise; + deleteClient(id: string): Promise; + toggleClient(id: string, enable: boolean): Promise; + updateClientName(id: string, name: string): Promise; + updateClientAddress(id: string, address: string): Promise; + updateClientExpirationDate( + id: string, + expirationDate: Date | null + ): Promise; + deleteOneTimeLink(id: string): Promise; + createOneTimeLink(id: string, oneTimeLink: OneTimeLink): Promise; +} diff --git a/src/services/database/repositories/database.ts b/src/services/database/repositories/database.ts index ce1c6505..1e7ca39c 100644 --- a/src/services/database/repositories/database.ts +++ b/src/services/database/repositories/database.ts @@ -1,3 +1,9 @@ +import type { + ClientRepository, + Client, + NewClient, + OneTimeLink, +} from './client'; import type { System, SystemRepository } from './system'; import type { User, UserRepository } from './user'; @@ -6,12 +12,14 @@ export type Database = { migrations: string[]; system: System; users: User[]; + clients: Record; }; export const DEFAULT_DATABASE: Database = { migrations: [], system: null as never, users: [], + clients: {}, }; /** @@ -22,7 +30,7 @@ export const DEFAULT_DATABASE: Database = { * */ export abstract class DatabaseProvider - implements SystemRepository, UserRepository + implements SystemRepository, UserRepository, ClientRepository { /** * Connects to the database. @@ -44,6 +52,23 @@ export abstract class DatabaseProvider ): Promise; abstract updateUser(user: User): Promise; abstract deleteUser(id: string): Promise; + + abstract getClients(): Promise>; + abstract getClient(id: string): Promise; + abstract createClient(client: NewClient): Promise; + abstract deleteClient(id: string): Promise; + abstract toggleClient(id: string, enable: boolean): Promise; + abstract updateClientName(id: string, name: string): Promise; + abstract updateClientAddress(id: string, address: string): Promise; + abstract updateClientExpirationDate( + id: string, + expirationDate: Date | null + ): Promise; + abstract deleteOneTimeLink(id: string): Promise; + abstract createOneTimeLink( + id: string, + oneTimeLink: OneTimeLink + ): Promise; } /**