feat(cli): add command to show qr code (#2518)
* refactor cli, add commands * add docs * improve * fix ec mode order
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
///
|
||||||
|
|||||||
@@ -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})`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
+26
-74
@@ -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' });
|
// Commands
|
||||||
const db = drizzle({ client, schema });
|
import dbAdminReset from './admin/reset';
|
||||||
|
import clientsList from './clients/list';
|
||||||
|
import clientsQr from './clients/qr';
|
||||||
|
const subCommands = [dbAdminReset, clientsList, clientsQr] as const;
|
||||||
|
|
||||||
const dbAdminReset = defineCommand({
|
// from citty
|
||||||
meta: {
|
function resolveValue<T>(input: Resolvable<T>): T | Promise<T> {
|
||||||
name: 'db:admin:reset',
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
description: 'Reset the admin user password and TOTP settings',
|
return typeof input === 'function' ? (input as any)() : input;
|
||||||
},
|
}
|
||||||
args: {
|
|
||||||
password: {
|
async function generateSubCommands(): Promise<SubCommandsDef> {
|
||||||
type: 'string',
|
const subCommandsMap: Record<string, SubCommandsDef[string]> = {};
|
||||||
description: 'New password for the admin user',
|
|
||||||
required: false,
|
for (const cmd of subCommands) {
|
||||||
},
|
const cmdMeta = await resolveValue(cmd.meta || {});
|
||||||
},
|
if (!cmdMeta.name) {
|
||||||
async run(ctx) {
|
console.warn('Skipping command without name:', cmd);
|
||||||
let password = ctx.args.password || undefined;
|
continue;
|
||||||
if (!password) {
|
|
||||||
password = await consola.prompt('Please enter a new password:', {
|
|
||||||
type: 'text',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!password) {
|
subCommandsMap[cmdMeta.name] = cmd;
|
||||||
consola.error('Password is required');
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
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) => {
|
return subCommandsMap;
|
||||||
const user = await tx
|
}
|
||||||
.select()
|
|
||||||
.from(schema.user)
|
|
||||||
.where(eq(schema.user.id, 1))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!user) {
|
const subCommandsMap = await generateSubCommands();
|
||||||
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})`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "es2024",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["./**/*.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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: (
|
||||||
|
|||||||
Reference in New Issue
Block a user