Browse Source

feat(cli): add command to show qr code (#2518)

* refactor cli, add commands

* add docs

* improve

* fix ec mode order
pull/2521/head
Bernd Storath 3 months ago
committed by GitHub
parent
commit
5228734c98
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 28
      docs/content/guides/cli.md
  2. 71
      src/cli/admin/reset.ts
  3. 29
      src/cli/clients/list.ts
  4. 71
      src/cli/clients/qr.ts
  5. 10
      src/cli/db.ts
  6. 102
      src/cli/index.ts
  7. 12
      src/cli/tsconfig.json
  8. 20
      src/server/utils/WireGuard.ts
  9. 41
      src/server/utils/qr.ts
  10. 4
      src/server/utils/wgHelper.ts

28
docs/content/guides/cli.md

@ -41,3 +41,31 @@ docker compose exec -it wg-easy cli db:admin:reset --password <new_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. 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 <client_id>
```
Replace `<client_id>` 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 <client_id> --no-ipv6
```
///

71
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})`
);
},
});

29
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);
},
});

71
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));
},
});

10
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 };

102
src/cli/index.ts

@ -1,84 +1,38 @@
#!/usr/bin/env node #!/usr/bin/env node
// ! Auto Imports are not supported in this file import type { Resolvable, SubCommandsDef } from 'citty';
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import { defineCommand, runMain } from 'citty'; import { defineCommand, runMain } from 'citty';
import { consola } from 'consola';
import { eq } from 'drizzle-orm';
import packageJson from '../package.json'; 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({ // Commands
meta: { import dbAdminReset from './admin/reset';
name: 'db:admin:reset', import clientsList from './clients/list';
description: 'Reset the admin user password and TOTP settings', import clientsQr from './clients/qr';
}, const subCommands = [dbAdminReset, clientsList, clientsQr] as const;
args: {
password: { // from citty
type: 'string', function resolveValue<T>(input: Resolvable<T>): T | Promise<T> {
description: 'New password for the admin user', // eslint-disable-next-line @typescript-eslint/no-explicit-any
required: false, return typeof input === 'function' ? (input as any)() : input;
}, }
},
async run(ctx) { async function generateSubCommands(): Promise<SubCommandsDef> {
let password = ctx.args.password || undefined; const subCommandsMap: Record<string, SubCommandsDef[string]> = {};
if (!password) {
password = await consola.prompt('Please enter a new password:', { for (const cmd of subCommands) {
type: 'text', const cmdMeta = await resolveValue(cmd.meta || {});
}); if (!cmdMeta.name) {
} console.warn('Skipping command without name:', cmd);
if (!password) { continue;
consola.error('Password is required');
return;
} }
if (password.length < 12) { subCommandsMap[cmdMeta.name] = cmd;
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();
if (!user) { return subCommandsMap;
consola.error('Admin user not found'); }
return;
}
await tx const subCommandsMap = await generateSubCommands();
.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 main = defineCommand({ const main = defineCommand({
meta: { meta: {
@ -86,9 +40,7 @@ const main = defineCommand({
version: packageJson.version, version: packageJson.version,
description: 'Command Line Interface', description: 'Command Line Interface',
}, },
subCommands: { subCommands: subCommandsMap,
'db:admin:reset': dbAdminReset,
},
}); });
runMain(main); runMain(main);

12
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"]
}

20
src/server/utils/WireGuard.ts

@ -1,6 +1,5 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import debug from 'debug'; import debug from 'debug';
import { encodeQR } from 'qr';
import type { InterfaceType } from '#db/repositories/interface/types'; import type { InterfaceType } from '#db/repositories/interface/types';
const WG_DEBUG = debug('WireGuard'); const WG_DEBUG = debug('WireGuard');
@ -181,24 +180,7 @@ class WireGuard {
async getClientQRCodeSVG({ clientId }: { clientId: ID }) { async getClientQRCodeSVG({ clientId }: { clientId: ID }) {
const config = await this.getClientConfiguration({ clientId }); const config = await this.getClientConfiguration({ clientId });
const ECMode = ['high', 'quartile', 'medium', 'low'] as const; return encodeQRCode(config);
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'
);
} }
cleanClientFilename(name: string): string { cleanClientFilename(name: string): string {

41
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<T>(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'
);
}

4
src/server/utils/wgHelper.ts

@ -9,7 +9,9 @@ type Options = {
enableIpv6?: boolean; 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 = { export const wg = {
generateServerPeer: ( generateServerPeer: (

Loading…
Cancel
Save