From bacba95adc21ae0b730a688f2fc5d489ee3c8741 Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Sun, 16 Mar 2025 00:11:31 -0700 Subject: [PATCH] WIP: add interface for wg provider --- package.json | 1 + src/hooks.server.ts | 21 +++- src/lib/server/auth.ts | 2 +- src/lib/server/devices/create.ts | 94 +++----------- src/lib/server/devices/delete.ts | 1 - src/lib/server/devices/find.ts | 3 +- src/lib/server/providers/opnsense.ts | 168 ++++++++++++++++++++++++++ src/lib/server/types/index.ts | 39 ++++++ src/lib/types/index.ts | 38 +++--- src/lib/types/result.ts | 27 +++++ src/routes/api/connections/+server.ts | 50 ++------ src/routes/devices/+page.server.ts | 2 +- 12 files changed, 299 insertions(+), 147 deletions(-) create mode 100644 src/lib/server/providers/opnsense.ts create mode 100644 src/lib/server/types/index.ts create mode 100644 src/lib/types/result.ts diff --git a/package.json b/package.json index d0a9d92..89685f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "vpgen", "version": "0.0.1", + "license": "AGPL-3.0-or-later", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f523dc5..24bfd68 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,10 +2,23 @@ import { type Handle, redirect } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { dev } from '$app/environment'; import * as auth from '$lib/server/auth'; -import { fetchOpnsenseServer } from '$lib/server/opnsense'; +import { WgProviderOpnsense } from '$lib/server/providers/opnsense'; +import { env } from '$env/dynamic/private'; -// fetch opnsense server info on startup -await fetchOpnsenseServer(); +// TODO: probably move this to a separate file, +// without injecting the provider into the event +const provider = new WgProviderOpnsense({ + opnsenseUrl: env.OPNSENSE_API_URL, + opnsenseApiKey: env.OPNSENSE_API_KEY, + opnsenseApiSecret: env.OPNSENSE_API_SECRET, + opnsenseWgIfname: env.OPNSENSE_WG_IFNAME, +}); +await provider.init(); + +const injectWgProvider: Handle = ({ event, resolve }) => { + event.locals.wgProvider = provider; + return resolve(event); +} const handleAuth: Handle = async ({ event, resolve }) => { const sessionId = event.cookies.get(auth.sessionCookieName); @@ -50,4 +63,4 @@ const handleProtectedPaths: Handle = ({ event, resolve }) => { return resolve(event); } -export const handle: Handle = sequence(handleAuth, handleProtectedPaths); +export const handle: Handle = sequence(injectWgProvider, handleAuth, handleProtectedPaths); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 3309bca..f7a56f9 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -50,7 +50,7 @@ export async function validateSession(sessionId: string) { const [result] = await db .select({ // Adjust user table here to tweak returned data - user: { id: table.users.id, username: table.users.username, name: table.users.name }, + user: { id: table.users.id, authSource: table.users.authSource, username: table.users.username, name: table.users.name }, session: table.sessions }) .from(table.sessions) diff --git a/src/lib/server/devices/create.ts b/src/lib/server/devices/create.ts index 5dd5280..4eb9dd3 100644 --- a/src/lib/server/devices/create.ts +++ b/src/lib/server/devices/create.ts @@ -3,11 +3,10 @@ import { err, ok, type Result } from '$lib/types'; import { db } from '$lib/server/db'; import { count, eq, isNull } from 'drizzle-orm'; import { env } from '$env/dynamic/private'; -import { opnsenseAuth, opnsenseUrl, serverUuid } from '$lib/server/opnsense'; -import { opnsenseSanitezedUsername } from '$lib/opnsense'; import { getIpsFromIndex } from './utils'; +import type { IWgProvider } from '$lib/server/types'; -export async function createDevice(params: { +export async function createDevice(wgProvider: IWgProvider, params: { name: string; user: User; }): Promise> { @@ -27,9 +26,9 @@ export async function createDevice(params: { // 3. create the client in opnsense // 4. reconfigure opnsense to enable the new client return await db.transaction(async (tx) => { - const [keys, availableAllocation, lastAllocation] = await Promise.all([ + const [keysResult, availableAllocation, lastAllocation] = await Promise.all([ // fetch params for new device from opnsense api - getKeys(), + wgProvider.generateKeys(), // find first unallocated IP await tx.query.ipAllocations.findFirst({ columns: { @@ -46,9 +45,12 @@ export async function createDevice(params: { }), ]); + if (keysResult?._tag === 'err') return err([500, 'Failed to get keys']); + const keys = keysResult.value; + // check for existing allocation or if we have any IPs left if (!availableAllocation && lastAllocation && lastAllocation.id >= parseInt(env.IP_MAX_INDEX)) { - return err([500, 'No more IP addresses available'] as [500, string]); + return err([500, 'No more IP addresses available']); } // use existing allocation or create a new one @@ -65,9 +67,9 @@ export async function createDevice(params: { .values({ userId: params.user.id, name: params.name, - publicKey: keys.pubkey, - privateKey: keys.privkey, - preSharedKey: keys.psk, + publicKey: keys.publicKey, + privateKey: keys.privateKey, + preSharedKey: keys.preSharedKey, }) .returning({ id: devices.id }); @@ -78,79 +80,17 @@ export async function createDevice(params: { .where(eq(ipAllocations.id, ipAllocationId)); // create client in opnsense - const opnsenseRes = await opnsenseCreateClient({ - username: params.user.username, - pubkey: keys.pubkey, - psk: keys.psk, + const providerRes = await wgProvider.createClient({ + user: params.user, + publicKey: keys.publicKey, + preSharedKey: keys.preSharedKey, allowedIps: getIpsFromIndex(ipAllocationId).join(','), }); - const opnsenseResJson = await opnsenseRes.json(); - if (opnsenseResJson['result'] !== 'saved') { + if (providerRes._tag === 'err') { tx2.rollback(); - console.error(`Error creating client in OPNsense: \n${opnsenseResJson}`); - return err([500, 'Error creating client in OPNsense'] as [500, string]); + return err([500, 'Failed to create client in OPNsense']); } - - // reconfigure opnsense - await opnsenseReconfigure(); return ok(newDevice.id); }); }); } - -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, - }; -} - -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-${opnsenseSanitezedUsername(params.username)}`, - pubkey: params.pubkey, - psk: params.psk, - tunneladdress: params.allowedIps, - server: serverUuid, - endpoint: env.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/server/devices/delete.ts b/src/lib/server/devices/delete.ts index bebffc0..933e1bf 100644 --- a/src/lib/server/devices/delete.ts +++ b/src/lib/server/devices/delete.ts @@ -2,7 +2,6 @@ import { and, eq } from 'drizzle-orm'; import { db } from '$lib/server/db'; import { devices } from '$lib/server/db/schema'; import { err, ok, type Result } from '$lib/types'; -import { opnsenseAuth, opnsenseUrl } from '$lib/server/opnsense'; export async function deleteDevice( userId: string, diff --git a/src/lib/server/devices/find.ts b/src/lib/server/devices/find.ts index f3b3810..7a3c595 100644 --- a/src/lib/server/devices/find.ts +++ b/src/lib/server/devices/find.ts @@ -2,7 +2,6 @@ import { db } from '$lib/server/db'; import { and, eq } from 'drizzle-orm'; import { devices } from '$lib/server/db/schema'; import type { DeviceDetails } from '$lib/devices'; -import { serverPublicKey } from '$lib/server/opnsense'; import { env } from '$env/dynamic/private'; import { getIpsFromIndex } from '$lib/server/devices/index'; @@ -49,7 +48,7 @@ export function mapDeviceToDetails( privateKey: device.privateKey, preSharedKey: device.preSharedKey, ips, - vpnPublicKey: serverPublicKey, + vpnPublicKey: '', // TODO: fix vpnEndpoint: env.VPN_ENDPOINT, vpnDns: env.VPN_DNS, }; diff --git a/src/lib/server/providers/opnsense.ts b/src/lib/server/providers/opnsense.ts new file mode 100644 index 0000000..9c69f25 --- /dev/null +++ b/src/lib/server/providers/opnsense.ts @@ -0,0 +1,168 @@ +import type { IWgProvider } from '$lib/server/types'; +import { encodeBasicCredentials } from 'arctic/dist/request'; +import { is } from 'typia'; +import type { OpnsenseWgPeers, OpnsenseWgServers } from '$lib/opnsense/wg'; +import { err, ok } from '$lib/types'; +import { opnsenseSanitezedUsername } from '$lib/opnsense'; +import assert from 'node:assert'; + +export class WgProviderOpnsense implements IWgProvider { + private opnsenseUrl: string; + private opnsenseAuth: string; + private opnsenseIfname: string; + private opnsenseWgServerUuid: string | undefined; + + public constructor(params: OpnsenseParams) { + this.opnsenseUrl = params.opnsenseUrl; + this.opnsenseAuth = 'Basic ' + encodeBasicCredentials(params.opnsenseApiKey, params.opnsenseApiSecret); + this.opnsenseIfname = params.opnsenseWgIfname; + } + + public async init() { + const resServers = await fetch(`${this.opnsenseUrl}/api/wireguard/client/list_servers`, { + method: 'GET', + headers: { + Authorization: this.opnsenseAuth, + Accept: 'application/json', + }, + }); + + const servers = await resServers.json(); + if (!is(servers)) { + console.error('Unexpected response for OPNsense WireGuard servers', servers); + return err(new Error('Failed to fetch OPNsense WireGuard servers')); + } + + const uuid = servers.rows.find((server) => server.name === this.opnsenseIfname)?.uuid; + if (!uuid) { + console.error('OPNsense WireGuard servers', servers); + return err(new Error('Failed to find server UUID for OPNsense WireGuard server')); + } + + console.debug('OPNsense WireGuard server UUID:', uuid); + this.opnsenseWgServerUuid = uuid; + return ok(null); + } + + public async generateKeys() { + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: this.opnsenseAuth, + Accept: 'application/json', + }, + }; + const resKeyPair = await fetch(`${this.opnsenseUrl}/api/wireguard/server/key_pair`, options); + const resPsk = await fetch(`${this.opnsenseUrl}/api/wireguard/client/psk`, options); + const keyPair = await resKeyPair.json(); + const psk = await resPsk.json(); + + if (!is<{ pubkey: string; privkey: string }>(keyPair)) { + console.error('Unexpected response for OPNsense key pair', keyPair); + return err(new Error('Failed to fetch OPNsense key pair')); + } + if (!is<{ psk: string }>(psk)) { + console.error('Unexpected response for OPNsense PSK', psk); + return err(new Error('Failed to fetch OPNsense PSK')); + } + + return ok({ + publicKey: keyPair.pubkey, + privateKey: keyPair.privkey, + preSharedKey: psk.psk, + }); + } + + async createClient(params) { + assert(this.opnsenseWgServerUuid, 'OPNsense server UUID not set, init() must be called first'); + + const createClientRes = await fetch(`${this.opnsenseUrl}/api/wireguard/client/addClientBuilder`, { + method: 'POST', + headers: { + Authorization: this.opnsenseAuth, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + configbuilder: { + enabled: '1', + name: `vpgen-${opnsenseSanitezedUsername(params.user.username)}`, + pubkey: params.publicKey, + psk: params.preSharedKey, + tunneladdress: params.allowedIps, + server: this.opnsenseWgServerUuid, + endpoint: '', + }, + }), + }); + const createClientResJson = await createClientRes.json(); + if (createClientResJson['result'] !== 'saved') { + console.error('Error creating client in OPNsense', createClientResJson); + return err(new Error('Failed to create client in OPNsense')); + } + + const reconfigureRes = await fetch(`${this.opnsenseUrl}/api/wireguard/service/reconfigure`, { + method: 'POST', + headers: { + Authorization: this.opnsenseAuth, + Accept: 'application/json', + }, + }); + + if (reconfigureRes.status !== 200) { + console.error('Error reconfiguring OPNsense', reconfigureRes); + return err(new Error('Failed to reconfigure OPNsense')); + } + + return ok(null); + } + + async findConnections(user) { + const res = await fetch(`${this.opnsenseUrl}/api/wireguard/service/show`, { + method: 'POST', + headers: { + Authorization: this.opnsenseAuth, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + current: 1, + // "rowCount": 7, + sort: {}, + // TODO: use a more unique search phrase + // unfortunately 64 character limit, + // but it should be fine if users can't change their own username + searchPhrase: `vpgen-${opnsenseSanitezedUsername(user.username)}`, + type: ['peer'], + }), + }); + + const peers = await res.json(); + if (!is(peers)) { + console.error('Unexpected response for OPNsense WireGuard peers', peers); + return err(new Error('Failed to fetch OPNsense WireGuard peers')); + } + + return ok(peers.rows.map((peer) => { + return { + publicKey: peer['public-key'], + endpoint: peer['endpoint'], + allowedIps: peer['allowed-ips'], + transferRx: peer['transfer-tx'], + transferTx: peer['transfer-rx'], + latestHandshake: peer['latest-handshake'] * 1000, + }; + })) + } + + async deleteClient(publicKey) { + return err(new Error('OPNsense deleteClient not implemented')); + } +} + +export type OpnsenseParams = { + opnsenseUrl: string; + opnsenseApiKey: string; + opnsenseApiSecret: string; + opnsenseWgIfname: string; +}; diff --git a/src/lib/server/types/index.ts b/src/lib/server/types/index.ts new file mode 100644 index 0000000..95b78e9 --- /dev/null +++ b/src/lib/server/types/index.ts @@ -0,0 +1,39 @@ +import type { Result } from '$lib/types'; +import type { User } from '$lib/server/db/schema'; +import type { ConnectionDetails } from '$lib/connections'; + +export interface IWgProvider { + init(): Promise>; + + // TODO: might be better to not make this a result type + getServerPublicKey(): Promise>; + + // TODO: might be better to not make this a result type + generateKeys(): Promise>; + + createClient(params: { + user: User; + publicKey: string; + preSharedKey: string; + allowedIps: string; + }): Promise>; + + findConnections(user: User): Promise>; + + deleteClient(publicKey: string): Promise>; +} + +export type WgKeys = { + publicKey: string; + privateKey: string; + preSharedKey: string; +}; + +export type ClientConnection = { + publicKey: string; + endpoint: string; + allowedIps: string; + transferRx: number; + transferTx: number; + latestHandshake: number; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 8530396..9641607 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,27 +1,17 @@ -class Ok { - readonly _tag = 'ok'; - value: T; +import type { Address4, Address6 } from 'ip-address'; - constructor(value: T) { - this.value = value; +export type { Result } from './result'; +export { ok, err } from './result'; + +export interface IWGProviderConfig { + ipv4?: { + startingAddr: Address4; + }; + ipv6?: { + startingAddr: Address6; + clientPrefixSize: number; } -} - -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); + endpoint: string; + dns: string; + addrMaxIndex: number; } diff --git a/src/lib/types/result.ts b/src/lib/types/result.ts new file mode 100644 index 0000000..8530396 --- /dev/null +++ b/src/lib/types/result.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/api/connections/+server.ts b/src/routes/api/connections/+server.ts index f149152..e042369 100644 --- a/src/routes/api/connections/+server.ts +++ b/src/routes/api/connections/+server.ts @@ -1,44 +1,42 @@ import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { opnsenseAuth, opnsenseUrl } from '$lib/server/opnsense'; -import type { OpnsenseWgPeers } from '$lib/opnsense/wg'; import { findDevices } from '$lib/server/devices'; import type { ConnectionDetails } from '$lib/connections'; -import { opnsenseSanitezedUsername } from '$lib/opnsense'; +import type { Result } from '$lib/types'; +import type { ClientConnection } from '$lib/server/types'; export const GET: RequestHandler = async (event) => { if (!event.locals.user) { return error(401, 'Unauthorized'); } console.debug('/api/connections'); - const peers = await fetchOpnsensePeers(event.locals.user.username); - console.debug('/api/connections: fetched opnsense peers', peers.rowCount); + + const peersResult: Result = await event.locals.wgProvider.findConnections(event.locals.user); + if (peersResult._tag === 'err') return error(500, peersResult.error.message); + const devices = await findDevices(event.locals.user.id); console.debug('/api/connections: fetched db devices'); - if (!peers) { - return error(500, 'Error getting info from OPNsense API'); - } // TODO: this is all garbage performance // filter devices with no recent handshakes - peers.rows = peers.rows.filter((peer) => peer['latest-handshake']); + const peers = peersResult.value.filter((peer) => peer.latestHandshake); // start from devices, to treat db as the source of truth const connections: ConnectionDetails[] = []; for (const device of devices) { - const peerData = peers.rows.find((peer) => peer['public-key'] === device.publicKey); + const peerData = peers.find((peer) => peer.publicKey === device.publicKey); if (!peerData) continue; connections.push({ deviceId: device.id, deviceName: device.name, devicePublicKey: device.publicKey, - deviceIps: peerData['allowed-ips'].split(','), - endpoint: peerData['endpoint'], + deviceIps: peerData.allowedIps.split(','), + endpoint: peerData.endpoint, // swap rx and tx, since the opnsense values are from the server perspective - transferRx: peerData['transfer-tx'], - transferTx: peerData['transfer-rx'], - latestHandshake: peerData['latest-handshake'] * 1000, + transferRx: peerData.transferTx, + transferTx: peerData.transferRx, + latestHandshake: peerData.latestHandshake, }); } @@ -49,25 +47,3 @@ export const GET: RequestHandler = async (event) => { }, }); }; - -async function fetchOpnsensePeers(username: string) { - const res = await fetch(`${opnsenseUrl}/api/wireguard/service/show`, { - method: 'POST', - headers: { - Authorization: opnsenseAuth, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - current: 1, - // "rowCount": 7, - sort: {}, - // TODO: use a more unique search phrase - // unfortunately 64 character limit, - // but it should be fine if users can't change their own username - searchPhrase: `vpgen-${opnsenseSanitezedUsername(username)}`, - type: ['peer'], - }), - }); - return (await res.json()) as OpnsenseWgPeers; -} diff --git a/src/routes/devices/+page.server.ts b/src/routes/devices/+page.server.ts index 4dcf0ce..8940ffe 100644 --- a/src/routes/devices/+page.server.ts +++ b/src/routes/devices/+page.server.ts @@ -8,7 +8,7 @@ export const actions = { const formData = await event.request.formData(); const name = formData.get('name'); if (typeof name !== 'string' || name.trim() === '') return fail(400, { name, invalid: true }); - const res = await createDevice({ + const res = await createDevice(event.locals.wgProvider, { name: name.trim(), user: event.locals.user, });