Compare commits

...

3 Commits

18 changed files with 406 additions and 116 deletions

20
.idea/dataSources.xml generated Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="local" uuid="f362368b-ba47-4270-9f81-b0d8484b9928">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/local.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar</url>
</library>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
</library>
</libraries>
</data-source>
</component>
</project>

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,32 @@
CREATE TABLE `ip_allocations` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`client_id` integer,
FOREIGN KEY (`client_id`) REFERENCES `wg_clients`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `ip_allocations_client_id_unique` ON `ip_allocations` (`client_id`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `wg_clients` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`opnsense_id` text,
`public_key` text NOT NULL,
`private_key` text,
`pre_shared_key` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `wg_clients_public_key_unique` ON `wg_clients` (`public_key`);

View File

@ -0,0 +1,221 @@
{
"version": "6",
"dialect": "sqlite",
"id": "29e6fd88-fa47-4f79-ad83-c52538bc36a6",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"ip_allocations": {
"name": "ip_allocations",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"ip_allocations_client_id_unique": {
"name": "ip_allocations_client_id_unique",
"columns": [
"client_id"
],
"isUnique": true
}
},
"foreignKeys": {
"ip_allocations_client_id_wg_clients_id_fk": {
"name": "ip_allocations_client_id_wg_clients_id_fk",
"tableFrom": "ip_allocations",
"tableTo": "wg_clients",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wg_clients": {
"name": "wg_clients",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"opnsense_id": {
"name": "opnsense_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pre_shared_key": {
"name": "pre_shared_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"wg_clients_public_key_unique": {
"name": "wg_clients_public_key_unique",
"columns": [
"public_key"
],
"isUnique": true
}
},
"foreignKeys": {
"wg_clients_user_id_users_id_fk": {
"name": "wg_clients_user_id_users_id_fk",
"tableFrom": "wg_clients",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1735028333867,
"tag": "0000_young_wong",
"breakpoints": true
}
]
}

View File

@ -25,6 +25,7 @@
"@tailwindcss/typography": "^0.5.15",
"@types/better-sqlite3": "^7.6.11",
"@types/eslint": "^9.6.0",
"@types/qrcode-svg": "^1.1.5",
"autoprefixer": "^10.4.20",
"bits-ui": "^0.21.16",
"clsx": "^2.1.1",
@ -52,6 +53,7 @@
"arctic": "^2.2.1",
"drizzle-orm": "^0.38.2",
"ip-address": "^10.0.1",
"lucide-svelte": "^0.454.0"
"lucide-svelte": "^0.454.0",
"qrcode-svg": "^1.1.0"
}
}

View File

@ -4,16 +4,16 @@
@layer base {
:root {
--background: 0 0% 100%;
--background: 0 0% 90%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover: 0 0% 90%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card: 0 0% 90%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
@ -22,10 +22,10 @@
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary: 210 26% 86%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent: 210 26% 86%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
@ -39,31 +39,31 @@
@media (prefers-color-scheme: dark) {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--foreground: 210 40% 90%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover-foreground: 210 40% 90%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card-foreground: 210 40% 90%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary: 210 40% 90%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary-foreground: 210 40% 90%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent-foreground: 210 40% 90%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--destructive-foreground: 210 40% 90%;
--ring: 212.7 26.8% 83.9%;
}

View File

@ -31,14 +31,17 @@ const handleAuth: Handle = async ({ event, resolve }) => {
};
const authRequired = new Set([
'/user',
'/connections',
'/api/connections',
]);
const authRequired = [
/^\/api/,
/^\/user/,
/^\/connections/,
/^\/clients/,
];
const handleProtectedPaths: Handle = ({ event, resolve }) => {
if (authRequired.has(event.url.pathname) && !event.locals.user) {
return redirect(302, '/');
const isProtected = authRequired.some((re) => re.test(event.url.pathname));
if (!event.locals.user && isProtected) {
return redirect(302, '/');
}
return resolve(event);
}

View File

@ -140,7 +140,7 @@ export async function createClient(params: {
username: params.user.username,
pubkey: keys.pubkey,
psk: keys.psk,
allowedIps: getIpsFromIndex(ipAllocationId - 1).join(','),
allowedIps: getIpsFromIndex(ipAllocationId).join(','),
});
const opnsenseResJson = await opnsenseRes.json();
if (opnsenseResJson['result'] !== 'saved') {
@ -178,6 +178,7 @@ async function getKeys() {
}
export function getIpsFromIndex(ipIndex: number) {
ipIndex -= 1; // 1-indexed in the db
const v4StartingAddr = new Address4(IPV4_STARTING_ADDR);
const v6StartingAddr = new Address6(IPV6_STARTING_ADDR);
const v4Allowed = Address4.fromBigInt(v4StartingAddr.bigInt() + BigInt(ipIndex));

View File

@ -26,7 +26,7 @@ export const ipAllocations = sqliteTable('ip_allocations', {
// unique for now, only allowing one allocation per client
clientId: integer('client_id')
.unique()
.references(() => wgClients.id),
.references(() => wgClients.id, { onDelete: 'set null' }),
});
export const wgClients = sqliteTable('wg_clients', {

View File

@ -6,20 +6,20 @@
const { data, children } = $props();
const { user } = data;
function getNavClass(path: string) {
function getNavClass(path: RegExp) {
return cn('hover:text-foreground/80 transition-colors',
$page.url.pathname.startsWith(path) ? 'text-foreground' : 'text-foreground/60');
path.test($page.url.pathname) ? 'text-foreground' : 'text-foreground/60');
}
</script>
<header class="p-4 sm:flex">
<span class=" mr-6 font-bold sm:inline-block">VPGen</span>
<nav class="flex items-center gap-6 text-sm">
<a href="/" class={getNavClass("/")}>Home</a>
<a href="/" class={getNavClass(/^\/$/)}>Home</a>
{#if user}
<a href="/user" class={getNavClass("/user")}>Profile</a>
<a href="/connections" class={getNavClass("/connections")}>Connections</a>
<a href="/clients" class={getNavClass("/clients")}>Clients</a>
<a href="/user" class={getNavClass(/^\/user$/)}>Profile</a>
<a href="/connections" class={getNavClass(/^\/connections$/)}>Connections</a>
<a href="/clients" class={getNavClass(/^\/clients(\/\d+)?$/)}>Clients</a>
{/if}
</nav>
</header>

View File

@ -1,9 +1,6 @@
import { error } from '@sveltejs/kit';
import { wgClients } from '$lib/server/db/schema';
import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
import { createClient, getIpsFromIndex } from '$lib/server/clients';
import { createClient, findClients, mapClientToDetails } from '$lib/server/clients';
export const GET: RequestHandler = async (event) => {
if (!event.locals.user) {
@ -13,38 +10,11 @@ export const GET: RequestHandler = async (event) => {
const clients = await findClients(event.locals.user.id);
return new Response(
JSON.stringify({
clients,
clients: clients.map(mapClientToDetails),
}),
);
};
async function findClients(userId: string) {
const clientsData = await db.query.wgClients.findMany({
columns: {
id: true,
name: true,
publicKey: true,
privateKey: true,
preSharedKey: true,
},
with: {
ipAllocation: true,
},
where: eq(wgClients.userId, userId),
});
// replace ip index with actual addresses
return clientsData.map((client) => {
const ips = getIpsFromIndex(client.ipAllocation.id);
return {
id: client.id,
name: client.name,
publicKey: client.publicKey,
privateKey: client.privateKey,
preSharedKey: client.preSharedKey,
ips,
};
});
}
export type Clients = Awaited<ReturnType<typeof findClients>>;

View File

@ -5,9 +5,11 @@ 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 formData = await event.request.formData();
const name = formData.get('name');
if (typeof name !== 'string' || name.trim() === '') return error(400, 'Invalid name');
const res = await createClient({
name,
name: name.trim(),
user: event.locals.user,
});

View File

@ -2,6 +2,7 @@
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import type { PageData } from './$types';
import { LucidePlus } from 'lucide-svelte';
@ -12,26 +13,28 @@
<title>Clients</title>
</svelte:head>
<Table.Root class="bg-accent rounded-xl">
<Table.Root class="bg-accent rounded-lg overflow-hidden divide-y-2 divide-background">
<Table.Header>
<Table.Head>Name</Table.Head>
<Table.Head>Public Key</Table.Head>
<Table.Head>Private Key</Table.Head>
<Table.Head>Pre-Shared Key</Table.Head>
<Table.Head>IP Allocation</Table.Head>
<Table.Row>
<Table.Head scope="col">Name</Table.Head>
<Table.Head scope="col">Public Key</Table.Head>
<!-- <Table.Head scope="col">Private Key</Table.Head>-->
<!-- <Table.Head scope="col">Pre-Shared Key</Table.Head>-->
<Table.Head scope="col">IP Allocation</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Body class="divide-y-2 divide-background">
{#each data.clients as client}
<Table.Row class="border-y-2 border-background hover:bg-muted-foreground">
<a href={`/clients/${client.id}`} class="contents">
<Table.Cell>
<Table.Row class="hover:bg-background hover:bg-opacity-40 group">
<Table.Head scope="row">
<a href={`/clients/${client.id}`} class="flex items-center size-full group-hover:underline">
{client.name}
</Table.Cell>
</a>
<Table.Cell class="truncate max-w-[10ch]">{client.publicKey}</Table.Cell>
<Table.Cell class="truncate max-w-[10ch]">{client.privateKey}</Table.Cell>
<Table.Cell class="truncate max-w-[10ch]">{client.preSharedKey}</Table.Cell>
<Table.Cell class="flex gap-1">
</a>
</Table.Head>
<Table.Cell class="truncate">{client.publicKey}</Table.Cell>
<!-- <Table.Cell class="truncate max-w-[10ch]">{client.privateKey}</Table.Cell>-->
<!-- <Table.Cell class="truncate max-w-[10ch]">{client.preSharedKey}</Table.Cell>-->
<Table.Cell class="flex flex-wrap gap-1">
{#each client.ips as ip}
<Badge class="bg-background select-auto" variant="secondary">{ip}</Badge>
{/each}
@ -42,9 +45,11 @@
</Table.Root>
<!--Floating action button for adding a new client-->
<form class="self-end mt-auto pt-4" method="post" action="?/create">
<!--Not sure if this is the best place for the input field, will think about it later-->
<form class="flex self-end mt-auto pt-4" method="post" action="?/create">
<Input type="text" name="name" placeholder="New Client" class="mr-2" />
<Button type="submit">
<LucidePlus />
<LucidePlus class="mr-2 h-4 w-4" />
Add Client
</Button>
</form>

View File

@ -1,9 +1,9 @@
import type { PageLoad } from './$types';
import type { Clients } from '../api/clients/+server';
import type { ClientDetails } from '$lib/types/clients';
export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/clients');
const { clients } = await res.json() as { clients: Clients };
const { clients } = await res.json() as { clients: ClientDetails[] };
return { clients };
};

View File

@ -1,19 +1,24 @@
<script lang="ts">
import type { PageData } from './$types';
import { LucideClipboardCopy } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import QRCode from 'qrcode-svg';
const { data }: { data: PageData } = $props();
let tooltipText = $state('Copy to clipboard');
let qrCode = new QRCode({
content: data.config,
join: true,
});
function copyToClipboard() {
navigator.clipboard.writeText(data.config).then(() => {
tooltipText = 'Copied';
});
async function copyToClipboard() {
await navigator.clipboard.writeText(data.config);
tooltipText = 'Copied!';
}
function onMouseLeave() {
tooltipText = 'Copy to Clipboard';
tooltipText = 'Copy to clipboard';
}
</script>
@ -21,21 +26,30 @@
<title></title>
</svelte:head>
<h1>Client: {data.client.name}</h1>
<h1 class="bg-accent text-lg w-fit rounded-lg p-2 mb-4">{data.client.name}</h1>
<div class="flex relative bg-accent p-2 rounded-xl overflow-x-scroll">
<pre><code>{data.config}</code></pre>
<div class="flex flex-wrap gap-4">
<div class="relative bg-accent rounded-lg max-w-fit">
<div class="flex items-start p-2 overflow-x-auto">
<pre><code>{data.config}</code></pre>
<!--Copy button for the configuration-->
<div class="absolute flex right-2 items-center group">
<span class="hidden group-hover:block bg-background text-xs rounded py-1 px-2">
{tooltipText}
</span>
<button class="flex items-center justify-center w-10 h-10 bg-background rounded-xl ml-2"
onclick={copyToClipboard}
onmouseleave="{onMouseLeave}"
>
<LucideClipboardCopy />
</button>
<!--Copy button for the configuration-->
<!--Flex reverse for peer hover to work properly-->
<div class="absolute group flex flex-row-reverse items-center gap-1 right-2">
<Button class="peer size-10 p-2"
onclick={copyToClipboard}
onmouseleave={onMouseLeave}
>
<LucideClipboardCopy />
</Button>
<span class="hidden peer-hover:block bg-background text-xs rounded-lg p-2">
{tooltipText}
</span>
</div>
</div>
</div>
</div>
<div class="rounded-lg overflow-hidden">
{@html qrCode.svg()}
</div>
</div>

View File

@ -1,10 +1,15 @@
import type { PageLoad } from './$types';
import type { ClientDetails } from '$lib/types/clients';
import { clientDetailsToConfig } from '$lib/clients';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ fetch, params }) => {
const res = await fetch(`/api/clients/${params.id}`);
const client = (await res.json()) as ClientDetails;
const resJson = await res.json();
if (!res.ok) {
return error(res.status, resJson['message']);
}
const client = resJson as ClientDetails;
const config = clientDetailsToConfig(client);
return { client, config };

View File

@ -33,22 +33,24 @@
<title>Connections</title>
</svelte:head>
<Table.Root class="bg-accent rounded-xl">
<Table.Root class="bg-accent rounded-lg overflow-hidden divide-y-2 divide-background">
<Table.Header>
<Table.Head>Name</Table.Head>
<Table.Head>Public Key</Table.Head>
<Table.Head>Endpoint</Table.Head>
<Table.Head>Allowed IPs</Table.Head>
<Table.Head>Latest Handshake</Table.Head>
<Table.Head>RX</Table.Head>
<Table.Head>TX</Table.Head>
<Table.Head class="hidden">Persistent Keepalive</Table.Head>
<Table.Head class="hidden">Interface Name</Table.Head>
<Table.Row>
<Table.Head scope="col">Name</Table.Head>
<Table.Head scope="col">Public Key</Table.Head>
<Table.Head scope="col">Endpoint</Table.Head>
<Table.Head scope="col">Allowed IPs</Table.Head>
<Table.Head scope="col">Latest Handshake</Table.Head>
<Table.Head scope="col">RX</Table.Head>
<Table.Head scope="col">TX</Table.Head>
<Table.Head scope="col" class="hidden">Persistent Keepalive</Table.Head>
<Table.Head scope="col" class="hidden">Interface Name</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Body class="divide-y-2 divide-background">
{#each data.peers.rows as peer}
<Table.Row class="border-y-2 border-background">
<Table.Cell>{peer.name}</Table.Cell>
<Table.Row class="hover:bg-background hover:bg-opacity-40">
<Table.Head scope="row">{peer.name}</Table.Head>
<Table.Cell class="truncate max-w-[10ch]">{peer['public-key']}</Table.Cell>
<Table.Cell>{peer.endpoint}</Table.Cell>
<Table.Cell>