Browse Source

refactor cli, add commands

pull/2518/head
Bernd Storath 4 months ago
parent
commit
afc97a5ac1
  1. 71
      src/cli/admin/reset.ts
  2. 29
      src/cli/clients/list.ts
  3. 71
      src/cli/clients/qr.ts
  4. 10
      src/cli/db.ts
  5. 102
      src/cli/index.ts
  6. 12
      src/cli/tsconfig.json
  7. 20
      src/server/utils/WireGuard.ts
  8. 40
      src/server/utils/qr.ts
  9. 4
      src/server/utils/wgHelper.ts

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 {

40
src/server/utils/qr.ts

@ -0,0 +1,40 @@
// ! Auto Imports are not supported in this file
import type { ECMode } 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: (typeof ECMode)[number]) => T): T {
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