7 Commits

37 changed files with 497 additions and 440 deletions

1
bunfig.toml Normal file
View File

@@ -0,0 +1 @@
logLevel = "info"

View File

@@ -1,10 +1,22 @@
CREATE TABLE `ip_allocations` ( CREATE TABLE `devices` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`client_id` integer, `user_id` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `wg_clients`(`id`) ON UPDATE no action ON DELETE set 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 --> statement-breakpoint
CREATE UNIQUE INDEX `ip_allocations_client_id_unique` ON `ip_allocations` (`client_id`);--> statement-breakpoint CREATE UNIQUE INDEX `devices_public_key_unique` ON `devices` (`public_key`);--> statement-breakpoint
CREATE TABLE `ip_allocations` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`device_id` integer,
FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `ip_allocations_device_id_unique` ON `ip_allocations` (`device_id`);--> statement-breakpoint
CREATE TABLE `sessions` ( CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL, `id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL, `user_id` text NOT NULL,
@@ -17,16 +29,3 @@ CREATE TABLE `users` (
`username` text NOT NULL, `username` text NOT NULL,
`name` 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

@@ -1,9 +1,90 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "29e6fd88-fa47-4f79-ad83-c52538bc36a6", "id": "48b7ce55-58f1-4b97-a144-ca733576dba6",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"devices": {
"name": "devices",
"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": {
"devices_public_key_unique": {
"name": "devices_public_key_unique",
"columns": [
"public_key"
],
"isUnique": true
}
},
"foreignKeys": {
"devices_user_id_users_id_fk": {
"name": "devices_user_id_users_id_fk",
"tableFrom": "devices",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ip_allocations": { "ip_allocations": {
"name": "ip_allocations", "name": "ip_allocations",
"columns": { "columns": {
@@ -14,8 +95,8 @@
"notNull": true, "notNull": true,
"autoincrement": true "autoincrement": true
}, },
"client_id": { "device_id": {
"name": "client_id", "name": "device_id",
"type": "integer", "type": "integer",
"primaryKey": false, "primaryKey": false,
"notNull": false, "notNull": false,
@@ -23,21 +104,21 @@
} }
}, },
"indexes": { "indexes": {
"ip_allocations_client_id_unique": { "ip_allocations_device_id_unique": {
"name": "ip_allocations_client_id_unique", "name": "ip_allocations_device_id_unique",
"columns": [ "columns": [
"client_id" "device_id"
], ],
"isUnique": true "isUnique": true
} }
}, },
"foreignKeys": { "foreignKeys": {
"ip_allocations_client_id_wg_clients_id_fk": { "ip_allocations_device_id_devices_id_fk": {
"name": "ip_allocations_client_id_wg_clients_id_fk", "name": "ip_allocations_device_id_devices_id_fk",
"tableFrom": "ip_allocations", "tableFrom": "ip_allocations",
"tableTo": "wg_clients", "tableTo": "devices",
"columnsFrom": [ "columnsFrom": [
"client_id" "device_id"
], ],
"columnsTo": [ "columnsTo": [
"id" "id"
@@ -125,87 +206,6 @@
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "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": {}, "views": {},

View File

@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1735028333867, "when": 1736295566569,
"tag": "0000_young_wong", "tag": "0000_fair_tarantula",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -44,6 +44,8 @@
--sidebar-accent-foreground: 240 5.9% 10%; --sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
--surface: 210 26% 76%;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -85,6 +87,8 @@
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
--surface: 217.2 40.6% 11.5%;
} }
} }
} }

View File

@@ -39,7 +39,7 @@ const authRequired = [
/^\/api/, /^\/api/,
/^\/user/, /^\/user/,
/^\/connections/, /^\/connections/,
/^\/clients/, /^\/devices/,
]; ];
const handleProtectedPaths: Handle = ({ event, resolve }) => { const handleProtectedPaths: Handle = ({ event, resolve }) => {
const isProtected = authRequired.some((re) => re.test(event.url.pathname)); const isProtected = authRequired.some((re) => re.test(event.url.pathname));

BIN
src/lib/assets/guide-android.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/lib/assets/guide-client.mp4 (Stored with Git LFS)

Binary file not shown.

View File

@@ -1,33 +0,0 @@
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
`;
}

View File

@@ -16,51 +16,59 @@
let wasCopied = $state(false); let wasCopied = $state(false);
const roundedPre = copy || download ? 'rounded-b-lg' : 'rounded-lg';
async function copyToClipboard() { async function copyToClipboard() {
await navigator.clipboard.writeText(data); await navigator.clipboard.writeText(data);
wasCopied = true; wasCopied = true;
} }
</script> </script>
<div class="relative flex-grow overflow-x-hidden rounded-lg bg-accent"> <div class="flex max-w-full flex-grow flex-col rounded-lg bg-accent">
<div class="flex items-start overflow-x-auto p-2">
<pre><code>{data}</code></pre>
{#if copy || download} {#if copy || download}
<!--Copy button--> <!--Copy and download buttons-->
<!--Flex reverse for peer hover to work properly--> <div class="b flex flex-wrap items-center justify-between gap-4 rounded-t-lg p-2">
<div class="absolute right-2 flex flex-col gap-2"> Configuration
<div class="flex gap-2">
{#if copy} {#if copy}
<div class="flex flex-row-reverse items-center gap-1">
<Button <Button
class="peer size-10 p-2" class="action-button group"
onclick={copyToClipboard} onclick={copyToClipboard}
onmouseleave={() => (wasCopied = false)} onmouseleave={() => (wasCopied = false)}
> >
<LucideClipboardCopy /> <LucideClipboardCopy />
</Button> <span class="group-hover:block">
<span class="hidden rounded-lg bg-background p-2 text-xs peer-hover:block">
{wasCopied ? 'Copied' : 'Copy to clipboard'} {wasCopied ? 'Copied' : 'Copy to clipboard'}
</span> </span>
</div> </Button>
{/if} {/if}
{#if download} {#if download}
<div class="flex flex-row-reverse items-center gap-1">
<a <a
class="peer contents" class="contents"
href={`data:application/octet-stream;charset=utf-8,${encodeURIComponent(data)}`} href={`data:application/octet-stream;charset=utf-8,${encodeURIComponent(data)}`}
download={filename} download={filename}
> >
<Button class="size-10 p-2"> <Button class="action-button group">
<LucideDownload /> <LucideDownload />
<span class="group-hover:block">Download</span>
</Button> </Button>
</a> </a>
<span class="hidden rounded-lg bg-background p-2 text-xs peer-hover:block">
Download
</span>
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
</div> </div>
{/if}
<div class="bg-surface flex items-start overflow-x-auto {roundedPre} p-2">
<pre><code>{data}</code></pre>
</div>
</div>
<style>
:global(.action-button) {
@apply relative size-auto p-2;
}
:global(.action-button > span) {
@apply absolute bottom-full mb-3 hidden rounded-lg bg-muted p-2 text-xs text-foreground;
}
</style>

View File

@@ -2,7 +2,7 @@
import * as Tabs from '$lib/components/ui/tabs'; import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import getItOnGooglePlay from '$lib/assets/GetItOnGooglePlay_Badge_Web_color_English.png'; import getItOnGooglePlay from '$lib/assets/GetItOnGooglePlay_Badge_Web_color_English.png';
import guideVideo from '$lib/assets/guide-client.mp4'; import guideVideoAndroid from '$lib/assets/guide-android.mp4';
</script> </script>
<Tabs.Root value="android"> <Tabs.Root value="android">
@@ -36,7 +36,7 @@
<p>Download the configuration file and import it</p> <p>Download the configuration file and import it</p>
<aside>Alternatively, you can scan the QR code with the WireGuard app</aside> <aside>Alternatively, you can scan the QR code with the WireGuard app</aside>
<video autoplay loop controls muted preload="metadata" class="max-h-screen"> <video autoplay loop controls muted preload="metadata" class="max-h-screen">
<source src={guideVideo} type="video/mp4" /> <source src={guideVideoAndroid} type="video/mp4" />
</video> </video>
</div> </div>
</li> </li>

10
src/lib/connections.ts Normal file
View File

@@ -0,0 +1,10 @@
export type ConnectionDetails = {
deviceId: number;
deviceName: string;
devicePublicKey: string;
deviceIps: string[];
endpoint: string;
transferRx: number;
transferTx: number;
latestHandshake: number;
};

43
src/lib/devices.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Convert device 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 device
*/
export function deviceDetailsToConfig(device: DeviceDetails): string {
return `\
[Interface]
PrivateKey = ${device.privateKey}
Address = ${device.ips.join(', ')}
DNS = ${device.vpnDns}
[Peer]
PublicKey = ${device.vpnPublicKey}
PresharedKey = ${device.preSharedKey}
Endpoint = ${device.vpnEndpoint}
AllowedIPs = 0.0.0.0/0,::/0
`;
}
export type DeviceDetails = {
id: number;
name: string;
publicKey: string;
privateKey: string | null;
preSharedKey: string | null;
ips: string[];
vpnPublicKey: string;
vpnEndpoint: string;
vpnDns: string;
};

View File

@@ -0,0 +1,3 @@
export function opnsenseSanitezedUsername(username: string) {
return username.slice(0, 63).replace(/[^a-zA-Z0-9_-]/g, '_');
}

View File

@@ -8,7 +8,7 @@ export const users = sqliteTable('users', {
}); });
export const usersRelations = relations(users, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({
wgClients: many(wgClients), devices: many(devices),
})); }));
export const sessions = sqliteTable('sessions', { export const sessions = sqliteTable('sessions', {
@@ -22,14 +22,14 @@ export const sessions = sqliteTable('sessions', {
export const ipAllocations = sqliteTable('ip_allocations', { export const ipAllocations = sqliteTable('ip_allocations', {
// for now, id will be the same as the ipIndex // for now, id will be the same as the ipIndex
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
// clientId is nullable because allocations can remain after the client is deleted // deviceId is nullable because allocations can remain after the device is deleted
// unique for now, only allowing one allocation per client // unique for now, only allowing one allocation per device
clientId: integer('client_id') deviceId: integer('device_id')
.unique() .unique()
.references(() => wgClients.id, { onDelete: 'set null' }), .references(() => devices.id, { onDelete: 'set null' }),
}); });
export const wgClients = sqliteTable('wg_clients', { export const devices = sqliteTable('devices', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
userId: text('user_id') userId: text('user_id')
.notNull() .notNull()
@@ -38,7 +38,7 @@ export const wgClients = sqliteTable('wg_clients', {
// questioning whether this should be nullable // questioning whether this should be nullable
opnsenseId: text('opnsense_id'), opnsenseId: text('opnsense_id'),
publicKey: text('public_key').notNull().unique(), publicKey: text('public_key').notNull().unique(),
// nullable for the possibility of a client supplying their own private key // nullable for the possibility of a user supplying their own private key
privateKey: text('private_key'), privateKey: text('private_key'),
// nullable for the possibility of no psk // nullable for the possibility of no psk
preSharedKey: text('pre_shared_key'), preSharedKey: text('pre_shared_key'),
@@ -48,18 +48,18 @@ export const wgClients = sqliteTable('wg_clients', {
// allowedIps: text('allowed_ips').notNull(), // allowedIps: text('allowed_ips').notNull(),
}); });
export const wgClientsRelations = relations(wgClients, ({ one }) => ({ export const devicesRelations = relations(devices, ({ one }) => ({
user: one(users, { user: one(users, {
fields: [wgClients.userId], fields: [devices.userId],
references: [users.id], references: [users.id],
}), }),
ipAllocation: one(ipAllocations, { ipAllocation: one(ipAllocations, {
fields: [wgClients.id], fields: [devices.id],
references: [ipAllocations.clientId], references: [ipAllocations.deviceId],
}), }),
})); }));
export type WgClient = typeof wgClients.$inferSelect; export type Device = typeof devices.$inferSelect;
export type Session = typeof sessions.$inferSelect; export type Session = typeof sessions.$inferSelect;

View File

@@ -1,4 +1,4 @@
import { ipAllocations, users, wgClients } from './schema'; import { ipAllocations, users, devices } from './schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import assert from 'node:assert'; import assert from 'node:assert';
import { drizzle } from 'drizzle-orm/libsql'; import { drizzle } from 'drizzle-orm/libsql';
@@ -11,10 +11,10 @@ async function seed() {
const user = await db.query.users.findFirst({ where: eq(users.username, 'CaZzzer') }); const user = await db.query.users.findFirst({ where: eq(users.username, 'CaZzzer') });
assert(user, 'User not found'); assert(user, 'User not found');
const clients: typeof wgClients.$inferInsert[] = [ const newDevices: typeof devices.$inferInsert[] = [
{ {
userId: user.id, userId: user.id,
name: 'Client1', name: 'Device1',
publicKey: 'BJ5faPVJsDP4CCxNYilmKnwlQXOtXEOJjqIwb4U/CgM=', publicKey: 'BJ5faPVJsDP4CCxNYilmKnwlQXOtXEOJjqIwb4U/CgM=',
privateKey: 'KKqsHDu30WCSrVsyzMkOKbE3saQ+wlx0sBwGs61UGXk=', privateKey: 'KKqsHDu30WCSrVsyzMkOKbE3saQ+wlx0sBwGs61UGXk=',
preSharedKey: '0LWopbrISXBNHUxr+WOhCSAg+0hD8j3TLmpyzHkBHCQ=', preSharedKey: '0LWopbrISXBNHUxr+WOhCSAg+0hD8j3TLmpyzHkBHCQ=',
@@ -22,10 +22,10 @@ async function seed() {
// allowedIps: '10.18.11.101/32,fd00::1/112', // allowedIps: '10.18.11.101/32,fd00::1/112',
}, },
]; ];
const returned = await db.insert(wgClients).values(clients).returning({ insertedId: wgClients.id }); const returned = await db.insert(devices).values(newDevices).returning({ insertedId: devices.id });
const ipAllocation: typeof ipAllocations.$inferInsert = { const ipAllocation: typeof ipAllocations.$inferInsert = {
clientId: returned[0].insertedId, deviceId: returned[0].insertedId,
}; };
await db.insert(ipAllocations).values(ipAllocation); await db.insert(ipAllocations).values(ipAllocation);
} }

View File

@@ -1,15 +1,16 @@
import type { User } from '$lib/server/db/schema'; import type { User } from '$lib/server/db/schema';
import { ipAllocations, wgClients } from '$lib/server/db/schema'; import { ipAllocations, devices } from '$lib/server/db/schema';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { opnsenseAuth, opnsenseUrl, serverPublicKey, serverUuid } from '$lib/server/opnsense'; import { opnsenseAuth, opnsenseUrl, serverPublicKey, serverUuid } from '$lib/server/opnsense';
import { Address4, Address6 } from 'ip-address'; import { Address4, Address6 } from 'ip-address';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { and, count, eq, isNull } from 'drizzle-orm'; import { and, count, eq, isNull } from 'drizzle-orm';
import { err, ok, type Result } from '$lib/types'; import { err, ok, type Result } from '$lib/types';
import type { ClientDetails } from '$lib/types/clients'; import type { DeviceDetails } from '$lib/devices';
import { opnsenseSanitezedUsername } from '$lib/opnsense';
export async function findClients(userId: string) { export async function findDevices(userId: string) {
return db.query.wgClients.findMany({ return db.query.devices.findMany({
columns: { columns: {
id: true, id: true,
name: true, name: true,
@@ -20,12 +21,12 @@ export async function findClients(userId: string) {
with: { with: {
ipAllocation: true, ipAllocation: true,
}, },
where: eq(wgClients.userId, userId), where: eq(devices.userId, userId),
}); });
} }
export async function findClient(userId: string, clientId: number) { export async function findDevice(userId: string, deviceId: number) {
return db.query.wgClients.findFirst({ return db.query.devices.findFirst({
columns: { columns: {
id: true, id: true,
name: true, name: true,
@@ -36,20 +37,20 @@ export async function findClient(userId: string, clientId: number) {
with: { with: {
ipAllocation: true, ipAllocation: true,
}, },
where: and(eq(wgClients.userId, userId), eq(wgClients.id, clientId)), where: and(eq(devices.userId, userId), eq(devices.id, deviceId)),
}); });
} }
export function mapClientToDetails( export function mapDeviceToDetails(
client: Awaited<ReturnType<typeof findClients>>[0], device: Awaited<ReturnType<typeof findDevices>>[0],
): ClientDetails { ): DeviceDetails {
const ips = getIpsFromIndex(client.ipAllocation.id); const ips = getIpsFromIndex(device.ipAllocation.id);
return { return {
id: client.id, id: device.id,
name: client.name, name: device.name,
publicKey: client.publicKey, publicKey: device.publicKey,
privateKey: client.privateKey, privateKey: device.privateKey,
preSharedKey: client.preSharedKey, preSharedKey: device.preSharedKey,
ips, ips,
vpnPublicKey: serverPublicKey, vpnPublicKey: serverPublicKey,
vpnEndpoint: env.VPN_ENDPOINT, vpnEndpoint: env.VPN_ENDPOINT,
@@ -57,35 +58,35 @@ export function mapClientToDetails(
}; };
} }
export async function createClient(params: { export async function createDevice(params: {
name: string; name: string;
user: User; user: User;
}): Promise<Result<number, [400 | 500, string]>> { }): Promise<Result<number, [400 | 500, string]>> {
// check if user exceeds the limit of clients // check if user exceeds the limit of devices
const [{ clientCount }] = await db const [{ deviceCount }] = await db
.select({ clientCount: count() }) .select({ deviceCount: count() })
.from(wgClients) .from(devices)
.where(eq(wgClients.userId, params.user.id)); .where(eq(devices.userId, params.user.id));
if (clientCount >= parseInt(env.MAX_CLIENTS_PER_USER)) if (deviceCount >= parseInt(env.MAX_CLIENTS_PER_USER))
return err([400, 'Maximum number of clients reached'] as [400, string]); return err([400, 'Maximum number of devices reached'] as [400, string]);
// this is going to be quite long // this is going to be quite long
// 1. fetch params for new client from opnsense api // 1. fetch params for new device from opnsense api
// 2.1 get an allocation for the client // 2.1 get an allocation for the device
// 2.2. insert new client into db // 2.2. insert new device into db
// 2.3. update the allocation with the client id // 2.3. update the allocation with the device id
// 3. create the client in opnsense // 3. create the client in opnsense
// 4. reconfigure opnsense to enable the new client // 4. reconfigure opnsense to enable the new client
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [keys, availableAllocation, lastAllocation] = await Promise.all([ const [keys, availableAllocation, lastAllocation] = await Promise.all([
// fetch params for new client from opnsense api // fetch params for new device from opnsense api
getKeys(), getKeys(),
// find first unallocated IP // find first unallocated IP
await tx.query.ipAllocations.findFirst({ await tx.query.ipAllocations.findFirst({
columns: { columns: {
id: true, id: true,
}, },
where: isNull(ipAllocations.clientId), where: isNull(ipAllocations.deviceId),
}), }),
// find last allocation to check if we have any IPs left // find last allocation to check if we have any IPs left
await tx.query.ipAllocations.findFirst({ await tx.query.ipAllocations.findFirst({
@@ -109,9 +110,9 @@ export async function createClient(params: {
// transaction savepoint after creating a new IP allocation // transaction savepoint after creating a new IP allocation
// TODO: not sure if this is needed // TODO: not sure if this is needed
return await tx.transaction(async (tx2) => { return await tx.transaction(async (tx2) => {
// create new client in db // create new device in db
const [newClient] = await tx2 const [newDevice] = await tx2
.insert(wgClients) .insert(devices)
.values({ .values({
userId: params.user.id, userId: params.user.id,
name: params.name, name: params.name,
@@ -119,12 +120,12 @@ export async function createClient(params: {
privateKey: keys.privkey, privateKey: keys.privkey,
preSharedKey: keys.psk, preSharedKey: keys.psk,
}) })
.returning({ id: wgClients.id }); .returning({ id: devices.id });
// update IP allocation with client ID // update IP allocation with device ID
await tx2 await tx2
.update(ipAllocations) .update(ipAllocations)
.set({ clientId: newClient.id }) .set({ deviceId: newDevice.id })
.where(eq(ipAllocations.id, ipAllocationId)); .where(eq(ipAllocations.id, ipAllocationId));
// create client in opnsense // create client in opnsense
@@ -143,7 +144,7 @@ export async function createClient(params: {
// reconfigure opnsense // reconfigure opnsense
await opnsenseReconfigure(); await opnsenseReconfigure();
return ok(newClient.id); return ok(newDevice.id);
}); });
}); });
} }
@@ -196,7 +197,7 @@ async function opnsenseCreateClient(params: {
body: JSON.stringify({ body: JSON.stringify({
configbuilder: { configbuilder: {
enabled: '1', enabled: '1',
name: `vpgen-${params.username}`, name: `vpgen-${opnsenseSanitezedUsername(params.username)}`,
pubkey: params.pubkey, pubkey: params.pubkey,
psk: params.psk, psk: params.psk,
tunneladdress: params.allowedIps, tunneladdress: params.allowedIps,

View File

@@ -30,7 +30,7 @@ export async function fetchOpnsenseServer() {
const uuid = servers.rows.find((server) => server.name === opnsenseIfname)?.uuid; const uuid = servers.rows.find((server) => server.name === opnsenseIfname)?.uuid;
assert(uuid, 'Failed to find server UUID for OPNsense WireGuard server'); assert(uuid, 'Failed to find server UUID for OPNsense WireGuard server');
serverUuid = uuid; serverUuid = uuid;
console.log('OPNsense WireGuard server UUID:', serverUuid); console.info('OPNsense WireGuard server UUID:', serverUuid);
const resServerInfo = await fetch( const resServerInfo = await fetch(
`${opnsenseUrl}/api/wireguard/client/get_server_info/${serverUuid}`, `${opnsenseUrl}/api/wireguard/client/get_server_info/${serverUuid}`,

View File

@@ -1,11 +0,0 @@
export type ClientDetails = {
id: number;
name: string;
publicKey: string;
privateKey: string | null;
preSharedKey: string | null;
ips: string[];
vpnPublicKey: string;
vpnEndpoint: string;
vpnDns: string;
};

View File

@@ -1,11 +1,9 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from "./$types";
export const trailingSlash = 'always';
export const load: LayoutServerLoad = async (event) => { export const load: LayoutServerLoad = async (event) => {
const { user } = event.locals; const { user } = event.locals;
return { return {
user, user
}; };
}; };

View File

@@ -15,19 +15,21 @@
</script> </script>
<header class="flex w-full flex-wrap justify-between gap-x-6 gap-y-4 xl:max-w-screen-xl"> <header class="flex w-full flex-wrap justify-between gap-x-6 gap-y-4 xl:max-w-screen-xl">
<a href="/" class="contents">
<span class="font-bold sm:inline-block">VPGen</span> <span class="font-bold sm:inline-block">VPGen</span>
</a>
<nav class="max-w-full"> <nav class="max-w-full">
<ul class="flex items-center gap-6 overflow-x-auto text-sm"> <ul class="flex items-center gap-6 overflow-x-auto text-sm">
<li><a href="/" class={getNavClass(/^\/$/)}>Home</a></li> <li><a href="/" class={getNavClass(/^\/$/)}>Home</a></li>
{#if user} {#if user}
<li><a href="/user/" class={getNavClass(/^\/user\/$/)}>Profile</a></li> <li><a href="/user" class={getNavClass(/^\/user$/)}>Profile</a></li>
<li><a href="/connections/" class={getNavClass(/^\/connections\/$/)}>Connections</a></li> <li><a href="/connections" class={getNavClass(/^\/connections$/)}>Connections</a></li>
<li><a href="/clients/" class={getNavClass(/^\/clients(\/\d+)?\/$/)}>Clients</a></li> <li><a href="/devices" class={getNavClass(/^\/devices(\/\d+)?$/)}>Devices</a></li>
{/if} {/if}
</ul> </ul>
</nav> </nav>
</header> </header>
<main class="flex min-w-full max-w-full flex-grow flex-col gap-4 xl:min-w-[1280px]"> <main class="flex min-w-full max-w-full flex-grow flex-col gap-4 xl:min-w-[78rem]">
{@render children()} {@render children()}
</main> </main>
@@ -35,7 +37,7 @@
<!--Some shenanings needed to be done to get the footer position to stick correctly, <!--Some shenanings needed to be done to get the footer position to stick correctly,
didn't work with display: contents--> didn't work with display: contents-->
<footer class="inset-x-0 bottom-0 w-full text-center"> <footer class="inset-x-0 bottom-0 w-full text-center">
<p>&copy; 2024</p> <p>&copy; 2025</p>
</footer> </footer>
<style> <style>

View File

@@ -21,7 +21,7 @@
<section id="get-started" class="border-l-2 pl-6"> <section id="get-started" class="border-l-2 pl-6">
<p> <p>
To get started, To get started,
<Button class="ml-2" href="/clients/?add=New+Client">Create a New Client</Button> <Button class="ml-2" href="/devices?add=New+Device">Add a New Device</Button>
</p> </p>
</section> </section>
<!-- <section id="using-wireguard">--> <!-- <section id="using-wireguard">-->

View File

@@ -1,20 +0,0 @@
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)));
};

View File

@@ -2,13 +2,56 @@ import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { opnsenseAuth, opnsenseUrl } from '$lib/server/opnsense'; import { opnsenseAuth, opnsenseUrl } from '$lib/server/opnsense';
import type { OpnsenseWgPeers } from '$lib/opnsense/wg'; import type { OpnsenseWgPeers } from '$lib/opnsense/wg';
import { findDevices } from '$lib/server/devices';
import type { ConnectionDetails } from '$lib/connections';
import { opnsenseSanitezedUsername } from '$lib/opnsense';
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
if (!event.locals.user) { if (!event.locals.user) {
return error(401, 'Unauthorized'); return error(401, 'Unauthorized');
} }
const apiUrl = `${opnsenseUrl}/api/wireguard/service/show`; console.debug('/api/connections');
const options: RequestInit = { const peers = await fetchOpnsensePeers(event.locals.user.username);
console.debug('/api/connections: fetched opnsense peers', peers.rowCount);
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']);
// 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);
if (!peerData) continue;
connections.push({
deviceId: device.id,
deviceName: device.name,
devicePublicKey: device.publicKey,
deviceIps: peerData['allowed-ips'].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,
});
}
return new Response(JSON.stringify(connections), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=5',
},
});
};
async function fetchOpnsensePeers(username: string) {
const res = await fetch(`${opnsenseUrl}/api/wireguard/service/show`, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: opnsenseAuth, Authorization: opnsenseAuth,
@@ -16,28 +59,15 @@ export const GET: RequestHandler = async (event) => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
'current': 1, current: 1,
// "rowCount": 7, // "rowCount": 7,
'sort': {}, sort: {},
// TODO: use a more unique search phrase // TODO: use a more unique search phrase
// unfortunately 64 character limit, // unfortunately 64 character limit,
// but it should be fine if users can't change their own username // but it should be fine if users can't change their own username
'searchPhrase': `vpgen-${event.locals.user.username}`, searchPhrase: `vpgen-${opnsenseSanitezedUsername(username)}`,
'type': ['peer'], type: ['peer'],
}), }),
};
const res = await fetch(apiUrl, options);
const peers = await res.json() as OpnsenseWgPeers;
peers.rows = peers.rows.filter(peer => peer['latest-handshake'])
if (!peers) {
return error(500, 'Error getting info from OPNsense API');
}
return new Response(JSON.stringify(peers), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=5',
}
}); });
}; return (await res.json()) as OpnsenseWgPeers;
}

View File

@@ -1,29 +1,29 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { createClient, findClients, mapClientToDetails } from '$lib/server/clients'; import { createDevice, findDevices, mapDeviceToDetails } from '$lib/server/devices';
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
if (!event.locals.user) { if (!event.locals.user) {
return error(401, 'Unauthorized'); return error(401, 'Unauthorized');
} }
const clients = await findClients(event.locals.user.id); const devices = await findDevices(event.locals.user.id);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
clients: clients.map(mapClientToDetails), devices: devices.map(mapDeviceToDetails),
}), }),
); );
}; };
export type Clients = Awaited<ReturnType<typeof findClients>>; export type Devices = Awaited<ReturnType<typeof findDevices>>;
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
if (!event.locals.user) { if (!event.locals.user) {
return error(401, 'Unauthorized'); return error(401, 'Unauthorized');
} }
const { name } = await event.request.json(); const { name } = await event.request.json();
const res = await createClient({ const res = await createDevice({
name, name,
user: event.locals.user, user: event.locals.user,
}); });

View File

@@ -0,0 +1,20 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { findDevice, mapDeviceToDetails } from '$lib/server/devices';
export const GET: RequestHandler = async (event) => {
if (!event.locals.user) {
return error(401, 'Unauthorized');
}
const { id } = event.params;
const deviceId = parseInt(id);
if (isNaN(deviceId)) {
return error(400, 'Invalid device ID');
}
const device = await findDevice(event.locals.user.id, deviceId);
if (!device) {
return error(404, 'Device not found');
}
return new Response(JSON.stringify(mapDeviceToDetails(device)));
};

View File

@@ -35,7 +35,7 @@ export async function GET(event: RequestEvent): Promise<Response> {
}); });
} }
const claims = decodeIdToken(tokens.idToken()) as { sub: string, preferred_username: string, name: string }; const claims = decodeIdToken(tokens.idToken()) as { sub: string, preferred_username: string, name: string };
console.log("claims", claims); console.info("claims", claims);
const userId: string = claims.sub; const userId: string = claims.sub;
const username: string = claims.preferred_username; const username: string = claims.preferred_username;

View File

@@ -1,9 +0,0 @@
import type { PageLoad } from './$types';
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: ClientDetails[] };
return { clients };
};

View File

@@ -1,16 +0,0 @@
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 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

@@ -16,14 +16,12 @@
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
function getSize(size: number) { function toSizeString(size: number) {
let sizes = ['Bytes', 'KiB', 'MiB', 'GiB', let sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
for (let i = 1; i < sizes.length; i++) { for (let i = 1; i < sizes.length; i++) {
if (size < Math.pow(1024, i)) if (size < Math.pow(1024, i))
return (Math.round((size / Math.pow( return Math.round((size / Math.pow(1024, i - 1)) * 100) / 100 + ' ' + sizes[i - 1];
1024, i - 1)) * 100) / 100) + ' ' + sizes[i - 1];
} }
return size; return size;
} }
@@ -33,38 +31,34 @@
<title>Connections</title> <title>Connections</title>
</svelte:head> </svelte:head>
<Table.Root class="bg-accent rounded-lg overflow-hidden divide-y-2 divide-background"> <Table.Root class="divide-y-2 divide-background overflow-hidden rounded-lg bg-accent">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head scope="col">Name</Table.Head> <Table.Head scope="col">Device</Table.Head>
<Table.Head scope="col">Public Key</Table.Head> <Table.Head scope="col">Public Key</Table.Head>
<Table.Head scope="col">Endpoint</Table.Head> <Table.Head scope="col">Endpoint</Table.Head>
<Table.Head scope="col">Allowed IPs</Table.Head> <Table.Head scope="col">Device IPs</Table.Head>
<Table.Head scope="col">Latest Handshake</Table.Head> <Table.Head scope="col">Latest Handshake</Table.Head>
<Table.Head scope="col">RX</Table.Head> <Table.Head scope="col">RX</Table.Head>
<Table.Head scope="col">TX</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.Row>
</Table.Header> </Table.Header>
<Table.Body class="divide-y-2 divide-background"> <Table.Body class="divide-y-2 divide-background">
{#each data.peers.rows as peer} {#each data.connections as conn}
<Table.Row class="hover:bg-background hover:bg-opacity-40"> <Table.Row class="hover:bg-surface">
<Table.Head scope="row">{peer.name}</Table.Head> <Table.Head scope="row">{conn.deviceName}</Table.Head>
<Table.Cell class="truncate max-w-[10ch]">{peer['public-key']}</Table.Cell> <Table.Cell class="max-w-[10ch] truncate">{conn.devicePublicKey}</Table.Cell>
<Table.Cell>{peer.endpoint}</Table.Cell> <Table.Cell>{conn.endpoint}</Table.Cell>
<Table.Cell> <Table.Cell>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each peer['allowed-ips'].split(',') as addr} {#each conn.deviceIps as addr}
<Badge class="bg-background select-auto" variant="secondary">{addr}</Badge> <Badge class="select-auto bg-background" variant="secondary">{addr}</Badge>
{/each} {/each}
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell>{new Date(peer['latest-handshake'] * 1000).toLocaleString()}</Table.Cell> <Table.Cell>{new Date(conn.latestHandshake).toLocaleString()}</Table.Cell>
<Table.Cell>{getSize(peer['transfer-rx'])}</Table.Cell> <Table.Cell>{toSizeString(conn.transferRx)}</Table.Cell>
<Table.Cell>{getSize(peer['transfer-tx'])}</Table.Cell> <Table.Cell>{toSizeString(conn.transferTx)}</Table.Cell>
<Table.Cell class="hidden">{peer['persistent-keepalive']}</Table.Cell>
<Table.Cell class="hidden">{peer.ifname}</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>

View File

@@ -1,9 +1,9 @@
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import type { OpnsenseWgPeers } from '$lib/opnsense/wg'; import type { ConnectionDetails } from '$lib/connections';
export const load: PageLoad = async ({ fetch }) => { export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/connections'); const res = await fetch('/api/connections');
const peers = await res.json() as OpnsenseWgPeers; const connections = await res.json() as ConnectionDetails[];
return { peers }; return { connections };
}; };

View File

@@ -1,5 +1,5 @@
import type { Actions } from './$types'; import type { Actions } from './$types';
import { createClient } from '$lib/server/clients'; import { createDevice } from '$lib/server/devices';
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
export const actions = { export const actions = {
@@ -8,14 +8,14 @@ export const actions = {
const formData = await event.request.formData(); const formData = await event.request.formData();
const name = formData.get('name'); const name = formData.get('name');
if (typeof name !== 'string' || name.trim() === '') return error(400, 'Invalid name'); if (typeof name !== 'string' || name.trim() === '') return error(400, 'Invalid name');
const res = await createClient({ const res = await createDevice({
name: name.trim(), name: name.trim(),
user: event.locals.user, user: event.locals.user,
}); });
switch (res._tag) { switch (res._tag) {
case 'ok': { case 'ok': {
return redirect(303, `/clients/${res.value}/`); return redirect(303, `/devices/${res.value}`);
} }
case 'err': { case 'err': {
const [status, message] = res.error; const [status, message] = res.error;

View File

@@ -4,7 +4,7 @@
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button, buttonVariants } from '$lib/components/ui/button'; import { Button, buttonVariants } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { LucidePlus } from 'lucide-svelte'; import { LucideLoaderCircle, LucidePlus } from 'lucide-svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -13,6 +13,7 @@
let dialogOpen = $state(page.url.searchParams.has('add')); let dialogOpen = $state(page.url.searchParams.has('add'));
let dialogVal = $state(page.url.searchParams.get('add') ?? ''); let dialogVal = $state(page.url.searchParams.get('add') ?? '');
let submitted = $state(false);
$effect(() => { $effect(() => {
if (dialogOpen) page.url.searchParams.set('add', dialogVal); if (dialogOpen) page.url.searchParams.set('add', dialogVal);
@@ -23,7 +24,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Clients</title> <title>Devices</title>
</svelte:head> </svelte:head>
<Table.Root class="divide-y-2 divide-background overflow-hidden rounded-lg bg-accent"> <Table.Root class="divide-y-2 divide-background overflow-hidden rounded-lg bg-accent">
@@ -37,21 +38,21 @@
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body class="divide-y-2 divide-background"> <Table.Body class="divide-y-2 divide-background">
{#each data.clients as client} {#each data.devices as device}
<Table.Row class="group hover:bg-background hover:bg-opacity-40"> <Table.Row class="hover:bg-surface group">
<Table.Head scope="row"> <Table.Head scope="row">
<a <a
href={`./${client.id}/`} href="/devices/{device.id}"
class="flex size-full items-center group-hover:underline" class="flex size-full items-center group-hover:underline"
> >
{client.name} {device.name}
</a> </a>
</Table.Head> </Table.Head>
<Table.Cell class="truncate">{client.publicKey}</Table.Cell> <Table.Cell class="truncate">{device.publicKey}</Table.Cell>
<!-- <Table.Cell class="truncate max-w-[10ch]">{client.privateKey}</Table.Cell>--> <!-- <Table.Cell class="truncate max-w-[10ch]">{device.privateKey}</Table.Cell>-->
<!-- <Table.Cell class="truncate max-w-[10ch]">{client.preSharedKey}</Table.Cell>--> <!-- <Table.Cell class="truncate max-w-[10ch]">{device.preSharedKey}</Table.Cell>-->
<Table.Cell class="flex flex-wrap gap-1"> <Table.Cell class="flex flex-wrap gap-1">
{#each client.ips as ip} {#each device.ips as ip}
<Badge class="select-auto bg-background" variant="secondary">{ip}</Badge> <Badge class="select-auto bg-background" variant="secondary">{ip}</Badge>
{/each} {/each}
</Table.Cell> </Table.Cell>
@@ -60,18 +61,18 @@
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
<!--Floating action button for adding a new client--> <!--Floating action button for adding a new device-->
<Dialog.Root bind:open={dialogOpen}> <Dialog.Root bind:open={dialogOpen}>
<div class="mt-auto flex self-end pt-4"> <div class="mt-auto flex self-end pt-4">
<Dialog.Trigger class={buttonVariants({ variant: 'default' }) + ' flex gap-4'}> <Dialog.Trigger class={buttonVariants({ variant: 'default' }) + ' flex gap-4'}>
<LucidePlus /> <LucidePlus />
New Client New Device
</Dialog.Trigger> </Dialog.Trigger>
</div> </div>
<Dialog.Content class="max-w-xs"> <Dialog.Content class="max-w-xs">
<form class="contents" method="post" action="?/create"> <form class="contents" method="post" onsubmit={() => submitted = true} action="?/create">
<Dialog.Header class=""> <Dialog.Header class="">
<Dialog.Title>Create a new client</Dialog.Title> <Dialog.Title>Add a new device</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<Label for="name">Name</Label> <Label for="name">Name</Label>
@@ -81,12 +82,17 @@
pattern=".*[^\s]+.*" pattern=".*[^\s]+.*"
type="text" type="text"
name="name" name="name"
placeholder="New Client" placeholder="New Device"
class="max-w-[20ch]" class="max-w-[20ch]"
/> />
</div> </div>
<Dialog.Footer> <Dialog.Footer>
<Button type="submit">Create</Button> <Button type="submit" disabled={submitted}>
{#if submitted}
<LucideLoaderCircle class="size-4 mr-2 animate-spin" />
{/if}
Add
</Button>
</Dialog.Footer> </Dialog.Footer>
</form> </form>
</Dialog.Content> </Dialog.Content>

View File

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

View File

@@ -2,33 +2,35 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import QRCode from 'qrcode-svg'; import QRCode from 'qrcode-svg';
import { CodeSnippet } from '$lib/components/app/code-snippet'; import { CodeSnippet } from '$lib/components/app/code-snippet';
import { WireguardGuide } from '$lib/components/app/wireguard-guide/index.js'; import { WireguardGuide } from '$lib/components/app/wireguard-guide';
const { data }: { data: PageData } = $props(); const { data }: { data: PageData } = $props();
// Clean the client name for the file name, // Clean the device name for the wg config filename,
// things can break otherwise (too long or invalid characters) // things can break otherwise (too long or invalid characters)
// https://github.com/pirate/wireguard-docs // https://github.com/pirate/wireguard-docs
const clientWgCleanedName = const deviceWgCleanedName =
data.client.name.slice(0, 15).replace(/[^a-zA-Z0-9_=+.-]/g, '_') + '.conf'; data.device.name.slice(0, 15).replace(/[^a-zA-Z0-9_=+.-]/g, '_') + '.conf';
let qrCode = new QRCode({ let qrCode = new QRCode({
content: data.config, content: data.config,
join: true, join: true,
background: 'hsl(var(--accent-light))', background: 'hsl(var(--accent-light))',
width: 296,
height: 296,
}); });
</script> </script>
<svelte:head> <svelte:head>
<title>{data.client.name}</title> <title>{data.device.name}</title>
</svelte:head> </svelte:head>
<h1 class="w-fit rounded-lg bg-accent p-2 text-lg">{data.client.name}</h1> <h1 class="w-fit rounded-lg bg-accent p-2 text-lg">{data.device.name}</h1>
<section id="client-configuration" class="flex flex-wrap justify-center gap-4"> <section id="device-configuration" class="flex flex-wrap items-center justify-center gap-4">
<CodeSnippet data={data.config} filename={clientWgCleanedName} copy download /> <CodeSnippet data={data.config} filename={deviceWgCleanedName} copy download />
<div class="overflow-hidden rounded-lg"> <div class="size-fit overflow-auto rounded-lg">
{@html qrCode.svg()} {@html qrCode.svg()}
</div> </div>
</section> </section>

View File

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

View File

@@ -1,92 +1,93 @@
import { fontFamily } from "tailwindcss/defaultTheme"; import { fontFamily } from 'tailwindcss/defaultTheme';
import type { Config } from "tailwindcss"; import type { Config } from 'tailwindcss';
import tailwindcssAnimate from "tailwindcss-animate"; import tailwindcssAnimate from 'tailwindcss-animate';
const config: Config = { const config: Config = {
darkMode: ["media"], darkMode: ['media'],
content: ["./src/**/*.{html,js,svelte,ts}"], content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ["dark"], safelist: ['dark'],
theme: { theme: {
container: { container: {
center: true, center: true,
padding: "2rem", padding: '2rem',
screens: { screens: {
"2xl": "1400px" '2xl': '1400px',
} },
}, },
extend: { extend: {
colors: { colors: {
border: "hsl(var(--border) / <alpha-value>)", border: 'hsl(var(--border) / <alpha-value>)',
input: "hsl(var(--input) / <alpha-value>)", input: 'hsl(var(--input) / <alpha-value>)',
ring: "hsl(var(--ring) / <alpha-value>)", ring: 'hsl(var(--ring) / <alpha-value>)',
background: "hsl(var(--background) / <alpha-value>)", background: 'hsl(var(--background) / <alpha-value>)',
foreground: "hsl(var(--foreground) / <alpha-value>)", foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: { primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)", DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: "hsl(var(--primary-foreground) / <alpha-value>)" foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)", DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)" foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)',
}, },
destructive: { destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)", DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)" foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)',
}, },
muted: { muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)", DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: "hsl(var(--muted-foreground) / <alpha-value>)" foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
}, },
accent: { accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)", DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
foreground: "hsl(var(--accent-foreground) / <alpha-value>)" foreground: 'hsl(var(--accent-foreground) / <alpha-value>)',
}, },
popover: { popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)", DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
foreground: "hsl(var(--popover-foreground) / <alpha-value>)" foreground: 'hsl(var(--popover-foreground) / <alpha-value>)',
}, },
card: { card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)", DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: "hsl(var(--card-foreground) / <alpha-value>)" foreground: 'hsl(var(--card-foreground) / <alpha-value>)',
}, },
sidebar: { sidebar: {
DEFAULT: "hsl(var(--sidebar-background))", DEFAULT: 'hsl(var(--sidebar-background))',
foreground: "hsl(var(--sidebar-foreground))", foreground: 'hsl(var(--sidebar-foreground))',
primary: "hsl(var(--sidebar-primary))", primary: 'hsl(var(--sidebar-primary))',
"primary-foreground": "hsl(var(--sidebar-primary-foreground))", 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: "hsl(var(--sidebar-accent))", accent: 'hsl(var(--sidebar-accent))',
"accent-foreground": "hsl(var(--sidebar-accent-foreground))", 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: "hsl(var(--sidebar-border))", border: 'hsl(var(--sidebar-border))',
ring: "hsl(var(--sidebar-ring))", ring: 'hsl(var(--sidebar-ring))',
}, },
surface: 'hsl(var(--surface) / <alpha-value>)',
}, },
borderRadius: { borderRadius: {
xl: "calc(var(--radius) + 4px)", xl: 'calc(var(--radius) + 4px)',
lg: "var(--radius)", lg: 'var(--radius)',
md: "calc(var(--radius) - 2px)", md: 'calc(var(--radius) - 2px)',
sm: "calc(var(--radius) - 4px)" sm: 'calc(var(--radius) - 4px)',
}, },
fontFamily: { fontFamily: {
sans: [...fontFamily.sans] sans: [...fontFamily.sans],
}, },
keyframes: { keyframes: {
"accordion-down": { 'accordion-down': {
from: { height: "0" }, from: { height: '0' },
to: { height: "var(--bits-accordion-content-height)" }, to: { height: 'var(--bits-accordion-content-height)' },
}, },
"accordion-up": { 'accordion-up': {
from: { height: "var(--bits-accordion-content-height)" }, from: { height: 'var(--bits-accordion-content-height)' },
to: { height: "0" }, to: { height: '0' },
}, },
"caret-blink": { 'caret-blink': {
"0%,70%,100%": { opacity: "1" }, '0%,70%,100%': { opacity: '1' },
"20%,50%": { opacity: "0" }, '20%,50%': { opacity: '0' },
}, },
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", 'accordion-down': 'accordion-down 0.2s ease-out',
"accordion-up": "accordion-up 0.2s ease-out", 'accordion-up': 'accordion-up 0.2s ease-out',
"caret-blink": "caret-blink 1.25s ease-out infinite", 'caret-blink': 'caret-blink 1.25s ease-out infinite',
}, },
}, },
}, },