Browse Source

migrate to sqlite

pull/1619/head
Bernd Storath 3 months ago
parent
commit
139ef95a84
  1. 9
      src/server/api/client/index.post.ts
  2. 12
      src/server/database/repositories/client/schema.ts
  3. 106
      src/server/database/repositories/client/service.ts
  4. 19
      src/server/database/repositories/client/types.ts
  5. 39
      src/server/database/repositories/clients/service.ts
  6. 8
      src/server/database/repositories/hooks/schema.ts
  7. 4
      src/server/database/repositories/hooks/types.ts
  8. 12
      src/server/database/repositories/interface/schema.ts
  9. 4
      src/server/database/repositories/interface/types.ts
  10. 8
      src/server/database/repositories/metrics/schema.ts
  11. 14
      src/server/database/repositories/oneTimeLink/schema.ts
  12. 2
      src/server/database/repositories/user/schema.ts
  13. 8
      src/server/database/repositories/userConfig/schema.ts
  14. 4
      src/server/database/repositories/userConfig/types.ts
  15. 6
      src/server/database/schema.ts
  16. 4
      src/server/database/sqlite.ts
  17. 55
      src/server/utils/WireGuard.ts
  18. 36
      src/server/utils/ip.ts
  19. 17
      src/server/utils/template.ts
  20. 59
      src/server/utils/wgHelper.ts

9
src/server/api/client/index.post.ts

@ -1,8 +1,11 @@
import { ClientCreateSchema } from '#db/repositories/client/types';
export default defineEventHandler(async (event) => {
const { name, expireDate } = await readValidatedBody(
const { name, expiresAt } = await readValidatedBody(
event,
validateZod(createType)
validateZod(ClientCreateSchema)
);
await WireGuard.createClient({ name, expireDate });
await Database.clients.create({ name, expiresAt });
await WireGuard.saveConfig();
return { success: true };
});

12
src/server/database/repositories/clients/schema.ts → src/server/database/repositories/client/schema.ts

@ -1,9 +1,9 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { oneTimeLinks } from '../../schema';
import { oneTimeLink } from '../../schema';
export const clients = sqliteTable('clients_table', {
export const client = sqliteTable('clients_table', {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
ipv4Address: text('ipv4_address').notNull().unique(),
@ -29,9 +29,9 @@ export const clients = sqliteTable('clients_table', {
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const clientsRelations = relations(clients, ({ one }) => ({
oneTimeLink: one(oneTimeLinks, {
fields: [clients.id],
references: [oneTimeLinks.clientId],
export const clientsRelations = relations(client, ({ one }) => ({
oneTimeLink: one(oneTimeLink, {
fields: [client.id],
references: [oneTimeLink.clientId],
}),
}));

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

@ -0,0 +1,106 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { client } from './schema';
import type { ClientCreateType } from './types';
import { wgInterface, userConfig } from '../../schema';
import { parseCidr } from 'cidr-tools';
function createPreparedStatement(db: DBType) {
return {
findAll: db.query.client
.findMany({
with: {
oneTimeLink: true,
},
})
.prepare(),
findById: db.query.client
.findFirst({ where: eq(client.id, sql.placeholder('id')) })
.prepare(),
};
}
export class ClientService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async getAll() {
const result = await this.#statements.findAll.all();
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
async get(id: number) {
return this.#statements.findById.all({ id });
}
async create({ name, expiresAt }: ClientCreateType) {
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
const preSharedKey = await wg.generatePreSharedKey();
let parsedExpiresAt = expiresAt;
if (parsedExpiresAt) {
const expiresAtDate = new Date(parsedExpiresAt);
expiresAtDate.setHours(23);
expiresAtDate.setMinutes(59);
expiresAtDate.setSeconds(59);
parsedExpiresAt = expiresAtDate.toISOString();
}
await this.#db.transaction(async (tx) => {
const clients = await tx.query.client.findMany().execute();
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
})
.execute();
if (!clientInterface) {
throw new Error('WireGuard interface not found');
}
const clientConfig = await tx.query.userConfig
.findFirst({
where: eq(userConfig.id, clientInterface.name),
})
.execute();
if (!clientConfig) {
throw new Error('WireGuard interface configuration not found');
}
const ipv4Cidr = parseCidr(clientInterface.ipv4Cidr);
const ipv4Address = nextIP(4, ipv4Cidr, clients);
const ipv6Cidr = parseCidr(clientInterface.ipv6Cidr);
const ipv6Address = nextIP(6, ipv6Cidr, clients);
return await tx
.insert(client)
.values({
name,
expiresAt: parsedExpiresAt,
privateKey,
publicKey,
preSharedKey,
ipv4Address,
ipv6Address,
mtu: clientConfig.defaultMtu,
allowedIps: clientConfig.defaultAllowedIps,
dns: clientConfig.defaultDns,
persistentKeepalive: clientConfig.defaultPersistentKeepalive,
serverAllowedIps: [],
enabled: true,
})
.execute();
});
}
}

19
src/server/database/repositories/clients/types.ts → src/server/database/repositories/client/types.ts

@ -1,7 +1,7 @@
import type { InferSelectModel } from 'drizzle-orm';
import { zod } from '#imports';
import type { clients } from './schema';
import type { client } from './schema';
const schemaForType =
<T>() =>
@ -10,10 +10,12 @@ const schemaForType =
return arg;
};
export type ClientsType = InferSelectModel<typeof clients>;
export type ID = string;
export type ClientType = InferSelectModel<typeof client>;
export type CreateClientType = Omit<
ClientsType,
ClientType,
'createdAt' | 'updatedAt' | 'id'
>;
@ -27,7 +29,7 @@ const name = zod
.min(1, 'zod.nameMin')
.pipe(safeStringRefine);
const expireDate = zod
const expiresAt = zod
.string({ message: 'zod.expireDate' })
.min(1, 'zod.expireDateMin')
.pipe(safeStringRefine)
@ -70,11 +72,18 @@ const enabled = zod.boolean({ message: 'zod.enabled' });
const dns = zod.array(address, { message: 'zod.dns' }).min(1, 'zod.dnsMin');
export const ClientCreateSchema = zod.object({
name: name,
expiresAt: expiresAt,
});
export type ClientCreateType = zod.infer<typeof ClientCreateSchema>;
export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
zod.object({
name: name,
enabled: enabled,
expiresAt: expireDate,
expiresAt: expiresAt,
ipv4Address: address4,
ipv6Address: address6,
allowedIps: allowedIps,

39
src/server/database/repositories/clients/service.ts

@ -1,39 +0,0 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { clients } from './schema';
function createPreparedStatement(db: DBType) {
return {
findAll: db.query.clients
.findMany({
with: {
oneTimeLink: true,
},
})
.prepare(),
findById: db.query.clients
.findFirst({ where: eq(clients.id, sql.placeholder('id')) })
.prepare(),
};
}
export class ClientsService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
async findAll() {
const result = await this.#statements.findAll.all();
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
async findById(id: number) {
return this.#statements.findById.all({ id });
}
}

8
src/server/database/repositories/hooks/schema.ts

@ -1,12 +1,12 @@
import { sql } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../../schema';
export const hooks = sqliteTable('hooks_table', {
id: int()
.primaryKey({ autoIncrement: true })
.references(() => wgInterface.id, {
id: text()
.primaryKey()
.references(() => wgInterface.name, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),

4
src/server/database/repositories/hooks/types.ts

@ -0,0 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { hooks } from './schema';
export type HooksType = InferSelectModel<typeof hooks>;

12
src/server/database/repositories/interface/schema.ts

@ -4,9 +4,9 @@ import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { userConfig, hooks, prometheus } from '../../schema';
// maybe support multiple interfaces in the future
export const wgInterface = sqliteTable('interface_table', {
id: int().primaryKey({ autoIncrement: true }),
device: text().notNull().unique(),
export const wgInterface = sqliteTable('interfaces_table', {
name: text().primaryKey(),
device: text().notNull(),
port: int().notNull().unique(),
privateKey: text('private_key').notNull(),
publicKey: text('public_key').notNull(),
@ -25,15 +25,15 @@ export const wgInterface = sqliteTable('interface_table', {
export const wgInterfaceRelations = relations(wgInterface, ({ one }) => ({
hooks: one(hooks, {
fields: [wgInterface.id],
fields: [wgInterface.name],
references: [hooks.id],
}),
prometheus: one(prometheus, {
fields: [wgInterface.id],
fields: [wgInterface.name],
references: [prometheus.id],
}),
userConfig: one(userConfig, {
fields: [wgInterface.id],
fields: [wgInterface.name],
references: [userConfig.id],
}),
}));

4
src/server/database/repositories/interface/types.ts

@ -0,0 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { wgInterface } from './schema';
export type InterfaceType = InferSelectModel<typeof wgInterface>;

8
src/server/database/repositories/metrics/schema.ts

@ -1,12 +1,12 @@
import { sql } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../../schema';
export const prometheus = sqliteTable('prometheus_table', {
id: int()
.primaryKey({ autoIncrement: true })
.references(() => wgInterface.id, {
id: text()
.primaryKey()
.references(() => wgInterface.name, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),

14
src/server/database/repositories/oneTimeLinks/schema.ts → src/server/database/repositories/oneTimeLink/schema.ts

@ -1,15 +1,15 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from '../../schema';
import { client } from '../../schema';
export const oneTimeLinks = sqliteTable('one_time_links_table', {
export const oneTimeLink = sqliteTable('one_time_links_table', {
id: int().primaryKey({ autoIncrement: true }),
oneTimeLink: text('one_time_link').notNull(),
expiresAt: text('expires_at').notNull(),
clientId: int()
.notNull()
.references(() => clients.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
.references(() => client.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@ -19,9 +19,9 @@ export const oneTimeLinks = sqliteTable('one_time_links_table', {
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const oneTimeLinksRelations = relations(oneTimeLinks, ({ one }) => ({
client: one(clients, {
fields: [oneTimeLinks.clientId],
references: [clients.id],
export const oneTimeLinksRelations = relations(oneTimeLink, ({ one }) => ({
client: one(client, {
fields: [oneTimeLink.clientId],
references: [client.id],
}),
}));

2
src/server/database/repositories/users/schema.ts → src/server/database/repositories/user/schema.ts

@ -1,7 +1,7 @@
import { sql } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users_table', {
export const user = sqliteTable('users_table', {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull(),
password: text().notNull(),

8
src/server/database/repositories/userConfig/schema.ts

@ -4,10 +4,10 @@ import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../../schema';
// default* means clients store it themselves
export const userConfig = sqliteTable('user_config_table', {
id: int()
.primaryKey({ autoIncrement: true })
.references(() => wgInterface.id, {
export const userConfig = sqliteTable('user_configs_table', {
id: text()
.primaryKey()
.references(() => wgInterface.name, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),

4
src/server/database/repositories/userConfig/types.ts

@ -0,0 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { userConfig } from './schema';
export type UserConfigType = InferSelectModel<typeof userConfig>;

6
src/server/database/schema.ts

@ -1,9 +1,9 @@
// Make sure to not use any Path Aliases in these files
export * from './repositories/clients/schema';
export * from './repositories/client/schema';
export * from './repositories/general/schema';
export * from './repositories/hooks/schema';
export * from './repositories/interface/schema';
export * from './repositories/metrics/schema';
export * from './repositories/oneTimeLinks/schema';
export * from './repositories/oneTimeLink/schema';
export * from './repositories/userConfig/schema';
export * from './repositories/users/schema';
export * from './repositories/user/schema';

4
src/server/database/sqlite.ts

@ -3,7 +3,7 @@ import { migrate as drizzleMigrate } from 'drizzle-orm/libsql/migrator';
import { createClient } from '@libsql/client';
import * as schema from './schema';
import { ClientsService } from './repositories/clients/service';
import { ClientService } from './repositories/client/service';
const client = createClient({ url: 'file:/etc/wireguard/wg0.db' });
const db = drizzle({ client, schema });
@ -14,7 +14,7 @@ export async function connect() {
}
class DBService {
clients = new ClientsService(db);
clients = new ClientService(db);
constructor(private db: DBType) {}
}

55
src/server/utils/WireGuard.ts

@ -50,7 +50,7 @@ class WireGuard {
}
async getClients() {
const dbClients = await Database.clients.findAll();
const dbClients = await Database.clients.getAll();
const clients = dbClients.map((client) => ({
...client,
latestHandshakeAt: null as Date | null,
@ -78,9 +78,9 @@ class WireGuard {
return clients;
}
async getClientConfiguration({ clientId }: { clientId: string }) {
async getClientConfiguration({ clientId }: { clientId: number }) {
const system = await Database.system.get();
const client = await this.getClient({ clientId });
const client = await Database.clients.get(clientId);
return wg.generateClientConfig(system, client);
}
@ -93,55 +93,6 @@ class WireGuard {
});
}
async createClient({
name,
expireDate,
}: {
name: string;
expireDate: string | null;
}) {
const system = await Database.system.get();
const clients = await Database.client.findAll();
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
const preSharedKey = await wg.generatePresharedKey();
const address4 = nextIPv4(system, clients);
const address6 = nextIPv6(system, clients);
const client: CreateClient = {
name,
address4,
address6,
privateKey,
publicKey,
preSharedKey,
oneTimeLink: null,
expiresAt: null,
enabled: true,
allowedIps: [...system.userConfig.allowedIps],
serverAllowedIPs: [],
persistentKeepalive: system.userConfig.persistentKeepalive,
mtu: system.userConfig.mtu,
};
if (expireDate) {
const date = new Date(expireDate);
date.setHours(23);
date.setMinutes(59);
date.setSeconds(59);
client.expiresAt = date.toISOString();
}
await Database.client.create(client);
await this.saveConfig();
return client;
}
async deleteClient({ clientId }: { clientId: string }) {
await Database.client.delete(clientId);
await this.saveConfig();

36
src/server/utils/ip.ts

@ -1,36 +1,20 @@
import { parseCidr } from 'cidr-tools';
import type { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import type { DeepReadonly } from 'vue';
import type { Database } from '~~/services/database/repositories/database';
export function nextIPv4(
system: DeepReadonly<Database['system']>,
clients: DeepReadonly<Database['clients']>
) {
return nextIP(4, system, clients);
}
export function nextIPv6(
system: DeepReadonly<Database['system']>,
clients: DeepReadonly<Database['clients']>
) {
return nextIP(6, system, clients);
}
import type { ClientType } from '#db/repositories/client/types';
// TODO: above functions should probably have a lock
// TODO(general): what happens if multiple users create client at the same time?
type ParsedCidr = ReturnType<typeof parseCidr>;
function nextIP(
export function nextIP(
version: 4 | 6,
system: DeepReadonly<Database['system']>,
clients: DeepReadonly<Database['clients']>
cidr: ParsedCidr,
clients: ClientType[]
) {
const cidr = parseCidr(system.userConfig[`address${version}Range`]);
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) => {
return client[`address${version}`] === currentIp;
return client[`ipv${version}Address`] === currentIp;
});
if (!client) {
@ -40,10 +24,8 @@ function nextIP(
}
if (!address) {
throw createError({
statusCode: 409,
statusMessage: 'Maximum number of clients reached.',
data: { cause: `IPv${version} Address Pool exhausted` },
throw new Error('Maximum number of clients reached', {
cause: `IPv${version} Address Pool exhausted`,
});
}

17
src/server/utils/template.ts

@ -1,5 +1,4 @@
import type { DeepReadonly } from 'vue';
import type { System } from '~~/services/database/repositories/system';
import type { InterfaceType } from '#db/repositories/interface/types';
/**
* Replace all {{key}} in the template with the values[key]
@ -12,16 +11,16 @@ export function template(templ: string, values: Record<string, string>) {
/**
* Available keys:
* - address4: IPv4 address range
* - address6: IPv6 address range
* - ipv4Cidr: IPv4 CIDR
* - ipv6Cidr: IPv6 CIDR
* - device: Network device
* - port: Port number
*/
export function iptablesTemplate(templ: string, system: DeepReadonly<System>) {
export function iptablesTemplate(templ: string, wgInterface: InterfaceType) {
return template(templ, {
address4: system.userConfig.address4Range,
address6: system.userConfig.address6Range,
device: system.interface.device,
port: system.interface.port.toString(),
ipv4Cidr: wgInterface.ipv4Cidr,
ipv6Cidr: wgInterface.ipv6Cidr,
device: wgInterface.device,
port: wgInterface.port.toString(),
});
}

59
src/server/utils/wgHelper.ts

@ -1,16 +1,18 @@
import { parseCidr } from 'cidr-tools';
import type { DeepReadonly } from 'vue';
import type { Client } from '~~/services/database/repositories/client';
import type { System } from '~~/services/database/repositories/system';
import type { ClientType } from '#db/repositories/client/types';
import type { InterfaceType } from '#db/repositories/interface/types';
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: DeepReadonly<Client>) => {
generateServerPeer: (client: ClientType) => {
const allowedIps = [
`${client.address4}/32`,
`${client.address6}/128`,
...(client.serverAllowedIPs ?? []),
`${client.ipv4Address}/32`,
`${client.ipv6Address}/128`,
...(client.serverAllowedIps ?? []),
];
return `# Client: ${client.name} (${client.id})
@ -20,44 +22,47 @@ PresharedKey = ${client.preSharedKey}
AllowedIPs = ${allowedIps.join(', ')}`;
},
generateServerInterface: (system: DeepReadonly<System>) => {
const cidr4Block = parseCidr(system.userConfig.address4Range).prefix;
const cidr6Block = parseCidr(system.userConfig.address6Range).prefix;
generateServerInterface: (wgInterface: InterfaceType, hooks: HooksType) => {
const cidr4 = parseCidr(wgInterface.ipv4Cidr);
const cidr6 = parseCidr(wgInterface.ipv6Cidr);
const ipv4Addr = stringifyIp({ number: cidr4.start + 1n, version: 4 });
const ipv6Addr = stringifyIp({ number: cidr6.start + 1n, version: 6 });
return `# Note: Do not edit this file directly.
# Your changes will be overwritten!
# Server
[Interface]
PrivateKey = ${system.interface.privateKey}
Address = ${system.interface.address4}/${cidr4Block}, ${system.interface.address6}/${cidr6Block}
ListenPort = ${system.interface.port}
MTU = ${system.interface.mtu}
PreUp = ${iptablesTemplate(system.hooks.PreUp, system)}
PostUp = ${iptablesTemplate(system.hooks.PostUp, system)}
PreDown = ${iptablesTemplate(system.hooks.PreDown, system)}
PostDown = ${iptablesTemplate(system.hooks.PostDown, system)}`;
PrivateKey = ${wgInterface.privateKey}
Address = ${ipv4Addr}/${cidr4.prefix}, ${ipv6Addr}/${cidr6.prefix}
ListenPort = ${wgInterface.port}
MTU = ${wgInterface.mtu}
PreUp = ${iptablesTemplate(hooks.preUp, wgInterface)}
PostUp = ${iptablesTemplate(hooks.postUp, wgInterface)}
PreDown = ${iptablesTemplate(hooks.preDown, wgInterface)}
PostDown = ${iptablesTemplate(hooks.postDown, wgInterface)}`;
},
generateClientConfig: (
system: DeepReadonly<System>,
client: DeepReadonly<Client>
wgInterface: InterfaceType,
userConfig: UserConfigType,
client: ClientType
) => {
const cidr4Block = parseCidr(system.userConfig.address4Range).prefix;
const cidr6Block = parseCidr(system.userConfig.address6Range).prefix;
const cidr4Block = parseCidr(wgInterface.ipv4Cidr).prefix;
const cidr6Block = parseCidr(wgInterface.ipv6Cidr).prefix;
return `[Interface]
PrivateKey = ${client.privateKey}
Address = ${client.address4}/${cidr4Block}, ${client.address6}/${cidr6Block}
DNS = ${system.userConfig.defaultDns.join(', ')}
Address = ${client.ipv4Address}/${cidr4Block}, ${client.ipv6Address}/${cidr6Block}
DNS = ${client.dns.join(', ')}
MTU = ${client.mtu}
[Peer]
PublicKey = ${system.interface.publicKey}
PublicKey = ${wgInterface.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIps.join(', ')}
PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
Endpoint = ${userConfig.host}:${userConfig.port}`;
},
generatePrivateKey: () => {
@ -70,7 +75,7 @@ Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
});
},
generatePresharedKey: () => {
generatePreSharedKey: () => {
return exec('wg genpsk');
},

Loading…
Cancel
Save