initial implementation of adding clients

This commit is contained in:
Yuri Tatishchev 2024-12-23 00:05:00 -08:00
parent 5e3772d39b
commit 2b56cba770
Signed by: CaZzzer
GPG Key ID: E0EBF441EA424369
8 changed files with 273 additions and 173 deletions

View File

@ -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

173
src/lib/server/clients.ts Normal file
View File

@ -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<Result<null, [400 | 500, string]>> {
// 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',
},
});
}

27
src/lib/types.ts Normal file
View File

@ -0,0 +1,27 @@
class Ok<T> {
readonly _tag = "ok";
value: T;
constructor(value: T) {
this.value = value;
}
}
class Err<E> {
readonly _tag = "err";
error: E;
constructor(error: E) {
this.error = error;
}
}
export type Result<T, E> = Ok<T> | Err<E>;
export function err<E>(e: E): Err<E> {
return new Err(e);
}
export function ok<T>(t: T): Ok<T> {
return new Ok(t);
}

View File

@ -23,7 +23,7 @@
{/if}
</nav>
</header>
<main class="flex-grow p-4">
<main class="flex flex-col flex-grow p-4">
{@render children()}
</main>

View File

@ -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<typeof error> | 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',
},
});
}

View File

@ -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;

View File

@ -1,7 +1,9 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import type { PageData } from './$types';
import { LucidePlus } from 'lucide-svelte';
const { data }: { data: PageData } = $props();
</script>
@ -32,3 +34,11 @@
{/each}
</Table.Body>
</Table.Root>
<!--Floating action button for adding a new client-->
<form class="self-end mt-auto pt-4" method="post" action="?/create">
<Button type="submit">
<LucidePlus />
Add Client
</Button>
</form>

View File

@ -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 @@
<title>User Profile</title>
</svelte:head>
<p>
{JSON.stringify(data.user)}
</p>
<pre>{JSON.stringify(data.user, null, 2)}</pre>
<Button onclick={refetch}>
<div class="flex gap-2">
<Button onclick={refetch}>
<LucideRefreshCw class="mr-2 h-4 w-4" />
Invalidate Data
</Button>
<form class="inline-flex" method="post" action="/auth?/logout">
<Button type="submit" onclick={() => {isLoadingSignOut = true}}>
{#if isLoadingSignOut}
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<LucideLogOut class="mr-2 h-4 w-4" />
{/if}
Sign Out
Invalidate Data
</Button>
</form>
<form class="inline-flex" method="post" action="/auth?/logout">
<Button type="submit" onclick={() => {isLoadingSignOut = true}}>
{#if isLoadingSignOut}
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<LucideLogOut class="mr-2 h-4 w-4" />
{/if}
Sign Out
</Button>
</form>
</div>