diff --git a/.env.example b/.env.example index d3c3a69..4a0393e 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -DATABASE_URL=local.db +DATABASE_URL=file:local.db AUTH_DOMAIN=auth.lab.cazzzer.com AUTH_CLIENT_ID= AUTH_CLIENT_SECRET= diff --git a/bun.lockb b/bun.lockb index ec7b8fc..e22ff0d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8bc45b9..d64ca26 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "format": "prettier --write .", "lint": "prettier --check . && eslint .", "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "db:seed": "bun run ./src/lib/server/db/seed.ts" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.0.0", @@ -26,7 +28,7 @@ "autoprefixer": "^10.4.20", "bits-ui": "^0.21.16", "clsx": "^2.1.1", - "drizzle-kit": "^0.22.0", + "drizzle-kit": "^0.30.1", "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", @@ -44,11 +46,11 @@ "vite": "^5.0.3" }, "dependencies": { + "@libsql/client": "^0.14.0", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "arctic": "^2.2.1", - "better-sqlite3": "^11.1.2", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "^0.38.2", "lucide-svelte": "^0.454.0" } } diff --git a/src/lib/opnsense/wg.ts b/src/lib/opnsense/wg.ts index b463f58..3bef846 100644 --- a/src/lib/opnsense/wg.ts +++ b/src/lib/opnsense/wg.ts @@ -59,3 +59,11 @@ export interface OpnsenseWgPeers { * }; * ``` */ + +export interface OpnsenseWgServers { + status: "ok" | string | number; + rows: { + name: string; + uuid: string; + }[]; +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 667c442..1973590 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -23,7 +23,7 @@ export async function createSession(userId: string): Promise { userId, expiresAt: new Date(Date.now() + DAY_IN_MS * 30) }; - await db.insert(table.session).values(session); + await db.insert(table.sessions).values(session); return session; } @@ -38,7 +38,7 @@ export function setSessionTokenCookie(event: RequestEvent, sessionId: string, ex } export async function invalidateSession(sessionId: string): Promise { - await db.delete(table.session).where(eq(table.session.id, sessionId)); + await db.delete(table.sessions).where(eq(table.sessions.id, sessionId)); } export function deleteSessionTokenCookie(event: RequestEvent) { @@ -49,12 +49,12 @@ export async function validateSession(sessionId: string) { const [result] = await db .select({ // Adjust user table here to tweak returned data - user: { id: table.user.id, username: table.user.username, name: table.user.name }, - session: table.session + user: { id: table.users.id, username: table.users.username, name: table.users.name }, + session: table.sessions }) - .from(table.session) - .innerJoin(table.user, eq(table.session.userId, table.user.id)) - .where(eq(table.session.id, sessionId)); + .from(table.sessions) + .innerJoin(table.users, eq(table.sessions.userId, table.users.id)) + .where(eq(table.sessions.id, sessionId)); if (!result) { return { session: null, user: null }; @@ -63,7 +63,7 @@ export async function validateSession(sessionId: string) { const sessionExpired = Date.now() >= session.expiresAt.getTime(); if (sessionExpired) { - await db.delete(table.session).where(eq(table.session.id, session.id)); + await db.delete(table.sessions).where(eq(table.sessions.id, session.id)); return { session: null, user: null }; } @@ -71,9 +71,9 @@ export async function validateSession(sessionId: string) { if (renewSession) { session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); await db - .update(table.session) + .update(table.sessions) .set({ expiresAt: session.expiresAt }) - .where(eq(table.session.id, session.id)); + .where(eq(table.sessions.id, session.id)); } return { session, user }; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index d8c8b85..62c6f8e 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -1,8 +1,7 @@ -import { drizzle } from 'drizzle-orm/better-sqlite3'; -import Database from 'better-sqlite3'; -import { env } from '$env/dynamic/private'; +import { drizzle } from 'drizzle-orm/libsql'; import assert from 'node:assert'; +import * as schema from './schema'; +import { DATABASE_URL } from '$env/static/private'; -assert(env.DATABASE_URL, 'DATABASE_URL is not set'); -const client = new Database(env.DATABASE_URL); -export const db = drizzle(client); +assert(DATABASE_URL, 'DATABASE_URL is not set'); +export const db= drizzle(DATABASE_URL, { schema }); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index ddcbc75..6b51959 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,19 +1,59 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import { relations } from 'drizzle-orm'; -export const user = sqliteTable('user', { +export const users = sqliteTable('users', { id: text('id').primaryKey(), username: text('username').notNull(), name: text('name').notNull(), }); -export const session = sqliteTable('session', { +export const usersRelations = relations(users, ({ many }) => ({ + wgClients: many(wgClients), +})); + +export const sessions = sqliteTable('sessions', { id: text('id').primaryKey(), userId: text('user_id') .notNull() - .references(() => user.id), - expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull() + .references(() => users.id), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), }); -export type Session = typeof session.$inferSelect; +export const ipAllocations = sqliteTable('ip_allocations', { + // for now, id will be the same as the ipIndex + id: integer('id').primaryKey({ autoIncrement: true }), + // clientId is nullable because allocations can remain after the client is deleted + // unique for now, only allowing one allocation per client + clientId: integer('client_id') + .unique() + .references(() => wgClients.id), +}); -export type User = typeof user.$inferSelect; +export const wgClients = sqliteTable('wg_clients', { + id: integer().primaryKey({ autoIncrement: true }), + userId: text('user_id') + .notNull() + .references(() => users.id), + name: text('name').notNull(), + // questioning whether this should be nullable + opnsenseId: text('opnsense_id'), + publicKey: text('public_key').notNull().unique(), + // nullable for the possibility of a client supplying their own private key + privateKey: text('private_key'), + // nullable for the possibility of no psk + preSharedKey: text('pre_shared_key'), + // discarded ideas: + // (mostly because they make finding the next available ipIndex difficult) + // ipIndex: integer('ip_index').notNull().unique(), + // allowedIps: text('allowed_ips').notNull(), +}); + +export const wgClientsRelations = relations(wgClients, ({ one }) => ({ + ipAllocation: one(ipAllocations), +})); + +export type WgClient = typeof wgClients.$inferSelect; + +export type Session = typeof sessions.$inferSelect; + +export type User = typeof users.$inferSelect; diff --git a/src/lib/server/db/seed.ts b/src/lib/server/db/seed.ts new file mode 100644 index 0000000..92f1418 --- /dev/null +++ b/src/lib/server/db/seed.ts @@ -0,0 +1,29 @@ +import { users, wgClients } from './schema'; +import { eq } from 'drizzle-orm'; +import assert from 'node:assert'; +import { drizzle } from 'drizzle-orm/libsql'; +import * as schema from '$lib/server/db/schema'; + +assert(process.env.DATABASE_URL, 'DATABASE_URL is not set'); +const db = drizzle(process.env.DATABASE_URL, { schema }); + +export async function seed() { + const user = await db.query.users.findFirst({ where: eq(users.username, 'CaZzzer') }); + assert(user, 'User not found'); + + const clients = [ + { + userId: user.id, + name: 'Client1', + publicKey: 'BJ5faPVJsDP4CCxNYilmKnwlQXOtXEOJjqIwb4U/CgM=', + privateKey: 'KKqsHDu30WCSrVsyzMkOKbE3saQ+wlx0sBwGs61UGXk=', + preSharedKey: '0LWopbrISXBNHUxr+WOhCSAg+0hD8j3TLmpyzHkBHCQ=', + ipIndex: 1, + // allowedIps: '10.18.11.101/32,fd00::1/112', + } + ] + + await db.insert(wgClients).values(clients); +} + +seed(); diff --git a/src/lib/server/opnsense.ts b/src/lib/server/opnsense.ts deleted file mode 100644 index 58655b0..0000000 --- a/src/lib/server/opnsense.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { env } from '$env/dynamic/private'; -import assert from 'node:assert'; -import { encodeBasicCredentials } from 'arctic/dist/request'; -import { dev } from '$app/environment'; - -assert(env.OPNSENSE_API_URL, 'OPNSENSE_API_URL is not set'); -assert(env.OPNSENSE_API_KEY, 'OPNSENSE_API_KEY is not set'); -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 opnsenseIfname = env.OPNSENSE_WG_IFNAME; - -// unset secret for security -if (!dev) env.OPNSENSE_API_SECRET = ""; diff --git a/src/lib/server/opnsense/index.ts b/src/lib/server/opnsense/index.ts new file mode 100644 index 0000000..435365b --- /dev/null +++ b/src/lib/server/opnsense/index.ts @@ -0,0 +1,33 @@ +import { env } from '$env/dynamic/private'; +import assert from 'node:assert'; +import { encodeBasicCredentials } from 'arctic/dist/request'; +import { dev } from '$app/environment'; +import type { OpnsenseWgServers } from '$lib/opnsense/wg'; + +assert(env.OPNSENSE_API_URL, 'OPNSENSE_API_URL is not set'); +assert(env.OPNSENSE_API_KEY, 'OPNSENSE_API_KEY is not set'); +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 opnsenseIfname = env.OPNSENSE_WG_IFNAME; + +// unset secret for security +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 +const resServers = await fetch(`${opnsenseUrl}/api/wireguard/client/list_servers`, { + method: 'GET', + headers: { + Authorization: opnsenseAuth, + Accept: 'application/json', + } +}); +assert(resServers.ok, 'Failed to fetch OPNsense WireGuard servers'); +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; +assert(serverUuid, 'Failed to find server UUID for OPNsense WireGuard server'); +console.log('OPNsense WireGuard server UUID:', serverUuid); diff --git a/src/routes/api/connections/+server.ts b/src/routes/api/connections/+server.ts index 4b2e343..1167743 100644 --- a/src/routes/api/connections/+server.ts +++ b/src/routes/api/connections/+server.ts @@ -1,9 +1,12 @@ import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { opnsenseAuth, opnsenseIfname, opnsenseUrl } from '$lib/server/opnsense'; +import { opnsenseAuth, opnsenseUrl } from '$lib/server/opnsense'; import type { OpnsenseWgPeers } from '$lib/opnsense/wg'; -export const GET: RequestHandler = async () => { +export const GET: RequestHandler = async (event) => { + if (!event.locals.user) { + return error(401, 'Unauthorized'); + } const apiUrl = `${opnsenseUrl}/api/wireguard/service/show`; const options: RequestInit = { method: 'POST', @@ -13,21 +16,24 @@ export const GET: RequestHandler = async () => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - "current": 1, + 'current': 1, // "rowCount": 7, - "sort": {}, - "searchPhrase": "", - "type": ["peer"], + '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-${event.locals.user.username}`, + 'type': ['peer'], }), }; - console.log("Fetching peers from OPNsense WireGuard API: ", apiUrl, options) + console.log('Fetching peers from OPNsense WireGuard API: ', apiUrl, options) const res = await fetch(apiUrl, options); const peers = await res.json() as OpnsenseWgPeers; - peers.rows = peers.rows.filter(peer => peer['latest-handshake'] && peer.ifname === opnsenseIfname) + peers.rows = peers.rows.filter(peer => peer['latest-handshake']) if (!peers) { - error(500, "Error getting info from OPNsense API"); + return error(500, 'Error getting info from OPNsense API'); } return new Response(JSON.stringify(peers), { headers: { diff --git a/src/routes/auth/authentik/callback/+server.ts b/src/routes/auth/authentik/callback/+server.ts index 791e8fa..a150155 100644 --- a/src/routes/auth/authentik/callback/+server.ts +++ b/src/routes/auth/authentik/callback/+server.ts @@ -39,7 +39,7 @@ export async function GET(event: RequestEvent): Promise { const userId: string = claims.sub; const username: string = claims.preferred_username; - const [existingUser] = await db.select().from(table.user).where(eq(table.user.id, userId)); + const existingUser = await db.query.users.findFirst({where: eq(table.users.id, userId)}); if (existingUser) { const session = await createSession(existingUser.id); @@ -59,7 +59,7 @@ export async function GET(event: RequestEvent): Promise { }; try { - await db.insert(table.user).values(user); + await db.insert(table.users).values(user); const session = await createSession(user.id); setSessionTokenCookie(event, session.id, session.expiresAt); } catch (e) {