Browse Source

migrate to sqlite

pull/1619/head
Bernd Storath 3 months ago
parent
commit
5853c0bcca
  1. 4
      src/nuxt.config.ts
  2. 2
      src/server/database/migrations/0000_faulty_plazm.sql
  3. 3
      src/server/database/migrations/0001_lonely_tusk.sql
  4. 15
      src/server/database/migrations/0001_next_george_stacy.sql
  5. 5
      src/server/database/migrations/meta/0000_snapshot.json
  6. 7
      src/server/database/migrations/meta/0001_snapshot.json
  7. 8
      src/server/database/migrations/meta/_journal.json
  8. 12
      src/server/database/repositories/client/service.ts
  9. 23
      src/server/database/repositories/hooks/service.ts
  10. 44
      src/server/database/repositories/interface/service.ts
  11. 25
      src/server/database/repositories/oneTimeLink/service.ts
  12. 7
      src/server/database/repositories/userConfig/service.ts
  13. 10
      src/server/database/sqlite.ts
  14. 2
      src/server/utils/Database.ts
  15. 205
      src/server/utils/WireGuard.ts
  16. 2
      src/server/utils/ip.ts
  17. 20
      src/server/utils/wgHelper.ts

4
src/nuxt.config.ts

@ -50,4 +50,8 @@ export default defineNuxtConfig({
'#db': fileURLToPath(new URL('./server/database/', import.meta.url)),
},
},
alias: {
// for typecheck reasons (https://github.com/nuxt/cli/issues/323)
'#db': fileURLToPath(new URL('./server/database/', import.meta.url)),
},
});

2
src/server/database/migrations/0000_fantastic_zemo.sql → src/server/database/migrations/0000_faulty_plazm.sql

@ -79,7 +79,7 @@ CREATE TABLE `users_table` (
`email` text,
`name` text NOT NULL,
`role` integer NOT NULL,
`enabled` integer DEFAULT 1 NOT NULL,
`enabled` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);

3
src/server/database/migrations/0001_lonely_tusk.sql

@ -1,3 +0,0 @@
-- Custom SQL migration file, put your code below! --
INSERT INTO `general_table` (`setupStep`, `session_password`, `session_timeout`)
VALUES (1, hex(randomblob(256)), 3600);

15
src/server/database/migrations/0001_next_george_stacy.sql

@ -0,0 +1,15 @@
-- Insert default values --
INSERT INTO `general_table` (`setupStep`, `session_password`, `session_timeout`)
VALUES (1, hex(randomblob(256)), 3600);
INSERT INTO `interfaces_table` (`name`, `device`, `port`, `private_key`, `public_key`, `ipv4_cidr`, `ipv6_cidr`, `mtu`, `enabled`)
VALUES ('wg0', 'eth0', 51820, '---default---', '---default---', '10.8.0.0/24', 'fdcc:ad94:bacf:61a4::cafe:0/112', 1420, 1);
INSERT INTO `hooks_table` (`id`, `pre_up`, `post_up`, `pre_down`, `post_down`)
VALUES (
'wg0',
'',
'iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT;',
'',
'iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT;'
);

5
src/server/database/migrations/meta/0000_snapshot.json

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "52812a10-9028-40dc-b1dc-69e4e641aa9e",
"id": "37203c54-1625-40bd-89fd-9be0f7140fae",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"clients_table": {
@ -542,8 +542,7 @@
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
"autoincrement": false
},
"created_at": {
"name": "created_at",

7
src/server/database/migrations/meta/0001_snapshot.json

@ -1,6 +1,6 @@
{
"id": "5eee8e4e-69d5-4c68-b5cd-a221ad7649de",
"prevId": "52812a10-9028-40dc-b1dc-69e4e641aa9e",
"id": "1bc0abb2-1297-42d2-ba8e-213bbfd92835",
"prevId": "37203c54-1625-40bd-89fd-9be0f7140fae",
"version": "6",
"dialect": "sqlite",
"tables": {
@ -542,8 +542,7 @@
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
"autoincrement": false
},
"created_at": {
"name": "created_at",

8
src/server/database/migrations/meta/_journal.json

@ -5,15 +5,15 @@
{
"idx": 0,
"version": "6",
"when": 1737101897688,
"tag": "0000_fantastic_zemo",
"when": 1737107311477,
"tag": "0000_faulty_plazm",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1737101904944,
"tag": "0001_lonely_tusk",
"when": 1737107315085,
"tag": "0001_next_george_stacy",
"breakpoints": true
}
]

12
src/server/database/repositories/client/service.ts

@ -2,6 +2,7 @@ import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { client } from './schema';
import type { ClientCreateType } from './types';
import type { ID } from '../../schema';
import { wgInterface, userConfig } from '../../schema';
import { parseCidr } from 'cidr-tools';
@ -17,6 +18,11 @@ function createPreparedStatement(db: DBType) {
findById: db.query.client
.findFirst({ where: eq(client.id, sql.placeholder('id')) })
.prepare(),
toggle: db
.update(client)
.set({ enabled: sql.placeholder('enabled') as never as boolean })
.where(eq(client.id, sql.placeholder('id')))
.prepare(),
};
}
@ -38,7 +44,7 @@ export class ClientService {
}));
}
async get(id: number) {
async get(id: ID) {
return this.#statements.findById.execute({ id });
}
@ -103,4 +109,8 @@ export class ClientService {
.execute();
});
}
async toggle(id: ID, enabled: boolean) {
return this.#statements.toggle.execute({ id, enabled });
}
}

23
src/server/database/repositories/hooks/service.ts

@ -0,0 +1,23 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { hooks } from './schema';
function createPreparedStatement(db: DBType) {
return {
get: db.query.hooks
.findFirst({ where: eq(hooks.id, sql.placeholder('interface')) })
.prepare(),
};
}
export class HooksService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
get(wgInterface: string) {
return this.#statements.get.execute({ interface: wgInterface });
}
}

44
src/server/database/repositories/interface/service.ts

@ -0,0 +1,44 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { wgInterface } from './schema';
function createPreparedStatement(db: DBType) {
return {
get: db.query.wgInterface
.findFirst({ where: eq(wgInterface.name, sql.placeholder('interface')) })
.prepare(),
getAll: db.query.wgInterface.findMany().prepare(),
updateKeyPair: db
.update(wgInterface)
.set({
privateKey: sql.placeholder('privateKey') as never as string,
publicKey: sql.placeholder('publicKey') as never as string,
})
.where(eq(wgInterface.name, sql.placeholder('interface')))
.prepare(),
};
}
export class InterfaceService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
get(infName: string) {
return this.#statements.get.execute({ interface: infName });
}
getAll() {
return this.#statements.getAll.execute();
}
updateKeyPair(infName: string, privateKey: string, publicKey: string) {
return this.#statements.updateKeyPair.execute({
interface: infName,
privateKey,
publicKey,
});
}
}

25
src/server/database/repositories/oneTimeLink/service.ts

@ -0,0 +1,25 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { oneTimeLink } from './schema';
import type { ID } from '../../schema';
function createPreparedStatement(db: DBType) {
return {
delete: db
.delete(oneTimeLink)
.where(eq(oneTimeLink.id, sql.placeholder('id')))
.prepare(),
};
}
export class OneTimeLinkService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
delete(id: ID) {
return this.#statements.delete.execute({ id });
}
}

7
src/server/database/repositories/userConfig/service.ts

@ -4,6 +4,9 @@ import { userConfig } from './schema';
function createPreparedStatement(db: DBType) {
return {
get: db.query.userConfig
.findFirst({ where: eq(userConfig.id, sql.placeholder('interface')) })
.prepare(),
updateHostPort: db
.update(userConfig)
.set({
@ -22,6 +25,10 @@ export class UserConfigService {
this.#statements = createPreparedStatement(db);
}
async get(wgInterface: string) {
return await this.#statements.get.execute({ interface: wgInterface });
}
async updateHostPort(wgInterface: string, host: string, port: number) {
return await this.#statements.updateHostPort.execute({
interface: wgInterface,

10
src/server/database/sqlite.ts

@ -7,6 +7,9 @@ import { ClientService } from './repositories/client/service';
import { GeneralService } from './repositories/general/service';
import { UserService } from './repositories/user/service';
import { UserConfigService } from './repositories/userConfig/service';
import { InterfaceService } from './repositories/interface/service';
import { HooksService } from './repositories/hooks/service';
import { OneTimeLinkService } from './repositories/oneTimeLink/service';
const client = createClient({ url: 'file:/etc/wireguard/wg0.db' });
const db = drizzle({ client, schema });
@ -21,11 +24,17 @@ class DBService {
general: GeneralService;
users: UserService;
userConfigs: UserConfigService;
interfaces: InterfaceService;
hooks: HooksService;
oneTimeLinks: OneTimeLinkService;
constructor(db: DBType) {
this.clients = new ClientService(db);
this.general = new GeneralService(db);
this.users = new UserService(db);
this.userConfigs = new UserConfigService(db);
this.interfaces = new InterfaceService(db);
this.hooks = new HooksService(db);
this.oneTimeLinks = new OneTimeLinkService(db);
}
}
@ -38,7 +47,6 @@ async function migrate() {
await drizzleMigrate(db, {
migrationsFolder: './server/database/migrations',
});
// TODO: data migration
console.log('Migration complete');
} catch (e) {
if (e instanceof Error) {

2
src/server/utils/Database.ts

@ -18,7 +18,7 @@ let provider = nullObject as never as DBServiceType;
connect().then((db) => {
provider = db;
// TODO: start wireguard
WireGuard.Startup();
});
// TODO: check if old config exists and tell user about migration path

205
src/server/utils/WireGuard.ts

@ -1,10 +1,7 @@
import fs from 'node:fs/promises';
import debug from 'debug';
import QRCode from 'qrcode';
import CRC32 from 'crc-32';
import isCidr from 'is-cidr';
import type { UpdateClient } from '~~/services/database/repositories/client';
import type { ID } from '#db/schema';
const DEBUG = debug('WireGuard');
@ -13,20 +10,26 @@ class WireGuard {
* Save and sync config
*/
async saveConfig() {
await this.#saveWireguardConfig();
await this.#syncWireguardConfig();
await this.#saveWireguardConfig('wg0');
await this.#syncWireguardConfig('wg0');
}
/**
* Generates and saves WireGuard config from database as wg0
*/
async #saveWireguardConfig() {
const system = await Database.get();
const clients = await Database.client.findAll();
async #saveWireguardConfig(infName: string) {
const wgInterface = await Database.interfaces.get(infName);
const clients = await Database.clients.getAll();
const hooks = await Database.hooks.get(infName);
if (!wgInterface || !hooks) {
throw new Error('Interface or Hooks not found');
}
const result = [];
result.push(wg.generateServerInterface(system));
result.push(wg.generateServerInterface(wgInterface, hooks));
for (const client of Object.values(clients)) {
for (const client of clients) {
if (!client.enabled) {
continue;
}
@ -34,15 +37,15 @@ class WireGuard {
}
DEBUG('Saving Config...');
await fs.writeFile('/etc/wireguard/wg0.conf', result.join('\n\n'), {
await fs.writeFile(`/etc/wireguard/${infName}.conf`, result.join('\n\n'), {
mode: 0o600,
});
DEBUG('Config saved successfully.');
}
async #syncWireguardConfig() {
async #syncWireguardConfig(infName: string) {
DEBUG('Syncing Config...');
await wg.sync();
await wg.sync(infName);
DEBUG('Config synced successfully.');
}
@ -57,7 +60,7 @@ class WireGuard {
}));
// Loop WireGuard status
const dump = await wg.dump();
const dump = await wg.dump('wg0');
dump.forEach(
({ publicKey, latestHandshakeAt, endpoint, transferRx, transferTx }) => {
const client = clients.find((client) => client.publicKey === publicKey);
@ -75,14 +78,24 @@ class WireGuard {
return clients;
}
async getClientConfiguration({ clientId }: { clientId: number }) {
const system = await Database.system.get();
async getClientConfiguration({ clientId }: { clientId: ID }) {
const wgInterface = await Database.interfaces.get('wg0');
const userConfig = await Database.userConfigs.get('wg0');
if (!wgInterface || !userConfig) {
throw new Error('Interface or UserConfig not found');
}
const client = await Database.clients.get(clientId);
return wg.generateClientConfig(system, client);
if (!client) {
throw new Error('Client not found');
}
return wg.generateClientConfig(wgInterface, userConfig, client);
}
async getClientQRCodeSVG({ clientId }: { clientId: string }) {
async getClientQRCodeSVG({ clientId }: { clientId: ID }) {
const config = await this.getClientConfiguration({ clientId });
return QRCode.toString(config, {
type: 'svg',
@ -90,85 +103,6 @@ class WireGuard {
});
}
async deleteClient({ clientId }: { clientId: string }) {
await Database.client.delete(clientId);
await this.saveConfig();
}
async enableClient({ clientId }: { clientId: string }) {
await Database.client.toggle(clientId, true);
await this.saveConfig();
}
async generateOneTimeLink({ clientId }: { clientId: string }) {
const key = `${clientId}-${Math.floor(Math.random() * 1000)}`;
const oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
await Database.client.createOneTimeLink(clientId, {
oneTimeLink,
expiresAt,
});
await this.saveConfig();
}
async eraseOneTimeLink({ clientId }: { clientId: string }) {
await Database.client.deleteOneTimeLink(clientId);
await this.saveConfig();
}
async disableClient({ clientId }: { clientId: string }) {
await Database.client.toggle(clientId, false);
await this.saveConfig();
}
async updateClient({
clientId,
client,
}: {
clientId: string;
client: UpdateClient;
}) {
// TODO: validate ipv4, v6, expire date etc
await Database.client.update(clientId, client);
await this.saveConfig();
}
async updateAddressRange({
address4,
address6,
}: {
address4: string;
address6: string;
}) {
// TODO: be able to revert if error
if (!isCidr(address4) || !isCidr(address6)) {
throw new Error('Invalid CIDR');
}
await Database.system.updateAddressRange(address4, address6);
const systems = await Database.system.get();
const clients = await Database.client.findAll();
for (const _client of Object.values(clients)) {
const clients = await Database.client.findAll();
const client = structuredClone(_client) as DeepWriteable<typeof _client>;
client.address4 = nextIPv4(systems, clients);
client.address6 = nextIPv6(systems, clients);
await Database.client.update(client.id, {
...client,
});
}
await this.saveConfig();
}
// TODO: reimplement database restore
async restoreConfiguration(_config: string) {
/* DEBUG('Starting configuration restore process.');
@ -189,24 +123,47 @@ class WireGuard {
}
async Startup() {
DEBUG('Starting Wireguard...');
await this.#saveWireguardConfig();
await wg.down().catch(() => {});
await wg.up().catch((err) => {
const wgInterfaces = await Database.interfaces.getAll();
for (const wgInterface of wgInterfaces) {
if (wgInterface.enabled !== true) {
continue;
}
// default interface has no keys
if (
err &&
err.message &&
err.message.includes('Cannot find device "wg0"')
wgInterface.privateKey === '---default---' &&
wgInterface.publicKey === '---default---'
) {
throw new Error(
'WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!'
DEBUG('Generating new Wireguard Keys...');
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
await Database.interfaces.updateKeyPair(
wgInterface.name,
privateKey,
publicKey
);
DEBUG('New Wireguard Keys generated successfully.');
}
DEBUG(`Starting Wireguard Interface ${wgInterface.name}...`);
await this.#saveWireguardConfig(wgInterface.name);
await wg.down(wgInterface.name).catch(() => {});
await wg.up(wgInterface.name).catch((err) => {
if (
err &&
err.message &&
err.message.includes(`Cannot find device "${wgInterface.name}"`)
) {
throw new Error(
`WireGuard exited with the error: Cannot find device "${wgInterface.name}"\nThis usually means that your host's kernel does not support WireGuard!`,
{ cause: err.message }
);
}
throw err;
});
await this.#syncWireguardConfig();
DEBUG('Wireguard started successfully.');
throw err;
});
await this.#syncWireguardConfig(wgInterface.name);
DEBUG(`Wireguard Interface ${wgInterface.name} started successfully.`);
}
DEBUG('Starting Cron Job.');
await this.startCronJob();
@ -214,7 +171,6 @@ class WireGuard {
}
// TODO: handle as worker_thread
// would need a better database aswell
async startCronJob() {
await this.cronJob().catch((err) => {
DEBUG('Running Cron Job failed.');
@ -227,31 +183,34 @@ class WireGuard {
// Shutdown wireguard
async Shutdown() {
await wg.down().catch(() => {});
const wgInterfaces = await Database.interfaces.getAll();
for (const wgInterface of wgInterfaces) {
await wg.down(wgInterface.name).catch(() => {});
}
}
async cronJob() {
const clients = await Database.client.findAll();
const clients = await Database.clients.getAll();
// Expires Feature
for (const client of Object.values(clients)) {
for (const client of clients) {
if (client.enabled !== true) continue;
if (
client.expiresAt !== null &&
new Date() > new Date(client.expiresAt)
) {
DEBUG(`Client ${client.id} expired.`);
await Database.client.toggle(client.id, false);
await Database.clients.toggle(client.id, false);
}
}
// One Time Link Feature
for (const client of Object.values(clients)) {
for (const client of clients) {
if (
client.oneTimeLink !== null &&
new Date() > new Date(client.oneTimeLink.expiresAt)
) {
DEBUG(`Client ${client.id} One Time Link expired.`);
await Database.client.deleteOneTimeLink(client.id);
await Database.oneTimeLinks.delete(client.id);
}
}
@ -266,7 +225,7 @@ class WireGuard {
let wireguardSentBytes = '';
let wireguardReceivedBytes = '';
let wireguardLatestHandshakeSeconds = '';
for (const client of Object.values(clients)) {
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
@ -274,9 +233,9 @@ class WireGuard {
if (client.endpoint !== null) {
wireguardConnectedPeersCount++;
}
wireguardSentBytes += `wireguard_sent_bytes{interface="wg0",enabled="${client.enabled}",address4="${client.address4}",address6="${client.address6}",name="${client.name}"} ${Number(client.transferTx)}\n`;
wireguardReceivedBytes += `wireguard_received_bytes{interface="wg0",enabled="${client.enabled}",address4="${client.address4}",address6="${client.address6}",name="${client.name}"} ${Number(client.transferRx)}\n`;
wireguardLatestHandshakeSeconds += `wireguard_latest_handshake_seconds{interface="wg0",enabled="${client.enabled}",address4="${client.address4}",address6="${client.address6}",name="${client.name}"} ${client.latestHandshakeAt ? (new Date().getTime() - new Date(client.latestHandshakeAt).getTime()) / 1000 : 0}\n`;
wireguardSentBytes += `wireguard_sent_bytes{interface="wg0",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"} ${Number(client.transferTx)}\n`;
wireguardReceivedBytes += `wireguard_received_bytes{interface="wg0",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"} ${Number(client.transferRx)}\n`;
wireguardLatestHandshakeSeconds += `wireguard_latest_handshake_seconds{interface="wg0",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"} ${client.latestHandshakeAt ? (new Date().getTime() - new Date(client.latestHandshakeAt).getTime()) / 1000 : 0}\n`;
}
let returnText = '# HELP wg-easy and wireguard metrics\n';
@ -315,7 +274,7 @@ class WireGuard {
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
for (const client of Object.values(clients)) {
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;

2
src/server/utils/ip.ts

@ -13,7 +13,7 @@ export function nextIP(
let address;
for (let i = cidr.start + 2n; i <= cidr.end - 1n; i++) {
const currentIp = stringifyIp({ number: i, version: version });
const client = Object.values(clients).find((client) => {
const client = clients.find((client) => {
return client[`ipv${version}Address`] === currentIp;
});

20
src/server/utils/wgHelper.ts

@ -5,10 +5,8 @@ import { stringifyIp } from 'ip-bigint';
import type { UserConfigType } from '#db/repositories/userConfig/types';
import type { HooksType } from '#db/repositories/hooks/types';
// TODO: replace wg0 with parameter (to allow multi interface design)
export const wg = {
generateServerPeer: (client: ClientType) => {
generateServerPeer: (client: Omit<ClientType, 'createdAt' | 'updatedAt'>) => {
const allowedIps = [
`${client.ipv4Address}/32`,
`${client.ipv6Address}/128`,
@ -79,20 +77,20 @@ Endpoint = ${userConfig.host}:${userConfig.port}`;
return exec('wg genpsk');
},
up: () => {
return exec('wg-quick up wg0');
up: (infName: string) => {
return exec(`wg-quick up ${infName}`);
},
down: () => {
return exec('wg-quick down wg0');
down: (infName: string) => {
return exec(`wg-quick down ${infName}`);
},
sync: () => {
return exec('wg syncconf wg0 <(wg-quick strip wg0)');
sync: (infName: string) => {
return exec(`wg syncconf ${infName} <(wg-quick strip ${infName})`);
},
dump: async () => {
const rawDump = await exec('wg show wg0 dump', {
dump: async (infName: string) => {
const rawDump = await exec(`wg show ${infName} dump`, {
log: false,
});

Loading…
Cancel
Save