diff --git a/docs/content/guides/cli.md b/docs/content/guides/cli.md index 5a4b5a07..f563e427 100644 --- a/docs/content/guides/cli.md +++ b/docs/content/guides/cli.md @@ -41,3 +41,31 @@ docker compose exec -it wg-easy cli db:admin:reset --password ``` This will reset the password for the admin user to the new password you provided. If you include special characters in the password, make sure to escape them properly. + +### Show Clients + +List all clients that are currently configured with details such as client ID, Name, Public Key, and enabled status. + +```shell +cli clients:list +``` + +### Show Client QR Code + +Display the QR code for a specific client, which can be scanned by a compatible app to import the client's configuration. + +```shell +cli clients:qr +``` + +Replace `` with the actual client ID you want to show the QR code for. + +/// warning | IPv6 Support + +IPv6 support is enabled by default, even if you disabled it using environment variables. To disable it pass the `--no-ipv6` flag when running the CLI. + +```shell +cli clients:qr --no-ipv6 +``` + +/// diff --git a/src/cli/admin/reset.ts b/src/cli/admin/reset.ts new file mode 100644 index 00000000..07743d2b --- /dev/null +++ b/src/cli/admin/reset.ts @@ -0,0 +1,71 @@ +import { defineCommand } from 'citty'; +import { consola } from 'consola'; +import { eq } from 'drizzle-orm'; + +import { db, schema } from '../db'; +import { hashPassword } from '../../server/utils/password'; + +export default defineCommand({ + meta: { + name: 'db:admin:reset', + description: 'Reset the admin user password and TOTP settings', + }, + args: { + password: { + type: 'string', + description: 'New password for the admin user', + required: false, + }, + }, + async run(ctx) { + let password = ctx.args.password || undefined; + if (!password) { + password = await consola.prompt('Please enter a new password:', { + type: 'text', + }); + } + if (!password) { + consola.error('Password is required'); + return; + } + if (password.length < 12) { + consola.error('Password must be at least 12 characters long'); + return; + } + consola.info('Setting new password for admin user...'); + const hash = await hashPassword(password); + + const user = await db.transaction(async (tx) => { + const user = await tx + .select() + .from(schema.user) + .where(eq(schema.user.id, 1)) + .get(); + + if (!user) { + consola.error('Admin user not found'); + return; + } + + await tx + .update(schema.user) + .set({ + password: hash, + totpVerified: false, + totpKey: null, + }) + .where(eq(schema.user.id, 1)); + + return user; + }); + + if (!user) { + consola.error('Failed to update admin user'); + return; + } + + consola.success( + `Successfully updated admin user ${user.id} (${user.username})` + ); + }, +}); diff --git a/src/cli/clients/list.ts b/src/cli/clients/list.ts new file mode 100644 index 00000000..3b45958d --- /dev/null +++ b/src/cli/clients/list.ts @@ -0,0 +1,29 @@ +import { defineCommand } from 'citty'; +import { consola } from 'consola'; + +import { db } from '../db'; + +export default defineCommand({ + meta: { + name: 'clients:list', + description: 'List all clients', + }, + async run() { + consola.info('Listing all clients...'); + const clients = await db.query.client.findMany({ + columns: { + id: true, + name: true, + publicKey: true, + enabled: true, + }, + }); + + if (clients.length === 0) { + consola.info('No clients found'); + return; + } + + console.table(clients); + }, +}); diff --git a/src/cli/clients/qr.ts b/src/cli/clients/qr.ts new file mode 100644 index 00000000..9c76d9ad --- /dev/null +++ b/src/cli/clients/qr.ts @@ -0,0 +1,71 @@ +import { defineCommand } from 'citty'; +import { consola } from 'consola'; +import { eq } from 'drizzle-orm'; + +import { wg } from '../../server/utils/wgHelper'; +import { encodeQRCodeTerm } from '../../server/utils/qr'; +import { db, schema } from '../db'; + +export default defineCommand({ + meta: { + name: 'clients:qr', + description: 'Generate QR code for a client', + }, + args: { + id: { + required: true, + type: 'positional', + }, + ipv6: { + required: false, + type: 'boolean', + default: true, + }, + }, + async run(ctx) { + const clientId = Number(ctx.args.id); + const enableIpv6 = ctx.args.ipv6; + + if (Number.isNaN(clientId)) { + consola.error('Invalid client ID'); + return; + } + + consola.info('Generating QR code for client...'); + + const wgInterface = await db.query.wgInterface.findFirst({ + where: eq(schema.wgInterface.name, 'wg0'), + }); + if (!wgInterface) { + consola.error('WireGuard interface not found'); + return; + } + + const userConfig = await db.query.userConfig.findFirst({ + where: eq(schema.userConfig.id, 'wg0'), + }); + if (!userConfig) { + consola.error('User config not found'); + return; + } + + const client = await db.query.client.findFirst({ + where: eq(schema.client.id, clientId), + }); + if (!client) { + consola.error(`Client with ID ${clientId} not found`); + return; + } + + const clientConfig = wg.generateClientConfig( + wgInterface, + userConfig, + client, + { + enableIpv6, + } + ); + + consola.log(encodeQRCodeTerm(clientConfig)); + }, +}); diff --git a/src/cli/db.ts b/src/cli/db.ts new file mode 100644 index 00000000..70945378 --- /dev/null +++ b/src/cli/db.ts @@ -0,0 +1,10 @@ +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; + +import * as schema from '../server/database/schema'; + +//const client = createClient({ url: 'file:../data/wg-easy.db' }); +const client = createClient({ url: 'file:/etc/wireguard/wg-easy.db' }); +export const db = drizzle({ client, schema }); + +export { schema }; diff --git a/src/cli/index.ts b/src/cli/index.ts index a3d2da04..69de8314 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,84 +1,38 @@ #!/usr/bin/env node -// ! Auto Imports are not supported in this file - -import { drizzle } from 'drizzle-orm/libsql'; -import { createClient } from '@libsql/client'; +import type { Resolvable, SubCommandsDef } from 'citty'; import { defineCommand, runMain } from 'citty'; -import { consola } from 'consola'; -import { eq } from 'drizzle-orm'; import packageJson from '../package.json'; -import * as schema from '../server/database/schema'; -import { hashPassword } from '../server/utils/password'; - -const client = createClient({ url: 'file:/etc/wireguard/wg-easy.db' }); -const db = drizzle({ client, schema }); -const dbAdminReset = defineCommand({ - meta: { - name: 'db:admin:reset', - description: 'Reset the admin user password and TOTP settings', - }, - args: { - password: { - type: 'string', - description: 'New password for the admin user', - required: false, - }, - }, - async run(ctx) { - let password = ctx.args.password || undefined; - if (!password) { - password = await consola.prompt('Please enter a new password:', { - type: 'text', - }); - } - if (!password) { - consola.error('Password is required'); - return; +// Commands +import dbAdminReset from './admin/reset'; +import clientsList from './clients/list'; +import clientsQr from './clients/qr'; +const subCommands = [dbAdminReset, clientsList, clientsQr] as const; + +// from citty +function resolveValue(input: Resolvable): T | Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return typeof input === 'function' ? (input as any)() : input; +} + +async function generateSubCommands(): Promise { + const subCommandsMap: Record = {}; + + for (const cmd of subCommands) { + const cmdMeta = await resolveValue(cmd.meta || {}); + if (!cmdMeta.name) { + console.warn('Skipping command without name:', cmd); + continue; } - if (password.length < 12) { - consola.error('Password must be at least 12 characters long'); - return; - } - console.info('Setting new password for admin user...'); - const hash = await hashPassword(password); - - const user = await db.transaction(async (tx) => { - const user = await tx - .select() - .from(schema.user) - .where(eq(schema.user.id, 1)) - .get(); + subCommandsMap[cmdMeta.name] = cmd; + } - if (!user) { - consola.error('Admin user not found'); - return; - } + return subCommandsMap; +} - await tx - .update(schema.user) - .set({ - password: hash, - totpVerified: false, - totpKey: null, - }) - .where(eq(schema.user.id, 1)); - - return user; - }); - - if (!user) { - consola.error('Failed to update admin user'); - return; - } - - consola.success( - `Successfully updated admin user ${user.id} (${user.username})` - ); - }, -}); +const subCommandsMap = await generateSubCommands(); const main = defineCommand({ meta: { @@ -86,9 +40,7 @@ const main = defineCommand({ version: packageJson.version, description: 'Command Line Interface', }, - subCommands: { - 'db:admin:reset': dbAdminReset, - }, + subCommands: subCommandsMap, }); runMain(main); diff --git a/src/cli/tsconfig.json b/src/cli/tsconfig.json new file mode 100644 index 00000000..6a1a3386 --- /dev/null +++ b/src/cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "es2024", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler" + }, + "include": ["./**/*.ts"] +} diff --git a/src/server/utils/WireGuard.ts b/src/server/utils/WireGuard.ts index 52a8cea4..b5f0dd42 100644 --- a/src/server/utils/WireGuard.ts +++ b/src/server/utils/WireGuard.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises'; import debug from 'debug'; -import { encodeQR } from 'qr'; import type { InterfaceType } from '#db/repositories/interface/types'; const WG_DEBUG = debug('WireGuard'); @@ -181,24 +180,7 @@ class WireGuard { async getClientQRCodeSVG({ clientId }: { clientId: ID }) { const config = await this.getClientConfiguration({ clientId }); - const ECMode = ['high', 'quartile', 'medium', 'low'] as const; - for (const ecc of ECMode) { - try { - return encodeQR(config, 'svg', { - ecc, - scale: 2, - encoding: 'byte', - }); - } catch (err) { - if (!(err instanceof Error && err.message === 'Capacity overflow')) { - throw err; - } - // retry with lower ecc - } - } - throw new Error( - 'Failed to generate QR code: Capacity overflow at all ECC levels' - ); + return encodeQRCode(config); } cleanClientFilename(name: string): string { diff --git a/src/server/utils/qr.ts b/src/server/utils/qr.ts new file mode 100644 index 00000000..a597be7a --- /dev/null +++ b/src/server/utils/qr.ts @@ -0,0 +1,41 @@ +// ! Auto Imports are not supported in this file + +import type { ErrorCorrection } from 'qr'; +import { encodeQR } from 'qr'; + +export function encodeQRCode(config: string): string { + return tryECCModes((ecc) => { + return encodeQR(config, 'svg', { + ecc, + scale: 2, + encoding: 'byte', + }); + }); +} + +export function encodeQRCodeTerm(config: string): string { + return tryECCModes((ecc) => { + return encodeQR(config, 'term', { + ecc, + encoding: 'byte', + }); + }); +} + +function tryECCModes(callback: (ecc: ErrorCorrection) => T): T { + // defined manually, as qr's ECMode is in wrong order + const ECMode = ['high', 'quartile', 'medium', 'low'] as const; + for (const ecc of ECMode) { + try { + return callback(ecc); + } catch (err) { + if (!(err instanceof Error && err.message === 'Capacity overflow')) { + throw err; + } + // retry with lower ecc + } + } + throw new Error( + 'Failed to generate QR code: Capacity overflow at all ECC levels' + ); +} diff --git a/src/server/utils/wgHelper.ts b/src/server/utils/wgHelper.ts index 7f69d6c5..733b0e56 100644 --- a/src/server/utils/wgHelper.ts +++ b/src/server/utils/wgHelper.ts @@ -9,7 +9,9 @@ type Options = { enableIpv6?: boolean; }; -const wgExecutable = WG_ENV.WG_EXECUTABLE; +// needed to support cli +const wgExecutable = + typeof WG_ENV !== 'undefined' ? WG_ENV.WG_EXECUTABLE : 'dev'; export const wg = { generateServerPeer: (