diff --git a/.env.example b/.env.example index 3588d80..c149642 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,4 @@ IPV6_STARTING_ADDR=fd00:10:18:11::100:0 IPV6_CLIENT_PREFIX_SIZE=112 IP_MAX_INDEX=100 VPN_ENDPOINT=vpn.lab.cazzzer.com:51820 +MAX_CLIENTS_PER_USER=20 diff --git a/src/lib/server/clients.ts b/src/lib/server/clients.ts new file mode 100644 index 0000000..46cf183 --- /dev/null +++ b/src/lib/server/clients.ts @@ -0,0 +1,173 @@ +import type { User } from '$lib/server/db/schema'; +import { db } from '$lib/server/db'; +import { wgClients, ipAllocations } from '$lib/server/db/schema'; +import { opnsenseAuth, opnsenseUrl, serverUuid } from '$lib/server/opnsense'; +import { Address4, Address6 } from 'ip-address'; +import { + IP_MAX_INDEX, + IPV4_STARTING_ADDR, + IPV6_CLIENT_PREFIX_SIZE, + IPV6_STARTING_ADDR, MAX_CLIENTS_PER_USER, + VPN_ENDPOINT, +} from '$env/static/private'; +import { count, eq, isNull } from 'drizzle-orm'; +import { err, ok, type Result } from '$lib/types'; + +export async function createClient(params: { + name: string; + user: User; +}): Promise> { + // check if user exceeds the limit of clients + const [{ clientCount }] = await db + .select({ clientCount: count() }) + .from(wgClients) + .where(eq(wgClients.userId, params.user.id)); + if (clientCount >= parseInt(MAX_CLIENTS_PER_USER)) return err([400, 'Maximum number of clients reached'] as [400, string]); + + // this is going to be quite long + // 1. fetch params for new client from opnsense api + // 2.1 get an allocation for the client + // 2.2. insert new client into db + // 2.3. update the allocation with the client id + // 3. create the client in opnsense + // 4. reconfigure opnsense to enable the new client + const error= await db.transaction(async (tx) => { + const [keys, availableAllocation, lastAllocation] = await Promise.all([ + // fetch params for new client from opnsense api + getKeys(), + // find first unallocated IP + await tx.query.ipAllocations.findFirst({ + columns: { + id: true, + }, + where: isNull(ipAllocations.clientId), + }), + // find last allocation to check if we have any IPs left + await tx.query.ipAllocations.findFirst({ + columns: { + id: true, + }, + orderBy: (ipAllocations, { desc }) => desc(ipAllocations.id), + }), + ]); + + // check for existing allocation or if we have any IPs left + if (!availableAllocation && lastAllocation && lastAllocation.id >= parseInt(IP_MAX_INDEX)) { + return err([500, 'No more IP addresses available'] as [500, string]); + } + + // use existing allocation or create a new one + const ipAllocationId = + availableAllocation?.id ?? + (await tx.insert(ipAllocations).values({}).returning({ id: ipAllocations.id }))[0].id; + + // transaction savepoint after creating a new IP allocation + // TODO: not sure if this is needed + return await tx.transaction(async (tx2) => { + // create new client in db + const [newClient] = await tx2 + .insert(wgClients) + .values({ + userId: params.user.id, + name: params.name, + publicKey: keys.pubkey, + privateKey: keys.privkey, + preSharedKey: keys.psk, + }) + .returning({ id: wgClients.id }); + + // update IP allocation with client ID + await tx2 + .update(ipAllocations) + .set({ clientId: newClient.id }) + .where(eq(ipAllocations.id, ipAllocationId)); + + // create client in opnsense + const opnsenseRes = await opnsenseCreateClient({ + username: params.user.username, + pubkey: keys.pubkey, + psk: keys.psk, + allowedIps: getAllowedIps(ipAllocationId - 1), + }); + const opnsenseResJson = await opnsenseRes.json(); + if (opnsenseResJson['result'] !== 'saved') { + tx2.rollback(); + console.error(`Error creating client in OPNsense: \n${opnsenseResJson}`); + return err([500, 'Error creating client in OPNsense'] as [500, string]); + } + + // reconfigure opnsense + await opnsenseReconfigure(); + }); + }); + if (error) return error; + return ok(null); +} + +async function getKeys() { + // fetch key pair from opnsense + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: opnsenseAuth, + Accept: 'application/json', + }, + }; + const resKeyPair = await fetch(`${opnsenseUrl}/api/wireguard/server/key_pair`, options); + const resPsk = await fetch(`${opnsenseUrl}/api/wireguard/client/psk`, options); + const keyPair = await resKeyPair.json(); + const psk = await resPsk.json(); + return { + pubkey: keyPair['pubkey'] as string, + privkey: keyPair['privkey'] as string, + psk: psk['psk'] as string, + }; +} + +function getAllowedIps(ipIndex: number) { + const v4StartingAddr = new Address4(IPV4_STARTING_ADDR); + const v6StartingAddr = new Address6(IPV6_STARTING_ADDR); + const v4Allowed = Address4.fromBigInt(v4StartingAddr.bigInt() + BigInt(ipIndex)); + const v6Offset = BigInt(ipIndex) << (128n - BigInt(IPV6_CLIENT_PREFIX_SIZE)); + const v6Allowed = Address6.fromBigInt(v6StartingAddr.bigInt() + v6Offset); + const v6AllowedShort = v6Allowed.parsedAddress.join(':'); + + return `${v4Allowed.address}/32,${v6AllowedShort}/${IPV6_CLIENT_PREFIX_SIZE}`; +} + +async function opnsenseCreateClient(params: { + username: string; + pubkey: string; + psk: string; + allowedIps: string; +}) { + return fetch(`${opnsenseUrl}/api/wireguard/client/addClientBuilder`, { + method: 'POST', + headers: { + Authorization: opnsenseAuth, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + configbuilder: { + enabled: '1', + name: `vpgen-${params.username}`, + pubkey: params.pubkey, + psk: params.psk, + tunneladdress: params.allowedIps, + server: serverUuid, + endpoint: VPN_ENDPOINT, + }, + }), + }); +} + +async function opnsenseReconfigure() { + return fetch(`${opnsenseUrl}/api/wireguard/service/reconfigure`, { + method: 'POST', + headers: { + Authorization: opnsenseAuth, + Accept: 'application/json', + }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..4ca2179 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,27 @@ +class Ok { + readonly _tag = "ok"; + value: T; + + constructor(value: T) { + this.value = value; + } +} + +class Err { + readonly _tag = "err"; + error: E; + + constructor(error: E) { + this.error = error; + } +} + +export type Result = Ok | Err; + +export function err(e: E): Err { + return new Err(e); +} + +export function ok(t: T): Ok { + return new Ok(t); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 28f08c6..d1e4e39 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -23,7 +23,7 @@ {/if} -
+
{@render children()}
diff --git a/src/routes/api/clients/+server.ts b/src/routes/api/clients/+server.ts index 33d2f9d..2fdd4b1 100644 --- a/src/routes/api/clients/+server.ts +++ b/src/routes/api/clients/+server.ts @@ -1,17 +1,9 @@ import { error } from '@sveltejs/kit'; -import { ipAllocations, wgClients } from '$lib/server/db/schema'; +import { wgClients } from '$lib/server/db/schema'; import { db } from '$lib/server/db'; -import { eq, isNull } from 'drizzle-orm'; -import { - IP_MAX_INDEX, - IPV4_STARTING_ADDR, - IPV6_CLIENT_PREFIX_SIZE, - IPV6_STARTING_ADDR, - VPN_ENDPOINT, -} from '$env/static/private'; -import { opnsenseAuth, opnsenseUrl, serverUuid } from '$lib/server/opnsense'; +import { eq } from 'drizzle-orm'; import type { RequestHandler } from './$types'; -import { Address4, Address6 } from 'ip-address'; +import { createClient } from '$lib/server/clients'; export const GET: RequestHandler = async (event) => { if (!event.locals.user) { @@ -41,150 +33,21 @@ export const POST: RequestHandler = async (event) => { if (!event.locals.user) { return error(401, 'Unauthorized'); } - // this is going to be quite long - // 1. fetch params for new client from opnsense api - // 2.1 get an allocation for the client - // 2.2. insert new client into db - // 2.3. update the allocation with the client id - // 3. create the client in opnsense - // 4. reconfigure opnsense to enable the new client - const err: ReturnType | undefined = await db.transaction(async (tx) => { - // fetch params for new client from opnsense api - const keys = await getKeys(); + const { name } = await event.request.json(); + const res = await createClient({ + name, + user: event.locals.user, + }); - // find first unallocated IP - const availableAllocation = await tx.query.ipAllocations.findFirst({ - columns: { - id: true, - }, - where: isNull(ipAllocations.clientId), - }); - // find last allocation to check if we have any IPs left - const lastAllocation = await tx.query.ipAllocations.findFirst({ - columns: { - id: true, - }, - orderBy: (ipAllocations, { desc }) => desc(ipAllocations.id), - }); - // check for existing allocation or if we have any IPs left - if (!availableAllocation && lastAllocation && lastAllocation.id >= parseInt(IP_MAX_INDEX)) { - return error(500, 'No more IP addresses available'); - } - - // use existing allocation or create a new one - const ipAllocationId = - availableAllocation?.id ?? - (await tx.insert(ipAllocations).values({}).returning({ id: ipAllocations.id }))[0].id; - - // transaction savepoint after creating a new IP allocation - // TODO: not sure if this is needed - return await tx.transaction(async (tx2) => { - // create client in opnsense - const opnsenseRes = await opnsenseCreateClient({ - // @ts-expect-error event.locals.user is checked at the beginning of the function - username: event.locals.user.username, - pubkey: keys.pubkey, - psk: keys.psk, - allowedIps: getAllowedIps(ipAllocationId - 1), + switch (res._tag) { + case 'ok': { + return new Response(null, { + status: 201, }); - const opnsenseResJson = await opnsenseRes.json(); - if (opnsenseResJson['result'] !== 'saved') { - tx2.rollback(); - console.error(`Error creating client in OPNsense: \n${opnsenseResJson}`); - return error(500, 'Error creating client in OPNsense'); - } - - // create new client in db - const [newClient] = await tx2 - .insert(wgClients) - .values({ - // @ts-expect-error event.locals.user is checked at the beginning of the function - userId: event.locals.user.id, - name: 'New Client', - publicKey: keys.pubkey, - privateKey: keys.privkey, - preSharedKey: keys.psk, - }) - .returning({ id: wgClients.id }); - - // update IP allocation with client ID - await tx2 - .update(ipAllocations) - .set({ clientId: newClient.id }) - .where(eq(ipAllocations.id, ipAllocationId)); - - // reconfigure opnsense - await opnsenseReconfigure(); - }); - - }); - return err?? new Response(null, { status: 201 }); + } + case 'err': { + const [status, message] = res.error; + return error(status, message); + } + } }; - -async function getKeys() { - // fetch key pair from opnsense - const options: RequestInit = { - method: 'GET', - headers: { - Authorization: opnsenseAuth, - Accept: 'application/json', - }, - }; - const resKeyPair = await fetch(`${opnsenseUrl}/api/wireguard/server/key_pair`, options); - const resPsk = await fetch(`${opnsenseUrl}/api/wireguard/client/psk`, options); - const keyPair = await resKeyPair.json(); - const psk = await resPsk.json(); - return { - pubkey: keyPair['pubkey'] as string, - privkey: keyPair['privkey'] as string, - psk: psk['psk'] as string, - }; -} - -function getAllowedIps(ipIndex: number) { - const v4StartingAddr = new Address4(IPV4_STARTING_ADDR); - const v6StartingAddr = new Address6(IPV6_STARTING_ADDR); - const v4Allowed = Address4.fromBigInt(v4StartingAddr.bigInt() + BigInt(ipIndex)); - const v6Offset = BigInt(ipIndex) << (128n - BigInt(IPV6_CLIENT_PREFIX_SIZE)); - const v6Allowed = Address6.fromBigInt(v6StartingAddr.bigInt() + v6Offset); - const v6AllowedShort = v6Allowed.parsedAddress.join(':'); - - return `${v4Allowed.address}/32,${v6AllowedShort}/${IPV6_CLIENT_PREFIX_SIZE}`; -} - -async function opnsenseCreateClient(params: { - username: string; - pubkey: string; - psk: string; - allowedIps: string; -}) { - return fetch(`${opnsenseUrl}/api/wireguard/client/addClientBuilder`, { - method: 'POST', - headers: { - Authorization: opnsenseAuth, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - configbuilder: { - enabled: '1', - name: `vpgen-${params.username}`, - pubkey: params.pubkey, - psk: params.psk, - tunneladdress: params.allowedIps, - server: serverUuid, - endpoint: VPN_ENDPOINT, - }, - }), - }); -} - -async function opnsenseReconfigure() { - return fetch(`${opnsenseUrl}/api/wireguard/service/reconfigure`, { - method: 'POST', - headers: { - Authorization: opnsenseAuth, - Accept: 'application/json', - }, - }); -} diff --git a/src/routes/clients/+page.server.ts b/src/routes/clients/+page.server.ts new file mode 100644 index 0000000..bff4fa8 --- /dev/null +++ b/src/routes/clients/+page.server.ts @@ -0,0 +1,26 @@ +import type { Actions } from './$types'; +import { createClient } from '$lib/server/clients'; +import { error } from '@sveltejs/kit'; + +export const actions = { + create: async (event) => { + if (!event.locals.user) return error(401, 'Unauthorized'); + const name = 'New Client Name'; + const res = await createClient({ + name, + user: event.locals.user, + }); + + switch (res._tag) { + case 'ok': { + return { + status: 201, + }; + } + case 'err': { + const [status, message] = res.error; + return error(status, message); + } + } + }, +} satisfies Actions; diff --git a/src/routes/clients/+page.svelte b/src/routes/clients/+page.svelte index 09b00e0..1fb680c 100644 --- a/src/routes/clients/+page.svelte +++ b/src/routes/clients/+page.svelte @@ -1,7 +1,9 @@ @@ -32,3 +34,11 @@ {/each} + + +
+ +
diff --git a/src/routes/user/+page.svelte b/src/routes/user/+page.svelte index 86b54d5..afdbce2 100644 --- a/src/routes/user/+page.svelte +++ b/src/routes/user/+page.svelte @@ -7,9 +7,9 @@ let isLoadingSignOut = $state(false); function refetch() { - console.log("refetching"); + console.log('refetching'); invalidate((url) => { - console.log("invalidation url", url); + console.log('invalidation url', url); return true; }); invalidateAll(); @@ -20,21 +20,21 @@ User Profile -

- {JSON.stringify(data.user)} -

+
{JSON.stringify(data.user, null, 2)}
- -
- -
+
+ +
+