diff --git a/.env.example b/.env.example index c149642..7baed01 100644 --- a/.env.example +++ b/.env.example @@ -14,4 +14,5 @@ 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 +VPN_DNS=10.18.11.1,fd00:10:18:11::1 MAX_CLIENTS_PER_USER=20 diff --git a/src/lib/clients.ts b/src/lib/clients.ts new file mode 100644 index 0000000..91af124 --- /dev/null +++ b/src/lib/clients.ts @@ -0,0 +1,33 @@ +import type { ClientDetails } from '$lib/types/clients'; + +/** + * Convert client details to WireGuard configuration. + * + * ```conf + * [Interface] + * PrivateKey = wPa07zR0H4wYoc1ljfeiqlSbR8Z28pPc6jplwE7zPms= + * Address = 10.18.11.100/32,fd00::1/128 + * DNS = 10.18.11.1,fd00::0 + * + * [Peer] + * PublicKey = BJ5faPVJsDP4CCxNYilmKnwlQXOtXEOJjqIwb4U/CgM= + * PresharedKey = uhZUVqXKF0oayP0BS6yPu6Gepgh68Nz9prtbE5Cuok0= + * Endpoint = vpn.lab.cazzzer.com:51820 + * AllowedIPs = 0.0.0.0/0,::/0 + * ``` + * @param client + */ +export function clientDetailsToConfig(client: ClientDetails): string { + return `\ +[Interface] +PrivateKey = ${client.privateKey} +Address = ${client.ips.join(', ')} +DNS = ${client.vpnDns} + +[Peer] +PublicKey = ${client.vpnPublicKey} +PresharedKey = ${client.preSharedKey} +Endpoint = ${client.vpnEndpoint} +AllowedIPs = 0.0.0.0/0,::/0 +`; +} diff --git a/src/lib/server/clients.ts b/src/lib/server/clients.ts index f9d47f6..08a4849 100644 --- a/src/lib/server/clients.ts +++ b/src/lib/server/clients.ts @@ -1,17 +1,69 @@ 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 { opnsenseAuth, opnsenseUrl, serverPublicKey, 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, + IPV6_STARTING_ADDR, + MAX_CLIENTS_PER_USER, + VPN_DNS, VPN_ENDPOINT, } from '$env/static/private'; -import { count, eq, isNull } from 'drizzle-orm'; +import { and, count, eq, isNull } from 'drizzle-orm'; import { err, ok, type Result } from '$lib/types'; +import type { ClientDetails } from '$lib/types/clients'; + +export async function findClients(userId: string) { + return db.query.wgClients.findMany({ + columns: { + id: true, + name: true, + publicKey: true, + privateKey: true, + preSharedKey: true, + }, + with: { + ipAllocation: true, + }, + where: eq(wgClients.userId, userId), + }); +} + +export async function findClient(userId: string, clientId: number) { + return db.query.wgClients.findFirst({ + columns: { + id: true, + name: true, + publicKey: true, + privateKey: true, + preSharedKey: true, + }, + with: { + ipAllocation: true, + }, + where: and(eq(wgClients.userId, userId), eq(wgClients.id, clientId)), + }); +} + +export function mapClientToDetails( + client: Awaited>[0], +): ClientDetails { + const ips = getIpsFromIndex(client.ipAllocation.id); + return { + id: client.id, + name: client.name, + publicKey: client.publicKey, + privateKey: client.privateKey, + preSharedKey: client.preSharedKey, + ips, + vpnPublicKey: serverPublicKey, + vpnEndpoint: VPN_ENDPOINT, + vpnDns: VPN_DNS, + }; +} export async function createClient(params: { name: string; @@ -22,7 +74,8 @@ export async function createClient(params: { .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]); + 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 @@ -31,7 +84,7 @@ export async function createClient(params: { // 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 error = await db.transaction(async (tx) => { const [keys, availableAllocation, lastAllocation] = await Promise.all([ // fetch params for new client from opnsense api getKeys(), @@ -132,11 +185,9 @@ export function getIpsFromIndex(ipIndex: number) { const v6Allowed = Address6.fromBigInt(v6StartingAddr.bigInt() + v6Offset); const v6AllowedShort = v6Allowed.parsedAddress.join(':'); - return [ - v4Allowed.address + '/32', - v6AllowedShort + '/' + IPV6_CLIENT_PREFIX_SIZE, - ]; + return [v4Allowed.address + '/32', v6AllowedShort + '/' + IPV6_CLIENT_PREFIX_SIZE]; } + async function opnsenseCreateClient(params: { username: string; pubkey: string; diff --git a/src/lib/server/opnsense/index.ts b/src/lib/server/opnsense/index.ts index 435365b..226bdd8 100644 --- a/src/lib/server/opnsense/index.ts +++ b/src/lib/server/opnsense/index.ts @@ -10,11 +10,12 @@ assert(env.OPNSENSE_API_SECRET, 'OPNSENSE_API_SECRET is not set'); assert(env.OPNSENSE_WG_IFNAME, 'OPNSENSE_WG_IFNAME is not set'); export const opnsenseUrl = env.OPNSENSE_API_URL; -export const opnsenseAuth = "Basic " + encodeBasicCredentials(env.OPNSENSE_API_KEY, env.OPNSENSE_API_SECRET); +export const opnsenseAuth = + 'Basic ' + encodeBasicCredentials(env.OPNSENSE_API_KEY, env.OPNSENSE_API_SECRET); export const opnsenseIfname = env.OPNSENSE_WG_IFNAME; // unset secret for security -if (!dev) env.OPNSENSE_API_SECRET = ""; +if (!dev) env.OPNSENSE_API_SECRET = ''; // this might be pretty bad if the server is down and in a bunch of other cases // TODO: write a retry loop later @@ -23,11 +24,26 @@ const resServers = await fetch(`${opnsenseUrl}/api/wireguard/client/list_servers headers: { Authorization: opnsenseAuth, Accept: 'application/json', - } + }, }); assert(resServers.ok, 'Failed to fetch OPNsense WireGuard servers'); -const servers = await resServers.json() as OpnsenseWgServers; +const servers = (await resServers.json()) as OpnsenseWgServers; assert.equal(servers.status, 'ok', 'Failed to fetch OPNsense WireGuard servers'); -export const serverUuid = servers.rows.find(server => server.name === opnsenseIfname)?.uuid; +export const serverUuid = servers.rows.find((server) => server.name === opnsenseIfname)?.uuid; assert(serverUuid, 'Failed to find server UUID for OPNsense WireGuard server'); console.log('OPNsense WireGuard server UUID:', serverUuid); + +const resServerInfo = await fetch( + `${opnsenseUrl}/api/wireguard/client/get_server_info/${serverUuid}`, + { + method: 'GET', + headers: { + Authorization: opnsenseAuth, + Accept: 'application/json', + }, + }, +); +assert(resServerInfo.ok, 'Failed to fetch OPNsense WireGuard server info'); +const serverInfo = await resServerInfo.json(); +assert.equal(serverInfo.status, 'ok', 'Failed to fetch OPNsense WireGuard server info'); +export const serverPublicKey = serverInfo['pubkey']; diff --git a/src/lib/types/clients.ts b/src/lib/types/clients.ts new file mode 100644 index 0000000..210dfef --- /dev/null +++ b/src/lib/types/clients.ts @@ -0,0 +1,11 @@ +export type ClientDetails = { + id: number; + name: string; + publicKey: string; + privateKey: string | null; + preSharedKey: string | null; + ips: string[]; + vpnPublicKey: string; + vpnEndpoint: string; + vpnDns: string; +}; diff --git a/src/lib/types.ts b/src/lib/types/index.ts similarity index 87% rename from src/lib/types.ts rename to src/lib/types/index.ts index 4ca2179..8530396 100644 --- a/src/lib/types.ts +++ b/src/lib/types/index.ts @@ -1,5 +1,5 @@ class Ok { - readonly _tag = "ok"; + readonly _tag = 'ok'; value: T; constructor(value: T) { @@ -8,7 +8,7 @@ class Ok { } class Err { - readonly _tag = "err"; + readonly _tag = 'err'; error: E; constructor(error: E) { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d1e4e39..03fcda6 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,8 +7,8 @@ const { user } = data; function getNavClass(path: string) { - return cn("hover:text-foreground/80 transition-colors", - $page.url.pathname === path ? "text-foreground" : "text-foreground/60"); + return cn('hover:text-foreground/80 transition-colors', + $page.url.pathname.startsWith(path) ? 'text-foreground' : 'text-foreground/60'); } diff --git a/src/routes/api/clients/[id]/+server.ts b/src/routes/api/clients/[id]/+server.ts new file mode 100644 index 0000000..e144971 --- /dev/null +++ b/src/routes/api/clients/[id]/+server.ts @@ -0,0 +1,20 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { findClient, mapClientToDetails } from '$lib/server/clients'; + +export const GET: RequestHandler = async (event) => { + if (!event.locals.user) { + return error(401, 'Unauthorized'); + } + + const { id } = event.params; + const clientId = parseInt(id); + if (isNaN(clientId)) { + return error(400, 'Invalid client ID'); + } + const client = await findClient(event.locals.user.id, clientId); + if (!client) { + return error(404, 'Client not found'); + } + return new Response(JSON.stringify(mapClientToDetails(client))); +}; diff --git a/src/routes/clients/+page.svelte b/src/routes/clients/+page.svelte index d5368ec..282aa18 100644 --- a/src/routes/clients/+page.svelte +++ b/src/routes/clients/+page.svelte @@ -22,8 +22,12 @@ {#each data.clients as client} - - {client.name} + + + + {client.name} + + {client.publicKey} {client.privateKey} {client.preSharedKey} diff --git a/src/routes/clients/[id]/+page.svelte b/src/routes/clients/[id]/+page.svelte new file mode 100644 index 0000000..cfee559 --- /dev/null +++ b/src/routes/clients/[id]/+page.svelte @@ -0,0 +1,41 @@ + + + + + + +

Client: {data.client.name}

+ +
+
{data.config}
+ + +
+ + +
+
\ No newline at end of file diff --git a/src/routes/clients/[id]/+page.ts b/src/routes/clients/[id]/+page.ts new file mode 100644 index 0000000..8a3b993 --- /dev/null +++ b/src/routes/clients/[id]/+page.ts @@ -0,0 +1,11 @@ +import type { PageLoad } from './$types'; +import type { ClientDetails } from '$lib/types/clients'; +import { clientDetailsToConfig } from '$lib/clients'; + +export const load: PageLoad = async ({ fetch, params }) => { + const res = await fetch(`/api/clients/${params.id}`); + const client = (await res.json()) as ClientDetails; + const config = clientDetailsToConfig(client); + + return { client, config }; +};