Compare commits
6 Commits
feature/qr
...
feature/lo
Author | SHA1 | Date | |
---|---|---|---|
b38ab19c3e
|
|||
80acec720c
|
|||
29fbccc953
|
|||
76559d2931
|
|||
cc7c94417d
|
|||
d99ee9ef1e
|
1
bunfig.toml
Normal file
1
bunfig.toml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
logLevel = "info"
|
@@ -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`);
|
|
@@ -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": {},
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@@ -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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
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)
BIN
src/lib/assets/guide-client.mp4
(Stored with Git LFS)
Binary file not shown.
@@ -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
|
|
||||||
`;
|
|
||||||
}
|
|
@@ -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">
|
{#if copy || download}
|
||||||
<pre><code>{data}</code></pre>
|
<!--Copy and download buttons-->
|
||||||
{#if copy || download}
|
<div class="b flex flex-wrap items-center justify-between gap-4 rounded-t-lg p-2">
|
||||||
<!--Copy button-->
|
Configuration
|
||||||
<!--Flex reverse for peer hover to work properly-->
|
<div class="flex gap-2">
|
||||||
<div class="absolute right-2 flex flex-col gap-2">
|
|
||||||
{#if copy}
|
{#if copy}
|
||||||
<div class="flex flex-row-reverse items-center gap-1">
|
<Button
|
||||||
<Button
|
class="action-button group"
|
||||||
class="peer size-10 p-2"
|
onclick={copyToClipboard}
|
||||||
onclick={copyToClipboard}
|
onmouseleave={() => (wasCopied = false)}
|
||||||
onmouseleave={() => (wasCopied = false)}
|
>
|
||||||
>
|
<LucideClipboardCopy />
|
||||||
<LucideClipboardCopy />
|
<span class="group-hover:block">
|
||||||
</Button>
|
|
||||||
<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="contents"
|
||||||
class="peer 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="action-button group">
|
||||||
<Button class="size-10 p-2">
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="bg-surface flex items-start overflow-x-auto {roundedPre} p-2">
|
||||||
|
<pre><code>{data}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
@@ -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
10
src/lib/connections.ts
Normal 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
43
src/lib/devices.ts
Normal 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;
|
||||||
|
};
|
3
src/lib/opnsense/index.ts
Normal file
3
src/lib/opnsense/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function opnsenseSanitezedUsername(username: string) {
|
||||||
|
return username.slice(0, 63).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
}
|
@@ -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;
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
@@ -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}`,
|
||||||
|
@@ -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;
|
|
||||||
};
|
|
@@ -24,12 +24,12 @@
|
|||||||
{#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>
|
||||||
|
|
||||||
@@ -37,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>© 2024</p>
|
<p>© 2025</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@@ -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">-->
|
||||||
|
@@ -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)));
|
|
||||||
};
|
|
@@ -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;
|
||||||
|
}
|
||||||
|
@@ -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,
|
||||||
});
|
});
|
20
src/routes/api/devices/[id]/+server.ts
Normal file
20
src/routes/api/devices/[id]/+server.ts
Normal 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)));
|
||||||
|
};
|
@@ -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;
|
||||||
|
|
||||||
|
@@ -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 };
|
|
||||||
};
|
|
@@ -1,79 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
import QRCode from 'qrcode-svg';
|
|
||||||
import { CodeSnippet } from '$lib/components/app/code-snippet';
|
|
||||||
import { WireguardGuide } from '$lib/components/app/wireguard-guide';
|
|
||||||
|
|
||||||
const { data }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
// Clean the client name for the file name,
|
|
||||||
// things can break otherwise (too long or invalid characters)
|
|
||||||
// https://github.com/pirate/wireguard-docs
|
|
||||||
const clientWgCleanedName =
|
|
||||||
data.client.name.slice(0, 15).replace(/[^a-zA-Z0-9_=+.-]/g, '_') + '.conf';
|
|
||||||
|
|
||||||
let qrCode = new QRCode({
|
|
||||||
content: data.config,
|
|
||||||
join: true,
|
|
||||||
background: 'hsl(var(--accent-light))',
|
|
||||||
xmlDeclaration: false,
|
|
||||||
// predefined: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const modules = qrCode.qrcode.modules;
|
|
||||||
const length = modules.length;
|
|
||||||
|
|
||||||
const s = 1;
|
|
||||||
|
|
||||||
// Rectangles representing modules
|
|
||||||
// let pathData = '';
|
|
||||||
//
|
|
||||||
// for (let y = 0; y < length; y++) {
|
|
||||||
// for (let x = 0; x < length; x++) {
|
|
||||||
// let module = modules[x][y];
|
|
||||||
// if (module) {
|
|
||||||
// pathData += `M${x*s},${y*s} h${s} v${s} h-${s} Z `
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const path = modules.reduce((acc, row, y) => {
|
|
||||||
// return acc + row.reduce((rowAcc, module, x) => {
|
|
||||||
// return module ? rowAcc + `M${x*s},${y*s} h${s} v${s} h-${s} Z ` : rowAcc;
|
|
||||||
// }, '');
|
|
||||||
// }, '');
|
|
||||||
const path = modules.flat().reduce((acc, module, i) => {
|
|
||||||
const x = Math.floor(i / length)
|
|
||||||
const y = i % length;
|
|
||||||
return module ? acc + `M${x*s},${y*s} h${s} v${s} h-${s} Z ` : acc;
|
|
||||||
}, '');
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{data.client.name}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 class="w-fit rounded-lg bg-accent p-2 text-lg">{data.client.name}</h1>
|
|
||||||
|
|
||||||
<section id="client-configuration" class="flex flex-wrap justify-center gap-4">
|
|
||||||
<CodeSnippet data={data.config} filename={clientWgCleanedName} copy download />
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-lg">
|
|
||||||
{@html qrCode.svg()}
|
|
||||||
</div>
|
|
||||||
<!--{qrCode.svg({ container: 'none' })}-->
|
|
||||||
<!--{qrCode.sv}-->
|
|
||||||
<div class="aspect-square min-h-64 p-3 bg-white rounded-lg">
|
|
||||||
<svg viewBox="0 0 {length*s*0.5} {length*s*0.5}" shape-rendering="crispEdges" image-rendering="optimizeQuality">
|
|
||||||
<path transform="scale(0.5)" d={path} />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<!--{pathdata}-->
|
|
||||||
</section>
|
|
||||||
<section id="usage" class="flex w-full flex-col gap-2">
|
|
||||||
<h2 class="text-xl font-semibold">Usage</h2>
|
|
||||||
<p>To use VPGen, you need to install the WireGuard app on your device.</p>
|
|
||||||
<WireguardGuide />
|
|
||||||
</section>
|
|
@@ -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 };
|
|
||||||
};
|
|
@@ -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>
|
||||||
|
@@ -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 };
|
||||||
};
|
};
|
||||||
|
@@ -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;
|
@@ -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={`/clients/${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>
|
9
src/routes/devices/+page.ts
Normal file
9
src/routes/devices/+page.ts
Normal 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 };
|
||||||
|
};
|
41
src/routes/devices/[id]/+page.svelte
Normal file
41
src/routes/devices/[id]/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import QRCode from 'qrcode-svg';
|
||||||
|
import { CodeSnippet } from '$lib/components/app/code-snippet';
|
||||||
|
import { WireguardGuide } from '$lib/components/app/wireguard-guide';
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// Clean the device name for the wg config filename,
|
||||||
|
// things can break otherwise (too long or invalid characters)
|
||||||
|
// https://github.com/pirate/wireguard-docs
|
||||||
|
const deviceWgCleanedName =
|
||||||
|
data.device.name.slice(0, 15).replace(/[^a-zA-Z0-9_=+.-]/g, '_') + '.conf';
|
||||||
|
|
||||||
|
let qrCode = new QRCode({
|
||||||
|
content: data.config,
|
||||||
|
join: true,
|
||||||
|
background: 'hsl(var(--accent-light))',
|
||||||
|
width: 296,
|
||||||
|
height: 296,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.device.name}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="w-fit rounded-lg bg-accent p-2 text-lg">{data.device.name}</h1>
|
||||||
|
|
||||||
|
<section id="device-configuration" class="flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<CodeSnippet data={data.config} filename={deviceWgCleanedName} copy download />
|
||||||
|
|
||||||
|
<div class="size-fit overflow-auto rounded-lg">
|
||||||
|
{@html qrCode.svg()}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="usage" class="flex w-full flex-col gap-2">
|
||||||
|
<h2 class="text-xl font-semibold">Usage</h2>
|
||||||
|
<p>To use VPGen, you need to install the WireGuard app on your device.</p>
|
||||||
|
<WireguardGuide />
|
||||||
|
</section>
|
15
src/routes/devices/[id]/+page.ts
Normal file
15
src/routes/devices/[id]/+page.ts
Normal 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 };
|
||||||
|
};
|
@@ -1,93 +1,94 @@
|
|||||||
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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [tailwindcssAnimate],
|
plugins: [tailwindcssAnimate],
|
||||||
|
Reference in New Issue
Block a user