11 Commits

Author SHA1 Message Date
0491189850 WIP: implement wg-quick as a provider
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2025-05-11 00:50:02 -07:00
bb80776776 ui: auth: improve auth form and invite page
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2025-05-04 18:43:15 -07:00
230fcf79df auth: refactor common oauth provider logic, add options to disable providers and require invites
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2025-05-02 16:41:13 -07:00
4300729638 ci: add woodpecker ci for container image builds
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2025-04-23 18:43:54 -07:00
73ef39770e fix docker builds 2025-04-23 18:42:47 -07:00
12a3001aec add editorconfig and zed settings 2025-04-10 19:02:55 -07:00
06a7f1bd51 ui: update shadcn components 2025-04-08 21:56:27 -07:00
d430f1db17 db: drop opnsense_id from devices schema 2025-03-30 19:55:05 -07:00
0e23c8e21c refactor: add interface for wg provider with opnsense implementation 2025-03-30 19:54:10 -07:00
e9d4be1d53 add typia for type validation 2025-03-15 21:07:17 -07:00
02ff13e4d3 auth: work on adding google auth via invite 2025-03-15 21:00:34 -07:00
70 changed files with 1844 additions and 624 deletions

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
[*]
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = tab
indent_size = 2
max_line_length = 100
quote_type = single
trim_trailing_whitespace = true

View File

@@ -1,13 +1,30 @@
DATABASE_URL=file:local.db DATABASE_URL=file:local.db
AUTH_DOMAIN=auth.lab.cazzzer.com
AUTH_CLIENT_ID= PUBLIC_AUTH_AUTHENTIK_ENABLE=1
AUTH_CLIENT_SECRET= AUTH_AUTHENTIK_REQUIRE_INVITE=0
AUTH_AUTHENTIK_DOMAIN=auth.lab.cazzzer.com
AUTH_AUTHENTIK_CLIENT_ID=
AUTH_AUTHENTIK_CLIENT_SECRET=
PUBLIC_AUTH_GOOGLE_ENABLE=1
AUTH_GOOGLE_REQUIRE_INVITE=1
AUTH_GOOGLE_CLIENT_ID=
AUTH_GOOGLE_CLIENT_SECRET=
AUTH_INVITE_TOKEN=GUjdsz9aREFTEBYDrA3AajUE8oVys2xW
WG_PROVIDER=opnsense
OPNSENSE_API_URL=https://opnsense.cazzzer.com OPNSENSE_API_URL=https://opnsense.cazzzer.com
OPNSENSE_API_KEY= OPNSENSE_API_KEY=
OPNSENSE_API_SECRET= OPNSENSE_API_SECRET=
OPNSENSE_WG_IFNAME=wg2 OPNSENSE_WG_IFNAME=wg2
WG_QUICK_FILENAME=wg0.conf
WG_QUICK_PRIVATE_KEY=MHV1/cTPiuOlEwwQ011dSn0e2c+sNRcPnA2e/74+N2E=
WG_QUICK_ADDRESS=10.20.30.1,fd00::1
WG_QUICK_LISTEN_PORT=51820
IPV4_STARTING_ADDR=10.18.11.100 IPV4_STARTING_ADDR=10.18.11.100
IPV6_STARTING_ADDR=fd00:10:18:11::100:0 IPV6_STARTING_ADDR=fd00:10:18:11::100:0
IPV6_CLIENT_PREFIX_SIZE=112 IPV6_CLIENT_PREFIX_SIZE=112

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ vite.config.ts.timestamp-*
# SQLite # SQLite
*.db *.db
# Generated wireguard configs
wg*.conf

6
.idea/bun.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BunSettings">
<option name="bunPath" value="bun" />
</component>
</project>

2
.idea/modules.xml generated
View File

@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/vpgen-sv5.iml" filepath="$PROJECT_DIR$/.idea/vpgen-sv5.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/vpgen.iml" filepath="$PROJECT_DIR$/.idea/vpgen.iml" />
</modules> </modules>
</component> </component>
</project> </project>

1
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true engine-strict=true
@jsr:registry=https://npm.jsr.io

View File

@@ -0,0 +1,15 @@
when:
- event: [push]
steps:
- name: build
image: woodpeckerci/plugin-kaniko
settings:
registry: gitea.cazzzer.com
repo: ${CI_REPO,,}
# replace '/' in branch name
tags: ${CI_COMMIT_BRANCH/\//-}
cache: true
username:
from_secret: registry-username
password:
from_secret: registry-password

7
.zed/settings.json Normal file
View File

@@ -0,0 +1,7 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"formatter": "prettier"
}

View File

@@ -2,19 +2,19 @@
# see all versions at https://hub.docker.com/r/oven/bun/tags # see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1-alpine AS base FROM oven/bun:1-alpine AS base
WORKDIR /app WORKDIR /app
COPY package.json bun.lockb /app/ COPY package.json bun.lock /app/
# install dependencies into temp directory # install dependencies into temp directory
# this will cache them and speed up future builds # this will cache them and speed up future builds
FROM base AS install FROM base AS install
RUN mkdir -p /temp/dev RUN mkdir -p /temp/dev
COPY package.json bun.lockb /temp/dev/ COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies) # install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/ COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production RUN cd /temp/prod && bun install --frozen-lockfile --production --ignore-scripts
# copy node_modules from temp directory # copy node_modules from temp directory
# then copy all (non-ignored) project files into the image # then copy all (non-ignored) project files into the image

View File

@@ -5,22 +5,25 @@ One-click WireGuard config generator, work in progress.
## Why? ## Why?
Make it easier to share VPN access with friends/family, Make it easier to share VPN access with friends/family,
making use of (my) existing networking infrastructure. making use of existing networking infrastructure.
## How? ## How?
Currently, the supported backend is [OPNsense](https://opnsense.org/). Currently, the supported backend is [OPNsense](https://opnsense.org/).
VPGen just creates WireGuard clients on the configured interface via the OPNsense API. VPGen just creates WireGuard clients on the configured interface via the OPNsense API.
Future plans include supporting other API backends, e.g. [Netmaker](https://github.com/gravitl/netmaker) Future plans include supporting other API backends (e.g. [Netmaker](https://github.com/gravitl/netmaker))
and [wg-quick](https://www.wireguard.com/quickstart/) for standalone setups.
## Development ## Development
Development uses bun. Development uses bun.
An additional prepare step is needed to set up typia for type validation.
For example .env settings, see [.env.example](.env.example) For example .env settings, see [.env.example](.env.example)
```shell ```shell
bun install bun install
bun run prepare
bun run dev bun run dev
``` ```

486
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
ALTER TABLE `users` ADD `auth_source` text DEFAULT 'authentik' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `devices` DROP COLUMN `opnsense_id`;

View File

@@ -0,0 +1,229 @@
{
"version": "6",
"dialect": "sqlite",
"id": "cc1fa973-1e9c-4bd6-b082-d7cf36f7342c",
"prevId": "48b7ce55-58f1-4b97-a144-ca733576dba6",
"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": {
"name": "ip_allocations",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"ip_allocations_device_id_unique": {
"name": "ip_allocations_device_id_unique",
"columns": [
"device_id"
],
"isUnique": true
}
},
"foreignKeys": {
"ip_allocations_device_id_devices_id_fk": {
"name": "ip_allocations_device_id_devices_id_fk",
"tableFrom": "ip_allocations",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"auth_source": {
"name": "auth_source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'authentik'"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,222 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0b364191-58d0-46a3-8372-4a30b0b88d85",
"prevId": "cc1fa973-1e9c-4bd6-b082-d7cf36f7342c",
"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
},
"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": {
"name": "ip_allocations",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"ip_allocations_device_id_unique": {
"name": "ip_allocations_device_id_unique",
"columns": [
"device_id"
],
"isUnique": true
}
},
"foreignKeys": {
"ip_allocations_device_id_devices_id_fk": {
"name": "ip_allocations_device_id_devices_id_fk",
"tableFrom": "ip_allocations",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"auth_source": {
"name": "auth_source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'authentik'"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,20 @@
"when": 1736295566569, "when": 1736295566569,
"tag": "0000_fair_tarantula", "tag": "0000_fair_tarantula",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1741936760967,
"tag": "0001_equal_unicorn",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1743389268079,
"tag": "0002_minor_black_panther",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,11 +1,12 @@
{ {
"name": "vpgen", "name": "vpgen",
"version": "0.0.1", "version": "0.0.1",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "bun x --bun vite dev",
"build": "vite build", "build": "bun x --bun vite build",
"preview": "vite preview", "preview": "bun x --bun vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
@@ -14,48 +15,53 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "bun run ./src/lib/server/db/seed.ts" "db:seed": "bun run ./src/lib/server/db/seed.ts",
"prepare": "ts-patch install"
}, },
"devDependencies": { "devDependencies": {
"@lucide/svelte": "^0.487.0",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@ryoppippi/unplugin-typia": "npm:@jsr/ryoppippi__unplugin-typia",
"@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.17.2", "@sveltejs/kit": "^2.20.7",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.2.13",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/qrcode-svg": "^1.1.5", "@types/qrcode-svg": "^1.1.5",
"arctic": "^2.3.4", "arctic": "^2.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.21",
"bits-ui": "^0.22.0", "bits-ui": "^1.3.19",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint": "^9.20.1", "eslint": "^9.25.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"ip-address": "^10.0.1", "ip-address": "^10.0.1",
"lucide-svelte": "^0.469.0", "prettier": "^3.5.3",
"prettier": "^3.5.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"qrcode-svg": "^1.1.0", "qrcode-svg": "^1.1.0",
"svelte": "^5.20.1", "svelte": "^5.28.1",
"svelte-check": "^4.1.4", "svelte-check": "^4.1.6",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwind-variants": "^0.3.1", "tailwind-variants": "^0.3.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3", "ts-patch": "^3.3.0",
"typescript-eslint": "^8.24.1", "typescript": "~5.8.3",
"vite": "^6.1.0" "typescript-eslint": "^8.31.0",
"typia": "^8.2.0",
"vite": "^6.3.2"
}, },
"dependencies": { "dependencies": {
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"drizzle-kit": "^0.30.4", "drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.4" "drizzle-orm": "^0.38.4"
} }
} }

View File

@@ -2,10 +2,9 @@ import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks'; import { sequence } from '@sveltejs/kit/hooks';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import * as auth from '$lib/server/auth'; import * as auth from '$lib/server/auth';
import { fetchOpnsenseServer } from '$lib/server/opnsense'; import wgProvider from '$lib/server/wg-provider';
// fetch opnsense server info on startup await wgProvider.init();
await fetchOpnsenseServer();
const handleAuth: Handle = async ({ event, resolve }) => { const handleAuth: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get(auth.sessionCookieName); const sessionId = event.cookies.get(auth.sessionCookieName);

13
src/lib/assets/google.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg width="40" height="40" viewBox="10 10 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_710_6221)">
<path d="M29.6 20.2273C29.6 19.5182 29.5364 18.8364 29.4182 18.1818H20V22.05H25.3818C25.15 23.3 24.4455 24.3591 23.3864 25.0682V27.5773H26.6182C28.5091 25.8364 29.6 23.2727 29.6 20.2273Z" fill="#4285F4"/>
<path d="M20 30C22.7 30 24.9636 29.1045 26.6181 27.5773L23.3863 25.0682C22.4909 25.6682 21.3454 26.0227 20 26.0227C17.3954 26.0227 15.1909 24.2636 14.4045 21.9H11.0636V24.4909C12.7091 27.7591 16.0909 30 20 30Z" fill="#34A853"/>
<path d="M14.4045 21.9C14.2045 21.3 14.0909 20.6591 14.0909 20C14.0909 19.3409 14.2045 18.7 14.4045 18.1V15.5091H11.0636C10.3864 16.8591 10 18.3864 10 20C10 21.6136 10.3864 23.1409 11.0636 24.4909L14.4045 21.9Z" fill="#FBBC04"/>
<path d="M20 13.9773C21.4681 13.9773 22.7863 14.4818 23.8227 15.4727L26.6909 12.6045C24.9591 10.9909 22.6954 10 20 10C16.0909 10 12.7091 12.2409 11.0636 15.5091L14.4045 18.1C15.1909 15.7364 17.3954 13.9773 20 13.9773Z" fill="#E94235"/>
</g>
<defs>
<clipPath id="clip0_710_6221">
<rect width="20" height="20" fill="white" transform="translate(10 10)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

9
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,9 @@
import { envToBool } from '$lib/utils';
import { env } from '$env/dynamic/public';
export type AuthProvider = 'authentik' | 'google';
export const enabledAuthProviders: Record<AuthProvider, boolean> = {
authentik: envToBool(env.PUBLIC_AUTH_AUTHENTIK_ENABLE),
google: envToBool(env.PUBLIC_AUTH_GOOGLE_ENABLE),
};

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { LucideLoaderCircle } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
providerName: string;
displayName: string;
iconSrc: string;
inviteToken?: string;
}
let { providerName, displayName, inviteToken, iconSrc }: Props = $props();
let submitted = $state(false);
</script>
<form method="get" onsubmit={() => (submitted = true)} action="/auth/{providerName}">
{#if inviteToken}
<input type="hidden" value={inviteToken} name="invite" />
{/if}
<Button type="submit" disabled={submitted}>
{#if submitted}
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<img class="mr-2 h-4 w-4" alt="{displayName} Logo" src={iconSrc} />
{/if}
Sign {inviteToken ? 'up' : 'in'} with {displayName}
</Button>
</form>

View File

@@ -1,31 +1,26 @@
<script lang="ts"> <script lang="ts">
import { LucideLoaderCircle } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';
import googleIcon from '$lib/assets/google.svg';
import { enabledAuthProviders } from '$lib/auth';
import AuthButton from './auth-button.svelte';
let { class: className, ...rest }: { class?: string; rest?: { [p: string]: unknown } } = $props(); interface Props {
inviteToken?: string;
let isLoading = $state(false); class?: string;
}
let { inviteToken, class: className }: Props = $props();
</script> </script>
<div class={cn('flex gap-6', className)} {...rest}> <div class={cn('flex gap-6', className)}>
<form method="get" action="/auth/authentik"> {#if enabledAuthProviders.authentik}
<Button <AuthButton
type="submit" providerName="authentik"
onclick={() => { displayName="Authentik"
isLoading = true; iconSrc="https://auth.cazzzer.com/static/dist/assets/icons/icon.svg"
}} {inviteToken}
>
{#if isLoading}
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<img
class="mr-2 h-4 w-4"
alt="Authentik Logo"
src="https://auth.cazzzer.com/static/dist/assets/icons/icon.svg"
/> />
{/if} {/if}
Sign in with Authentik {#if enabledAuthProviders.google}
</Button> <AuthButton providerName="google" displayName="Google" iconSrc={googleIcon} {inviteToken} />
</form> {/if}
</div> </div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { LucideClipboardCopy, LucideDownload } from 'lucide-svelte'; import { LucideClipboardCopy, LucideDownload } from '@lucide/svelte';
const { const {
data, data,

View File

@@ -43,7 +43,7 @@
this={href ? "a" : "span"} this={href ? "a" : "span"}
bind:this={ref} bind:this={ref}
{href} {href}
class={cn(badgeVariants({ variant, className }))} class={cn(badgeVariants({ variant }), className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}

View File

@@ -56,7 +56,7 @@
{#if href} {#if href}
<a <a
bind:this={ref} bind:this={ref}
class={cn(buttonVariants({ variant, size, className }))} class={cn(buttonVariants({ variant, size }), className)}
{href} {href}
{...restProps} {...restProps}
> >
@@ -65,7 +65,7 @@
{:else} {:else}
<button <button
bind:this={ref} bind:this={ref}
class={cn(buttonVariants({ variant, size, className }))} class={cn(buttonVariants({ variant, size }), className)}
{type} {type}
{...restProps} {...restProps}
> >

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import Check from "lucide-svelte/icons/check"; import Check from "@lucide/svelte/icons/check";
import Minus from "lucide-svelte/icons/minus"; import Minus from "@lucide/svelte/icons/minus";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui"; import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import X from "lucide-svelte/icons/x"; import X from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import * as Dialog from "./index.js"; import * as Dialog from "./index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -17,7 +17,7 @@
} = $props(); } = $props();
</script> </script>
<Dialog.Portal class="absolute" {...portalProps}> <Dialog.Portal {...portalProps}>
<Dialog.Overlay /> <Dialog.Overlay />
<DialogPrimitive.Content <DialogPrimitive.Content
bind:ref bind:ref

View File

@@ -5,7 +5,6 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children,
...restProps ...restProps
}: DialogPrimitive.DescriptionProps = $props(); }: DialogPrimitive.DescriptionProps = $props();
</script> </script>
@@ -14,6 +13,4 @@
bind:ref bind:ref
class={cn("text-muted-foreground text-sm", className)} class={cn("text-muted-foreground text-sm", className)}
{...restProps} {...restProps}
> />
{@render children?.()}
</DialogPrimitive.Description>

View File

@@ -5,7 +5,6 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children,
...restProps ...restProps
}: DialogPrimitive.OverlayProps = $props(); }: DialogPrimitive.OverlayProps = $props();
</script> </script>
@@ -17,6 +16,4 @@
className className
)} )}
{...restProps} {...restProps}
> />
{@render children?.()}
</DialogPrimitive.Overlay>

View File

@@ -5,7 +5,6 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children,
...restProps ...restProps
}: DialogPrimitive.TitleProps = $props(); }: DialogPrimitive.TitleProps = $props();
</script> </script>
@@ -14,6 +13,4 @@
bind:ref bind:ref
class={cn("text-lg font-semibold leading-none tracking-tight", className)} class={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...restProps} {...restProps}
> />
{@render children?.()}
</DialogPrimitive.Title>

View File

@@ -1,22 +1,46 @@
<script lang="ts"> <script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements"; import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import type { WithElementRef } from "bits-ui"; import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let { let {
ref = $bindable(null), ref = $bindable(null),
value = $bindable(), value = $bindable(),
type,
files = $bindable(),
class: className, class: className,
...restProps ...restProps
}: WithElementRef<HTMLInputAttributes> = $props(); }: Props = $props();
</script> </script>
<input {#if type === "file"}
<input
bind:this={ref} bind:this={ref}
class={cn( class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
type="file"
bind:files
bind:value bind:value
{...restProps} {...restProps}
/> />
{:else}
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -5,7 +5,6 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children,
...restProps ...restProps
}: LabelPrimitive.RootProps = $props(); }: LabelPrimitive.RootProps = $props();
</script> </script>
@@ -17,6 +16,4 @@
className className
)} )}
{...restProps} {...restProps}
> />
{@render children?.()}
</LabelPrimitive.Root>

View File

@@ -5,7 +5,6 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children,
...restProps ...restProps
}: TabsPrimitive.ContentProps = $props(); }: TabsPrimitive.ContentProps = $props();
</script> </script>
@@ -17,6 +16,4 @@
className className
)} )}
{...restProps} {...restProps}
> />
{@render children?.()}
</TabsPrimitive.Content>

View File

@@ -5,7 +5,6 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children,
...restProps ...restProps
}: TabsPrimitive.ListProps = $props(); }: TabsPrimitive.ListProps = $props();
</script> </script>
@@ -17,6 +16,4 @@
className className
)} )}
{...restProps} {...restProps}
> />
{@render children?.()}
</TabsPrimitive.List>

View File

@@ -5,7 +5,6 @@
let { let {
ref = $bindable(null), ref = $bindable(null),
class: className, class: className,
children,
...restProps ...restProps
}: TabsPrimitive.TriggerProps = $props(); }: TabsPrimitive.TriggerProps = $props();
</script> </script>
@@ -17,6 +16,4 @@
className className
)} )}
{...restProps} {...restProps}
> />
{@render children?.()}
</TabsPrimitive.Trigger>

View File

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

View File

@@ -3,8 +3,9 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import type { RequestEvent } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
const DAY_IN_MS = 1000 * 60 * 60 * 24; const DAY_IN_MS = 1000 * 60 * 60 * 24;
@@ -21,14 +22,14 @@ export async function createSession(userId: string): Promise<table.Session> {
const session: table.Session = { const session: table.Session = {
id: sessionId, id: sessionId,
userId, userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30) expiresAt: new Date(Date.now() + DAY_IN_MS * 30),
}; };
await db.insert(table.sessions).values(session); await db.insert(table.sessions).values(session);
return session; return session;
} }
export function setSessionTokenCookie(event: RequestEvent, sessionId: string, expiresAt: Date) { export function setSessionTokenCookie(cookies: Cookies, sessionId: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, sessionId, { cookies.set(sessionCookieName, sessionId, {
path: '/', path: '/',
sameSite: 'lax', sameSite: 'lax',
httpOnly: true, httpOnly: true,
@@ -41,16 +42,21 @@ export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(table.sessions).where(eq(table.sessions.id, sessionId)); await db.delete(table.sessions).where(eq(table.sessions.id, sessionId));
} }
export function deleteSessionTokenCookie(event: RequestEvent) { export function deleteSessionTokenCookie(cookies: Cookies) {
event.cookies.delete(sessionCookieName, { path: '/' }); cookies.delete(sessionCookieName, { path: '/' });
} }
export async function validateSession(sessionId: string) { export async function validateSession(sessionId: string) {
const [result] = await db const [result] = await db
.select({ .select({
// Adjust user table here to tweak returned data // Adjust user table here to tweak returned data
user: { id: table.users.id, username: table.users.username, name: table.users.name }, user: {
session: table.sessions id: table.users.id,
authSource: table.users.authSource,
username: table.users.username,
name: table.users.name,
},
session: table.sessions,
}) })
.from(table.sessions) .from(table.sessions)
.innerJoin(table.users, eq(table.sessions.userId, table.users.id)) .innerJoin(table.users, eq(table.sessions.userId, table.users.id))
@@ -79,4 +85,8 @@ export async function validateSession(sessionId: string) {
return { session, user }; return { session, user };
} }
export function isValidInviteToken(inviteToken: string) {
return inviteToken === env.AUTH_INVITE_TOKEN;
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSession>>; export type SessionValidationResult = Awaited<ReturnType<typeof validateSession>>;

View File

@@ -3,6 +3,7 @@ import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', { export const users = sqliteTable('users', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
authSource: text('auth_source').notNull().default('authentik'),
username: text('username').notNull(), username: text('username').notNull(),
name: text('name').notNull(), name: text('name').notNull(),
}); });
@@ -35,8 +36,6 @@ export const devices = sqliteTable('devices', {
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
name: text('name').notNull(), name: text('name').notNull(),
// questioning whether this should be nullable
opnsenseId: text('opnsense_id'),
publicKey: text('public_key').notNull().unique(), publicKey: text('public_key').notNull().unique(),
// nullable for the possibility of a user 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'),

View File

@@ -2,7 +2,7 @@ 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';
import * as schema from '$lib/server/db/schema'; import * as schema from './schema';
assert(process.env.DATABASE_URL, 'DATABASE_URL is not set'); assert(process.env.DATABASE_URL, 'DATABASE_URL is not set');
const db = drizzle(process.env.DATABASE_URL, { schema }); const db = drizzle(process.env.DATABASE_URL, { schema });

View File

@@ -3,9 +3,8 @@ import { err, ok, type Result } from '$lib/types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { count, eq, isNull } from 'drizzle-orm'; import { count, eq, isNull } from 'drizzle-orm';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { opnsenseAuth, opnsenseUrl, serverUuid } from '$lib/server/opnsense';
import { opnsenseSanitezedUsername } from '$lib/opnsense';
import { getIpsFromIndex } from './utils'; import { getIpsFromIndex } from './utils';
import wgProvider from '$lib/server/wg-provider';
export async function createDevice(params: { export async function createDevice(params: {
name: string; name: string;
@@ -19,17 +18,15 @@ export async function createDevice(params: {
if (deviceCount >= parseInt(env.MAX_CLIENTS_PER_USER)) if (deviceCount >= parseInt(env.MAX_CLIENTS_PER_USER))
return err([400, 'Maximum number of devices reached'] as [400, string]); return err([400, 'Maximum number of devices reached'] as [400, string]);
// this is going to be quite long // 1. fetch params for new device from provider
// 1. fetch params for new device from opnsense api
// 2.1 get an allocation for the device // 2.1 get an allocation for the device
// 2.2. insert new device into db // 2.2. insert new device into db
// 2.3. update the allocation with the device id // 2.3. update the allocation with the device id
// 3. create the client in opnsense // 3. create the client in provider
// 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 [keysResult, availableAllocation, lastAllocation] = await Promise.all([
// fetch params for new device from opnsense api // fetch params for new device from provider
getKeys(), wgProvider.generateKeys(),
// find first unallocated IP // find first unallocated IP
await tx.query.ipAllocations.findFirst({ await tx.query.ipAllocations.findFirst({
columns: { columns: {
@@ -46,9 +43,12 @@ export async function createDevice(params: {
}), }),
]); ]);
if (keysResult?._tag === 'err') return err([500, 'Failed to get keys']);
const keys = keysResult.value;
// check for existing allocation or if we have any IPs left // check for existing allocation or if we have any IPs left
if (!availableAllocation && lastAllocation && lastAllocation.id >= parseInt(env.IP_MAX_INDEX)) { if (!availableAllocation && lastAllocation && lastAllocation.id >= parseInt(env.IP_MAX_INDEX)) {
return err([500, 'No more IP addresses available'] as [500, string]); return err([500, 'No more IP addresses available']);
} }
// use existing allocation or create a new one // use existing allocation or create a new one
@@ -65,9 +65,9 @@ export async function createDevice(params: {
.values({ .values({
userId: params.user.id, userId: params.user.id,
name: params.name, name: params.name,
publicKey: keys.pubkey, publicKey: keys.publicKey,
privateKey: keys.privkey, privateKey: keys.privateKey,
preSharedKey: keys.psk, preSharedKey: keys.preSharedKey,
}) })
.returning({ id: devices.id }); .returning({ id: devices.id });
@@ -77,80 +77,18 @@ export async function createDevice(params: {
.set({ deviceId: newDevice.id }) .set({ deviceId: newDevice.id })
.where(eq(ipAllocations.id, ipAllocationId)); .where(eq(ipAllocations.id, ipAllocationId));
// create client in opnsense // create client in provider
const opnsenseRes = await opnsenseCreateClient({ const providerRes = await wgProvider.createClient({
username: params.user.username, user: params.user,
pubkey: keys.pubkey, publicKey: keys.publicKey,
psk: keys.psk, preSharedKey: keys.preSharedKey,
allowedIps: getIpsFromIndex(ipAllocationId).join(','), allowedIps: getIpsFromIndex(ipAllocationId).join(','),
}); });
const opnsenseResJson = await opnsenseRes.json(); if (providerRes._tag === 'err') {
if (opnsenseResJson['result'] !== 'saved') {
tx2.rollback(); tx2.rollback();
console.error(`Error creating client in OPNsense: \n${opnsenseResJson}`); return err([500, 'Failed to create client in provider']);
return err([500, 'Error creating client in OPNsense'] as [500, string]);
} }
// reconfigure opnsense
await opnsenseReconfigure();
return ok(newDevice.id); return ok(newDevice.id);
}); });
}); });
} }
async function getKeys() {
// fetch key pair from opnsense
const options: RequestInit = {
method: 'GET',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
},
};
const resKeyPair = await fetch(`${opnsenseUrl}/api/wireguard/server/key_pair`, options);
const resPsk = await fetch(`${opnsenseUrl}/api/wireguard/client/psk`, options);
const keyPair = await resKeyPair.json();
const psk = await resPsk.json();
return {
pubkey: keyPair['pubkey'] as string,
privkey: keyPair['privkey'] as string,
psk: psk['psk'] as string,
};
}
async function opnsenseCreateClient(params: {
username: string;
pubkey: string;
psk: string;
allowedIps: string;
}) {
return fetch(`${opnsenseUrl}/api/wireguard/client/addClientBuilder`, {
method: 'POST',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
configbuilder: {
enabled: '1',
name: `vpgen-${opnsenseSanitezedUsername(params.username)}`,
pubkey: params.pubkey,
psk: params.psk,
tunneladdress: params.allowedIps,
server: serverUuid,
endpoint: env.VPN_ENDPOINT,
},
}),
});
}
async function opnsenseReconfigure() {
return fetch(`${opnsenseUrl}/api/wireguard/service/reconfigure`, {
method: 'POST',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
},
});
}

View File

@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { devices } from '$lib/server/db/schema'; import { devices } from '$lib/server/db/schema';
import { err, ok, type Result } from '$lib/types'; import { err, ok, type Result } from '$lib/types';
import { opnsenseAuth, opnsenseUrl } from '$lib/server/opnsense'; import wgProvider from '$lib/server/wg-provider';
export async function deleteDevice( export async function deleteDevice(
userId: string, userId: string,
@@ -15,59 +15,13 @@ export async function deleteDevice(
where: and(eq(devices.userId, userId), eq(devices.id, deviceId)), where: and(eq(devices.userId, userId), eq(devices.id, deviceId)),
}); });
if (!device) return err([400, 'Device not found']); if (!device) return err([400, 'Device not found']);
const opnsenseClientUuid = (await opnsenseFindClient(device.publicKey))?.['uuid'];
if (typeof opnsenseClientUuid !== 'string') {
console.error(
'failed to get OPNsense client for deletion for device',
deviceId,
device.publicKey,
);
return err([500, 'Error getting client from OPNsense']);
}
const opnsenseDeletionResult = await opnsenseDeleteClient(opnsenseClientUuid); const providerDeletionResult = await wgProvider.deleteClient(device.publicKey);
if (opnsenseDeletionResult?.['result'] !== 'deleted') { if (providerDeletionResult._tag === 'err') {
console.error( console.error('failed to delete provider client for device', deviceId, device.publicKey);
'failed to delete OPNsense client for device', return err([500, 'Error deleting client in provider']);
deviceId,
device.publicKey,
'\n',
'OPNsense client uuid:',
opnsenseClientUuid,
);
return err([500, 'Error deleting client in OPNsense']);
} }
await db.delete(devices).where(eq(devices.id, deviceId)); await db.delete(devices).where(eq(devices.id, deviceId));
return ok(null); return ok(null);
} }
async function opnsenseFindClient(pubkey: string) {
const res = await fetch(`${opnsenseUrl}/api/wireguard/client/searchClient`, {
method: 'POST',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
current: 1,
// "rowCount": 7,
sort: {},
searchPhrase: pubkey,
type: ['peer'],
}),
});
return (await res.json())?.rows?.[0] ?? null;
}
async function opnsenseDeleteClient(clientUuid: string) {
const res = await fetch(`${opnsenseUrl}/api/wireguard/client/delClient/${clientUuid}`, {
method: 'POST',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
},
});
return res.json();
}

View File

@@ -2,9 +2,9 @@ import { db } from '$lib/server/db';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { devices } from '$lib/server/db/schema'; import { devices } from '$lib/server/db/schema';
import type { DeviceDetails } from '$lib/devices'; import type { DeviceDetails } from '$lib/devices';
import { serverPublicKey } from '$lib/server/opnsense';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { getIpsFromIndex } from '$lib/server/devices/index'; import { getIpsFromIndex } from '$lib/server/devices/index';
import wgProvider from '$lib/server/wg-provider';
export async function findDevices(userId: string) { export async function findDevices(userId: string) {
return db.query.devices.findMany({ return db.query.devices.findMany({
@@ -49,7 +49,7 @@ export function mapDeviceToDetails(
privateKey: device.privateKey, privateKey: device.privateKey,
preSharedKey: device.preSharedKey, preSharedKey: device.preSharedKey,
ips, ips,
vpnPublicKey: serverPublicKey, vpnPublicKey: wgProvider.getServerPublicKey(),
vpnEndpoint: env.VPN_ENDPOINT, vpnEndpoint: env.VPN_ENDPOINT,
vpnDns: env.VPN_DNS, vpnDns: env.VPN_DNS,
}; };

View File

@@ -0,0 +1,34 @@
import { envToBool } from '$lib/utils';
import { env } from '$env/dynamic/private';
import { Authentik, decodeIdToken } from 'arctic';
import { assertGuard } from 'typia';
import type { IOAuthProvider } from '$lib/server/oauth';
const authentikProvider = new Authentik(
env.AUTH_AUTHENTIK_DOMAIN,
env.AUTH_AUTHENTIK_CLIENT_ID,
env.AUTH_AUTHENTIK_CLIENT_SECRET,
`${env.ORIGIN}/auth/authentik/callback`,
);
export const authentik: IOAuthProvider = {
requireInvite: envToBool(env.AUTH_AUTHENTIK_REQUIRE_INVITE, true),
createAuthorizationURL: (state: string, codeVerifier: string) => {
const scopes = ['openid', 'profile'];
return authentikProvider.createAuthorizationURL(state, codeVerifier, scopes);
},
validateAuthorizationCode: async (code: string, codeVerifier: string) => {
const tokens = await authentikProvider.validateAuthorizationCode(code, codeVerifier);
const claims = decodeIdToken(tokens.idToken());
assertGuard<{
sub: string;
name: string;
preferred_username: string;
}>(claims);
return {
sub: claims.sub,
name: claims.name,
username: claims.preferred_username,
};
},
};

View File

@@ -0,0 +1,33 @@
import { decodeIdToken, Google } from 'arctic';
import { env } from '$env/dynamic/private';
import { envToBool } from '$lib/utils';
import { assertGuard } from 'typia';
import type { IOAuthProvider } from '$lib/server/oauth';
const googleProvider = new Google(
env.AUTH_GOOGLE_CLIENT_ID,
env.AUTH_GOOGLE_CLIENT_SECRET,
`${env.ORIGIN}/auth/google/callback`,
);
export const google: IOAuthProvider = {
requireInvite: envToBool(env.AUTH_GOOGLE_REQUIRE_INVITE, true),
createAuthorizationURL: (state: string, codeVerifier: string) => {
const scopes = ['openid', 'profile', 'email'];
return googleProvider.createAuthorizationURL(state, codeVerifier, scopes);
},
validateAuthorizationCode: async (code: string, codeVerifier: string) => {
const tokens = await googleProvider.validateAuthorizationCode(code, codeVerifier);
const claims = decodeIdToken(tokens.idToken());
assertGuard<{
sub: string;
email: string;
name: string;
}>(claims);
return {
sub: claims.sub,
name: claims.name,
username: claims.email,
};
},
};

View File

@@ -0,0 +1,2 @@
export { authentik } from './authentik';
export { google } from './google';

View File

@@ -1,9 +1,19 @@
import { Authentik } from 'arctic'; import type { AuthProvider } from '$lib/auth';
import { env } from '$env/dynamic/private'; import { authentik, google } from '$lib/server/oauth-providers';
export const authentik = new Authentik( export interface IOAuthClaims {
env.AUTH_DOMAIN, sub: string;
env.AUTH_CLIENT_ID, name: string;
env.AUTH_CLIENT_SECRET, username: string;
`${env.ORIGIN}/auth/authentik/callback`, }
);
export interface IOAuthProvider {
readonly requireInvite: boolean;
createAuthorizationURL(state: string, codeVerifier: string): URL;
validateAuthorizationCode(code: string, codeVerifier: string): Promise<IOAuthClaims>;
}
export const oauthProviders: Record<AuthProvider, IOAuthProvider> = {
authentik,
google,
};

View File

@@ -1,49 +0,0 @@
import { env } from '$env/dynamic/private';
import assert from 'node:assert';
import { encodeBasicCredentials } from 'arctic/dist/request';
import { dev } from '$app/environment';
import type { OpnsenseWgServers } from '$lib/opnsense/wg';
export const opnsenseUrl = env.OPNSENSE_API_URL;
export const opnsenseAuth =
'Basic ' + encodeBasicCredentials(env.OPNSENSE_API_KEY, env.OPNSENSE_API_SECRET);
export const opnsenseIfname = env.OPNSENSE_WG_IFNAME;
// unset secret for security
if (!dev) env.OPNSENSE_API_SECRET = '';
export let serverUuid: string, serverPublicKey: string;
export async function fetchOpnsenseServer() {
// this might be pretty bad if the server is down and in a bunch of other cases
// TODO: write a retry loop later
const resServers = await fetch(`${opnsenseUrl}/api/wireguard/client/list_servers`, {
method: 'GET',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
},
});
assert(resServers.ok, 'Failed to fetch OPNsense WireGuard servers');
const servers = (await resServers.json()) as OpnsenseWgServers;
assert.equal(servers.status, 'ok', 'Failed to fetch OPNsense WireGuard servers');
const uuid = servers.rows.find((server) => server.name === opnsenseIfname)?.uuid;
assert(uuid, 'Failed to find server UUID for OPNsense WireGuard server');
serverUuid = uuid;
console.log('OPNsense WireGuard server UUID:', serverUuid);
const resServerInfo = await fetch(
`${opnsenseUrl}/api/wireguard/client/get_server_info/${serverUuid}`,
{
method: 'GET',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
},
},
);
assert(resServerInfo.ok, 'Failed to fetch OPNsense WireGuard server info');
const serverInfo = await resServerInfo.json();
assert.equal(serverInfo.status, 'ok', 'Failed to fetch OPNsense WireGuard server info');
serverPublicKey = serverInfo['pubkey'];
}

View File

@@ -0,0 +1,38 @@
import type { Result } from '$lib/types';
import type { User } from '$lib/server/db/schema';
export interface IWgProvider {
init(): Promise<Result<null, Error>>;
getServerPublicKey(): string;
generateKeys(): Promise<Result<WgKeys, Error>>;
createClient(params: CreateClientParams): Promise<Result<null, Error>>;
findConnections(user: User): Promise<Result<ClientConnection[], Error>>;
deleteClient(publicKey: string): Promise<Result<null, Error>>;
}
export type WgKeys = {
publicKey: string;
privateKey: string;
preSharedKey: string;
};
export type CreateClientParams = {
user: User;
publicKey: string;
preSharedKey: string;
allowedIps: string;
}
export type ClientConnection = {
publicKey: string;
endpoint: string;
allowedIps: string;
transferRx: number;
transferTx: number;
latestHandshake: number;
}

View File

@@ -0,0 +1,32 @@
import { assertGuard } from 'typia';
import { env } from '$env/dynamic/private';
import type { IWgProvider } from '$lib/server/types';
import { WgProviderOpnsense } from '$lib/server/wg-providers/opnsense';
import { WgProviderWgQuick } from '$lib/server/wg-providers/wg-quick';
const opnsense: IWgProvider = new WgProviderOpnsense({
opnsenseUrl: env.OPNSENSE_API_URL,
opnsenseApiKey: env.OPNSENSE_API_KEY,
opnsenseApiSecret: env.OPNSENSE_API_SECRET,
opnsenseWgIfname: env.OPNSENSE_WG_IFNAME,
});
const wgQuick: IWgProvider = new WgProviderWgQuick({
filename: env.WG_QUICK_FILENAME,
address: env.WG_QUICK_ADDRESS,
privateKey: env.WG_QUICK_PRIVATE_KEY,
listenPort: parseInt(env.WG_QUICK_LISTEN_PORT),
});
const providers = {
opnsense,
'wg-quick': wgQuick,
};
const chosenProvider = env.WG_PROVIDER?? 'wg-quick';
assertGuard<keyof typeof providers>(chosenProvider, () =>
Error(`WG_PROVIDER must be one of ${Object.keys(providers).join(', ')}`),
);
const wgProvider = providers[chosenProvider];
export default wgProvider;

View File

@@ -0,0 +1,244 @@
import type { ClientConnection, CreateClientParams, IWgProvider, WgKeys } from '$lib/server/types';
import { encodeBasicCredentials } from 'arctic/dist/request';
import { is } from 'typia';
import type { OpnsenseWgPeers, OpnsenseWgServers } from '$lib/server/wg-providers/opnsense/types';
import { err, ok, type Result } from '$lib/types';
import assert from 'node:assert';
import type { User } from '$lib/server/db/schema';
export class WgProviderOpnsense implements IWgProvider {
private opnsenseUrl: string;
private opnsenseAuth: string;
private opnsenseIfname: string;
private opnsenseWgServerUuid: string | undefined;
private opnsenseWgServerPublicKey: string | undefined;
public constructor(params: OpnsenseParams) {
this.opnsenseUrl = params.opnsenseUrl;
this.opnsenseAuth =
'Basic ' + encodeBasicCredentials(params.opnsenseApiKey, params.opnsenseApiSecret);
this.opnsenseIfname = params.opnsenseWgIfname;
}
public async init(): Promise<Result<null, Error>> {
const resServers = await fetch(`${this.opnsenseUrl}/api/wireguard/client/list_servers`, {
method: 'GET',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
},
});
const servers = await resServers.json();
if (!is<OpnsenseWgServers>(servers)) {
console.error('Unexpected response for OPNsense WireGuard servers', servers);
return err(new Error('Failed to fetch OPNsense WireGuard servers'));
}
const uuid = servers.rows.find((server) => server.name === this.opnsenseIfname)?.uuid;
if (!uuid) {
console.error('OPNsense WireGuard servers', servers);
return err(new Error('Failed to find server UUID for OPNsense WireGuard server'));
}
const resServerInfo = await fetch(
`${this.opnsenseUrl}/api/wireguard/client/get_server_info/${uuid}`,
{
method: 'GET',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
},
},
);
const serverInfo = await resServerInfo.json();
const serverPublicKey = serverInfo['pubkey'];
if (serverInfo['status'] !== 'ok' || typeof serverPublicKey !== 'string') {
console.error('Failed to fetch OPNsense WireGuard server info', serverInfo);
return err(new Error('Failed to fetch OPNsense WireGuard server info'));
}
console.debug('OPNsense WireGuard server UUID:', uuid);
console.debug('OPNsense WireGuard server public key:', serverPublicKey);
this.opnsenseWgServerUuid = uuid;
this.opnsenseWgServerPublicKey = serverPublicKey;
return ok(null);
}
getServerPublicKey(): string {
assert(
this.opnsenseWgServerPublicKey,
'OPNsense server public key not set, init() must be called first',
);
return this.opnsenseWgServerPublicKey;
}
public async generateKeys(): Promise<Result<WgKeys, Error>> {
const options: RequestInit = {
method: 'GET',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
},
};
const resKeyPair = await fetch(`${this.opnsenseUrl}/api/wireguard/server/key_pair`, options);
const resPsk = await fetch(`${this.opnsenseUrl}/api/wireguard/client/psk`, options);
const keyPair = await resKeyPair.json();
const psk = await resPsk.json();
if (!is<{ pubkey: string; privkey: string }>(keyPair)) {
console.error('Unexpected response for OPNsense key pair', keyPair);
return err(new Error('Failed to fetch OPNsense key pair'));
}
if (!is<{ psk: string }>(psk)) {
console.error('Unexpected response for OPNsense PSK', psk);
return err(new Error('Failed to fetch OPNsense PSK'));
}
return ok({
publicKey: keyPair.pubkey,
privateKey: keyPair.privkey,
preSharedKey: psk.psk,
});
}
async createClient(params: CreateClientParams): Promise<Result<null, Error>> {
assert(this.opnsenseWgServerUuid, 'OPNsense server UUID not set, init() must be called first');
const createClientRes = await fetch(
`${this.opnsenseUrl}/api/wireguard/client/addClientBuilder`,
{
method: 'POST',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
configbuilder: {
enabled: '1',
name: `vpgen-${opnsenseSanitezedUsername(params.user.username)}`,
pubkey: params.publicKey,
psk: params.preSharedKey,
tunneladdress: params.allowedIps,
server: this.opnsenseWgServerUuid,
endpoint: '',
},
}),
},
);
const createClientResJson = await createClientRes.json();
if (createClientResJson['result'] !== 'saved') {
console.error('Error creating client in OPNsense', createClientResJson);
return err(new Error('Failed to create client in OPNsense'));
}
const reconfigureRes = await fetch(`${this.opnsenseUrl}/api/wireguard/service/reconfigure`, {
method: 'POST',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
},
});
if (reconfigureRes.status !== 200) {
console.error('Error reconfiguring OPNsense', reconfigureRes);
return err(new Error('Failed to reconfigure OPNsense'));
}
return ok(null);
}
async findConnections(user: User): Promise<Result<ClientConnection[], Error>> {
const res = await fetch(`${this.opnsenseUrl}/api/wireguard/service/show`, {
method: 'POST',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
current: 1,
// "rowCount": 7,
sort: {},
// TODO: use a more unique search phrase
// unfortunately 64 character limit,
// but it should be fine if users can't change their own username
searchPhrase: `vpgen-${opnsenseSanitezedUsername(user.username)}`,
type: ['peer'],
}),
});
const peers = await res.json();
if (!is<OpnsenseWgPeers>(peers)) {
console.error('Unexpected response for OPNsense WireGuard peers', peers);
return err(new Error('Failed to fetch OPNsense WireGuard peers'));
}
return ok(
peers.rows.map((peer) => {
return {
publicKey: peer['public-key'],
endpoint: peer['endpoint'],
allowedIps: peer['allowed-ips'],
transferRx: peer['transfer-rx'],
transferTx: peer['transfer-tx'],
latestHandshake: peer['latest-handshake'] * 1000,
};
}),
);
}
async deleteClient(publicKey: string): Promise<Result<null, Error>> {
const client = await this.findOpnsenseClient(publicKey);
const clientUuid = client?.uuid;
if (typeof clientUuid !== 'string') {
console.error('Failed to get OPNsense client UUID for deletion', client);
return err(new Error('Failed to get OPNsense client UUID for deletion'));
}
const res = await fetch(`${this.opnsenseUrl}/api/wireguard/client/delClient/${clientUuid}`, {
method: 'POST',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
},
});
const resJson = await res.json();
if (resJson['result'] !== 'deleted') {
console.error('Failed to delete OPNsense client', resJson);
return err(new Error('Failed to delete OPNsense client'));
}
return ok(null);
}
private async findOpnsenseClient(publicKey: string) {
const res = await fetch(`${this.opnsenseUrl}/api/wireguard/client/searchClient`, {
method: 'POST',
headers: {
Authorization: this.opnsenseAuth,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
current: 1,
sort: {},
searchPhrase: publicKey,
type: ['peer'],
}),
});
return (await res.json())?.rows?.[0] ?? null;
}
}
function opnsenseSanitezedUsername(username: string) {
return username.slice(0, 63).replace(/[^a-zA-Z0-9_-]/g, '_');
}
export type OpnsenseParams = {
opnsenseUrl: string;
opnsenseApiKey: string;
opnsenseApiSecret: string;
opnsenseWgIfname: string;
};

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert';
import { appendFile } from 'node:fs/promises';
import type { ClientConnection, CreateClientParams, IWgProvider, WgKeys } from '$lib/server/types';
import { err, ok, type Result } from '$lib/types';
import {
type IWgQuickInterfaceConfig,
wgGenPrivKey,
wgGenPsk,
wgGenPubKey,
wgPeerConfig,
wgQuickInterfaceConfig, wgQuickUp
} from './snippets';
import type { User } from '$lib/server/db/schema';
export class WgProviderWgQuick implements IWgProvider {
private filename: string;
private publicKey?: string;
private config: IWgQuickInterfaceConfig;
constructor(params: WgQuickParams) {
this.filename = params.filename;
this.config = {
address: params.address,
privateKey: params.privateKey,
listenPort: params.listenPort,
};
}
async init(): Promise<Result<null, Error>> {
const file = Bun.file(this.filename);
this.publicKey = await wgGenPubKey(this.config.privateKey);
console.log(`wg-quick: running with public key: ${this.publicKey}`);
if (await file.exists()) {
// TODO: Check if the file is a valid WireGuard config file and our settings match
return ok(null);
}
await Bun.write(this.filename, wgQuickInterfaceConfig(this.config) + '\n');
console.log('created wg-quick config file', this.filename);
return wgQuickUp(this.filename);
}
getServerPublicKey(): string {
assert(this.publicKey, 'WgQuick public key not set, init() must be called first');
return this.publicKey;
}
async generateKeys(): Promise<Result<WgKeys, Error>> {
const privateKey = await wgGenPrivKey();
const publicKey = await wgGenPubKey(privateKey);
const preSharedKey = await wgGenPsk();
return ok({
publicKey,
privateKey,
preSharedKey,
});
}
async createClient(params: CreateClientParams): Promise<Result<null, Error>> {
const peerConfig = wgPeerConfig(params);
await appendFile(this.filename, peerConfig + `\n`);
return ok(null);
}
async findConnections(user: User): Promise<Result<ClientConnection[], Error>> {
return err(Error('WgProviderWgQuick: listing connection information is not yet supported'));
}
async deleteClient(publicKey: string): Promise<Result<null, Error>> {
return err(Error('WgProviderWgQuick: deleting client is not yet supported'));
}
}
interface WgQuickParams extends IWgQuickInterfaceConfig {
filename: string;
}

View File

@@ -0,0 +1,59 @@
import { $ } from 'bun';
import type { CreateClientParams } from '$lib/server/types';
import { err, ok, type Result } from '$lib/types';
export type IWgQuickInterfaceConfig = {
address: string;
privateKey: string;
listenPort: number;
}
export function wgQuickInterfaceConfig(params: IWgQuickInterfaceConfig): string {
return`\
[Interface]
Address = ${params.address}
PrivateKey = ${params.privateKey}
ListenPort = ${params.listenPort}
`;
}
export function wgPeerConfig(params: CreateClientParams): string {
return`\
[Peer]
PublicKey = ${params.publicKey}
PresharedKey = ${params.preSharedKey}
AllowedIPs = ${params.allowedIps}
# vpgen-user = ${params.user.username}
`;
}
export async function runCommand(command: string): Promise<Result<null, Error>> {
const result = await $`${command}`;
if (result.exitCode !== 0) return err(Error(`'${command}' failed with exit code ${result.exitCode}\n${result.stderr.toString()}`));
return ok(null);
}
export async function wgQuickUp(ifname: string): Promise<Result<null, Error>> {
return runCommand(`wg-quick up ${ifname}`);
}
export async function wgQuickDown(ifname: string): Promise<Result<null, Error>> {
return runCommand(`wg-quick down ${ifname}`);
}
export async function wgReload(ifname: string): Promise<Result<null, Error>> {
return runCommand(`wg-quick strip ${ifname} | wg syncconf ${ifname} /dev/stdin`);
// return runCommand(`wg syncconf ${ifname} <(wg-quick strip ${ifname})`);
}
export async function wgGenPubKey(privateKey: string) {
return (await $`echo ${privateKey} | wg pubkey`.text()).trim();
}
export async function wgGenPrivKey() {
return (await $`wg genkey`.text()).trim();
}
export async function wgGenPsk() {
return (await $`wg genpsk`.text()).trim();
}

View File

@@ -1,27 +1,2 @@
class Ok<T> { export type { Result } from './result';
readonly _tag = 'ok'; export { ok, err } from './result';
value: T;
constructor(value: T) {
this.value = value;
}
}
class Err<E> {
readonly _tag = 'err';
error: E;
constructor(error: E) {
this.error = error;
}
}
export type Result<T, E> = Ok<T> | Err<E>;
export function err<E>(e: E): Err<E> {
return new Err(e);
}
export function ok<T>(t: T): Ok<T> {
return new Ok(t);
}

27
src/lib/types/result.ts Normal file
View File

@@ -0,0 +1,27 @@
class Ok<T> {
readonly _tag = 'ok';
value: T;
constructor(value: T) {
this.value = value;
}
}
class Err<E> {
readonly _tag = 'err';
error: E;
constructor(error: E) {
this.error = error;
}
}
export type Result<T, E> = Ok<T> | Err<E>;
export function err<E>(e: E): Err<E> {
return new Err(e);
}
export function ok<T>(t: T): Ok<T> {
return new Ok(t);
}

View File

@@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function envToBool(value: string | undefined, defaultValue = false): boolean {
if (typeof value === "undefined") {
return defaultValue;
}
return ['true', '1', 'yes'].includes(value.toLowerCase());
}

View File

@@ -1,44 +1,43 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { opnsenseAuth, opnsenseUrl } from '$lib/server/opnsense';
import type { OpnsenseWgPeers } from '$lib/opnsense/wg';
import { findDevices } from '$lib/server/devices'; import { findDevices } from '$lib/server/devices';
import type { ConnectionDetails } from '$lib/connections'; import type { ConnectionDetails } from '$lib/connections';
import { opnsenseSanitezedUsername } from '$lib/opnsense'; import type { Result } from '$lib/types';
import type { ClientConnection } from '$lib/server/types';
import wgProvider from '$lib/server/wg-provider';
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');
} }
console.debug('/api/connections'); console.debug('/api/connections');
const peers = await fetchOpnsensePeers(event.locals.user.username);
console.debug('/api/connections: fetched opnsense peers', peers.rowCount); const peersResult: Result<ClientConnection[], Error> = await wgProvider.findConnections(event.locals.user);
if (peersResult._tag === 'err') return error(500, peersResult.error.message);
const devices = await findDevices(event.locals.user.id); const devices = await findDevices(event.locals.user.id);
console.debug('/api/connections: fetched db devices'); console.debug('/api/connections: fetched db devices');
if (!peers) {
return error(500, 'Error getting info from OPNsense API');
}
// TODO: this is all garbage performance // TODO: this is all garbage performance
// filter devices with no recent handshakes // filter devices with no recent handshakes
peers.rows = peers.rows.filter((peer) => peer['latest-handshake']); const peers = peersResult.value.filter((peer) => peer.latestHandshake);
// start from devices, to treat db as the source of truth // start from devices, to treat db as the source of truth
const connections: ConnectionDetails[] = []; const connections: ConnectionDetails[] = [];
for (const device of devices) { for (const device of devices) {
const peerData = peers.rows.find((peer) => peer['public-key'] === device.publicKey); const peerData = peers.find((peer) => peer.publicKey === device.publicKey);
if (!peerData) continue; if (!peerData) continue;
connections.push({ connections.push({
deviceId: device.id, deviceId: device.id,
deviceName: device.name, deviceName: device.name,
devicePublicKey: device.publicKey, devicePublicKey: device.publicKey,
deviceIps: peerData['allowed-ips'].split(','), deviceIps: peerData.allowedIps.split(','),
endpoint: peerData['endpoint'], endpoint: peerData.endpoint,
// swap rx and tx, since the opnsense values are from the server perspective // swap rx and tx, since the opnsense values are from the server perspective
transferRx: peerData['transfer-tx'], transferRx: peerData.transferTx,
transferTx: peerData['transfer-rx'], transferTx: peerData.transferRx,
latestHandshake: peerData['latest-handshake'] * 1000, latestHandshake: peerData.latestHandshake,
}); });
} }
@@ -49,25 +48,3 @@ export const GET: RequestHandler = async (event) => {
}, },
}); });
}; };
async function fetchOpnsensePeers(username: string) {
const res = await fetch(`${opnsenseUrl}/api/wireguard/service/show`, {
method: 'POST',
headers: {
Authorization: opnsenseAuth,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
current: 1,
// "rowCount": 7,
sort: {},
// TODO: use a more unique search phrase
// unfortunately 64 character limit,
// but it should be fine if users can't change their own username
searchPhrase: `vpgen-${opnsenseSanitezedUsername(username)}`,
type: ['peer'],
}),
});
return (await res.json()) as OpnsenseWgPeers;
}

View File

@@ -1,14 +1,14 @@
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from '@sveltejs/kit';
import { invalidateSession, deleteSessionTokenCookie } from "$lib/server/auth"; import { invalidateSession, deleteSessionTokenCookie } from '$lib/server/auth';
import type { Actions } from "./$types"; import type { Actions } from './$types';
export const actions: Actions = { export const actions: Actions = {
logout: async (event) => { logout: async ({ locals, cookies }) => {
if (event.locals.session === null) { if (locals.session === null) {
return fail(401); return fail(401);
} }
await invalidateSession(event.locals.session.id); await invalidateSession(locals.session.id);
deleteSessionTokenCookie(event); deleteSessionTokenCookie(cookies);
return redirect(302, "/"); redirect(302, '/');
} },
}; };

View File

@@ -0,0 +1,36 @@
import { generateCodeVerifier, generateState } from 'arctic';
import { oauthProviders } from '$lib/server/oauth';
import { is } from 'typia';
import { type AuthProvider, enabledAuthProviders } from '$lib/auth';
export async function GET({ params: { provider }, url, cookies }) {
if (!is<AuthProvider>(provider) || !enabledAuthProviders[provider]) {
return new Response(null, { status: 404 });
}
const oauthProvider = oauthProviders[provider];
const inviteToken = url.searchParams.get('invite') ?? '';
const state = generateState();
const codeVerifier = generateCodeVerifier();
const authUrl = oauthProvider.createAuthorizationURL(state + inviteToken, codeVerifier);
cookies.set(`${provider}_oauth_state`, state, {
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
cookies.set(`${provider}_code_verifier`, codeVerifier, {
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
return new Response(null, {
status: 302,
headers: {
Location: authUrl.toString(),
},
});
}

View File

@@ -0,0 +1,76 @@
import { is } from 'typia';
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { type AuthProvider, enabledAuthProviders } from '$lib/auth';
import { oauthProviders } from '$lib/server/oauth';
import { ArcticFetchError, OAuth2RequestError } from 'arctic';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { createSession, isValidInviteToken, setSessionTokenCookie } from '$lib/server/auth';
export const load: PageServerLoad = async ({ params: { provider }, url, cookies }) => {
if (!is<AuthProvider>(provider) || !enabledAuthProviders[provider]) {
error(404);
}
const oauthProvider = oauthProviders[provider];
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies.get(`${provider}_oauth_state`) ?? null;
const codeVerifier = cookies.get(`${provider}_code_verifier`) ?? null;
if (code === null || state === null || storedState === null || codeVerifier === null) {
error(400, 'Missing url parameters');
}
const stateGeneratedToken = state.slice(0, storedState.length);
const stateInviteToken = state.slice(storedState.length);
if (stateGeneratedToken !== storedState) {
error(400, 'Invalid state in url');
}
let claims;
try {
claims = await oauthProvider.validateAuthorizationCode(code, codeVerifier);
} catch (e) {
if (e instanceof OAuth2RequestError) {
console.debug('Arctic: OAuth: invalid authorization code, credentials, or redirect URI', e);
error(400, 'Invalid authorization code');
}
if (e instanceof ArcticFetchError) {
console.debug('Arctic: failed to call `fetch()`', e);
error(400, 'Failed to validate authorization code');
}
console.error('Unexpected error validating authorization code', code, e);
error(500);
}
const existingUser = await db.query.users.findFirst({ where: eq(table.users.id, claims.sub) });
if (existingUser) {
const session = await createSession(existingUser.id);
setSessionTokenCookie(cookies, session.id, session.expiresAt);
redirect(302, '/');
}
if (oauthProvider.requireInvite && !isValidInviteToken(stateInviteToken)) {
const message =
stateInviteToken.length === 0 ? 'sign up with an invite link first' : 'invalid invite link';
error(403, 'Not Authorized: ' + message);
}
const user: table.User = {
id: claims.sub,
authSource: provider,
username: claims.username,
name: claims.name,
};
await db.insert(table.users).values(user);
console.log('created user', user, 'using provider', provider, 'with invite token', stateInviteToken);
const session = await createSession(user.id);
setSessionTokenCookie(cookies, session.id, session.expiresAt);
redirect(302, '/');
};

View File

@@ -1,30 +0,0 @@
import { generateState, generateCodeVerifier } from "arctic";
import { authentik } from "$lib/server/oauth";
import type { RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = authentik.createAuthorizationURL(state, codeVerifier, ["openid", "profile"]);
event.cookies.set("authentik_oauth_state", state, {
path: "/",
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: "lax"
});
event.cookies.set("authentik_code_verifier", codeVerifier, {
path: "/",
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: "lax"
});
return new Response(null, {
status: 302,
headers: {
Location: url.toString()
}
});
}

View File

@@ -1,78 +0,0 @@
import { createSession, setSessionTokenCookie } from "$lib/server/auth";
import { authentik } from "$lib/server/oauth";
import { decodeIdToken } from "arctic";
import type { RequestEvent } from "@sveltejs/kit";
import type { OAuth2Tokens } from "arctic";
import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm';
import * as table from '$lib/server/db/schema';
export async function GET(event: RequestEvent): Promise<Response> {
const code = event.url.searchParams.get("code");
const state = event.url.searchParams.get("state");
const storedState = event.cookies.get("authentik_oauth_state") ?? null;
const codeVerifier = event.cookies.get("authentik_code_verifier") ?? null;
if (code === null || state === null || storedState === null || codeVerifier === null) {
return new Response(null, {
status: 400
});
}
if (state !== storedState) {
return new Response(null, {
status: 400
});
}
let tokens: OAuth2Tokens;
try {
tokens = await authentik.validateAuthorizationCode(code, codeVerifier);
} catch (e) {
// Invalid code or client credentials
return new Response(null, {
status: 400
});
}
const claims = decodeIdToken(tokens.idToken()) as { sub: string, preferred_username: string, name: string };
console.log("claims", claims);
const userId: string = claims.sub;
const username: string = claims.preferred_username;
const existingUser = await db.query.users.findFirst({where: eq(table.users.id, userId)});
if (existingUser) {
const session = await createSession(existingUser.id);
setSessionTokenCookie(event, session.id, session.expiresAt);
return new Response(null, {
status: 302,
headers: {
Location: "/"
}
});
}
const user: table.User = {
id: userId,
username,
name: claims.name as string,
};
try {
await db.insert(table.users).values(user);
const session = await createSession(user.id);
setSessionTokenCookie(event, session.id, session.expiresAt);
} catch (e) {
console.error('failed to create user', e);
return new Response(null, {
status: 500
});
}
return new Response(null, {
status: 302,
headers: {
Location: "/"
}
});
}

View File

@@ -1,6 +1,7 @@
import type { Actions } from './$types'; import type { Actions } from './$types';
import { createDevice } from '$lib/server/devices'; import { createDevice } from '$lib/server/devices';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import wgProvider from '$lib/server/wg-provider';
export const actions = { export const actions = {
create: async (event) => { create: async (event) => {

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 { LucideLoaderCircle, 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';

View File

@@ -1,7 +1,7 @@
<script> <script>
import { Button, buttonVariants } from '$lib/components/ui/button'; import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { LucideLoaderCircle, LucideTrash } from 'lucide-svelte'; import { LucideLoaderCircle, LucideTrash } from '@lucide/svelte';
const { device } = $props(); const { device } = $props();
let submitted = $state(false); let submitted = $state(false);
@@ -26,10 +26,12 @@
<Button type="submit" variant="destructive" disabled={submitted}> <Button type="submit" variant="destructive" disabled={submitted}>
Delete Delete
</Button> </Button>
<Dialog.Close asChild let:builder> <Dialog.Close>
<button class={buttonVariants()} disabled={submitted} use:builder.action {...builder}> {#snippet child({ props })}
<Button {...props} disabled={submitted}>
Cancel Cancel
</button> </Button>
{/snippet}
</Dialog.Close> </Dialog.Close>
</Dialog.Footer> </Dialog.Footer>
</form> </form>

View File

@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { isValidInviteToken } from '$lib/server/auth';
export const load: LayoutServerLoad = ({ params, locals }) => {
if (!isValidInviteToken(params.id)) redirect(302, '/');
if (locals.user !== null) redirect(302, '/');
};

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { AuthForm } from '$lib/components/app/auth-form';
import { page } from '$app/state';
let inviteToken = page.params.id;
</script>
<svelte:head>
<title>You are Invited to VPGen</title>
</svelte:head>
<h1 class="mb-2 scroll-m-20 text-center text-3xl font-extrabold tracking-tight lg:text-4xl">
You are invited to VPGen
</h1>
<AuthForm {inviteToken} />

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invalidate, invalidateAll } from '$app/navigation'; import { invalidate, invalidateAll } from '$app/navigation';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { LucideLoaderCircle, LucideLogOut, LucideRefreshCw } from 'lucide-svelte'; import { LucideLoaderCircle, LucideLogOut, LucideRefreshCw } from '@lucide/svelte';
import { CodeSnippet } from '$lib/components/app/code-snippet/index.js'; import { CodeSnippet } from '$lib/components/app/code-snippet/index.js';
let { data } = $props(); let { data } = $props();

View File

@@ -9,7 +9,13 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
"plugins": [
{
"transform": "typia/lib/transform"
}
],
"strictNullChecks": true
} }
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files

View File

@@ -1,6 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import UnpluginTypia from '@ryoppippi/unplugin-typia/vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit(), UnpluginTypia()],
}); });