2 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
11 changed files with 223 additions and 51 deletions

View File

@@ -13,11 +13,18 @@ AUTH_GOOGLE_CLIENT_SECRET=
AUTH_INVITE_TOKEN=GUjdsz9aREFTEBYDrA3AajUE8oVys2xW 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

View File

@@ -21,6 +21,7 @@
"@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.13", "@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",
@@ -281,6 +282,8 @@
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
@@ -363,6 +366,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],

View File

@@ -4,9 +4,9 @@
"license": "AGPL-3.0-or-later", "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 .",
@@ -31,6 +31,7 @@
"@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.13", "@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",

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,54 +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 googleIcon from '$lib/assets/google.svg';
import { enabledAuthProviders } from '$lib/auth'; import { enabledAuthProviders } from '$lib/auth';
import AuthButton from './auth-button.svelte';
let { inviteToken, class: className, ...rest }: { interface Props {
inviteToken?: string; inviteToken?: string;
class?: string; class?: string;
rest?: { [p: string]: unknown } }
} = $props(); let { inviteToken, class: className }: Props = $props();
let submitted = $state(false);
</script> </script>
<div class={cn('flex gap-6', className)} {...rest}> <div class={cn('flex gap-6', className)}>
{#if enabledAuthProviders.authentik } {#if enabledAuthProviders.authentik}
<form method="get" onsubmit={() => submitted = true} <AuthButton
action="/auth/authentik{inviteToken ? `?invite=${inviteToken}` : ''}"> providerName="authentik"
<input type="hidden" value={inviteToken} name="invite" /> displayName="Authentik"
<Button type="submit" disabled={submitted}> iconSrc="https://auth.cazzzer.com/static/dist/assets/icons/icon.svg"
{#if submitted} {inviteToken}
<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}
Sign in with Authentik
</Button>
</form>
{/if} {/if}
{#if enabledAuthProviders.google } {#if enabledAuthProviders.google}
<form method="get" onsubmit={() => submitted = true} <AuthButton providerName="google" displayName="Google" iconSrc={googleIcon} {inviteToken} />
action="/auth/google{inviteToken ? `?invite=${inviteToken}` : ''}">
<input type="hidden" value={inviteToken} name="invite" />
<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="Google Logo"
src={googleIcon}
/>
{/if}
Sign in with Google
</Button>
</form>
{/if} {/if}
</div> </div>

View File

@@ -1,12 +1,32 @@
import { WgProviderOpnsense } from '$lib/server/wg-providers/opnsense'; import { assertGuard } from 'typia';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import type { IWgProvider } from '$lib/server/types'; 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 wgProvider: IWgProvider = new WgProviderOpnsense({ const opnsense: IWgProvider = new WgProviderOpnsense({
opnsenseUrl: env.OPNSENSE_API_URL, opnsenseUrl: env.OPNSENSE_API_URL,
opnsenseApiKey: env.OPNSENSE_API_KEY, opnsenseApiKey: env.OPNSENSE_API_KEY,
opnsenseApiSecret: env.OPNSENSE_API_SECRET, opnsenseApiSecret: env.OPNSENSE_API_SECRET,
opnsenseWgIfname: env.OPNSENSE_WG_IFNAME, 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; export default wgProvider;

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

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

View File

@@ -10,7 +10,7 @@
</svelte:head> </svelte:head>
<h1 class="mb-2 scroll-m-20 text-center text-3xl font-extrabold tracking-tight lg:text-4xl"> <h1 class="mb-2 scroll-m-20 text-center text-3xl font-extrabold tracking-tight lg:text-4xl">
Welcome to VPGen You are invited to VPGen
</h1> </h1>
<AuthForm inviteToken={inviteToken} /> <AuthForm {inviteToken} />