This commit is contained in:
Shahrad Elahi
2024-05-29 20:10:18 +03:30
committed by GitHub
parent 66a1fe2ece
commit efb93e5e31
162 changed files with 2780 additions and 2758 deletions

View File

@@ -1,5 +0,0 @@
{
"$schema": "https://json.schemastore.org/mocharc.json",
"require": ["tsx", "chai/register-expect", "mocha.setup.js"],
"timeout": 10000
}

View File

@@ -12,4 +12,4 @@ static
pnpm-lock.yaml
package-lock.json
yarn.lock
tsconfig.json
tsconfig.json

View File

@@ -1,17 +0,0 @@
{
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte",
"plugins": ["prettier-plugin-svelte"]
}
}
]
}

View File

@@ -2,7 +2,7 @@
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "gray"
},

View File

@@ -1 +0,0 @@
process.env.NODE_ENV = 'test';

View File

@@ -1,17 +1,13 @@
{
"name": "web",
"version": "0.0.0-dev",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "mocha",
"check:format": "prettier --check .",
"format": "prettier --write .",
"start": "node ./build/index.js"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"packageManager": "pnpm@8.15.0",
"engines": {
@@ -19,50 +15,45 @@
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.5",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@types/chai": "^4.3.14",
"@sveltejs/kit": "^2.5.10",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/mocha": "^10.0.6",
"@types/node": "^20.12.2",
"@types/node": "^20.12.12",
"@types/qrcode": "^1.5.5",
"autoprefixer": "^10.4.19",
"chai": "^5.1.0",
"mocha": "^10.4.0",
"postcss": "^8.4.38",
"postcss-load-config": "^5.0.3",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"svelte": "^4.2.12",
"svelte-check": "^3.6.8",
"svelte-preprocess": "^5.1.3",
"sveltekit-superforms": "^2.12.2",
"postcss-load-config": "^5.1.0",
"svelte": "^4.2.17",
"svelte-check": "^3.7.1",
"svelte-preprocess": "^5.1.4",
"sveltekit-superforms": "^2.13.1",
"tailwindcss": "^3.4.3",
"tslib": "^2.6.2",
"tsx": "^4.7.1",
"typescript": "^5.4.3",
"vite": "^5.2.7",
"zod": "^3.22.4"
"tsx": "^4.10.5",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"zod": "^3.23.8"
},
"dependencies": {
"@litehex/storage-box": "^0.2.2-canary.0",
"@t3-oss/env-core": "0.7.3",
"bits-ui": "^0.18.0",
"clsx": "^2.1.0",
"bits-ui": "^0.21.9",
"clsx": "^2.1.1",
"deepmerge": "^4.3.1",
"dotenv": "^16.4.4",
"execa": "^8.0.1",
"dotenv": "^16.4.5",
"execa": "^9.1.0",
"formsnap": "^1.0.0",
"jsonwebtoken": "^9.0.2",
"lucide-svelte": "^0.330.0",
"lucide-svelte": "^0.379.0",
"mode-watcher": "^0.3.0",
"node-netkit": "0.1.0-canary.2",
"pino": "^8.18.0",
"pino-pretty": "^10.3.1",
"node-netkit": "0.1.0-canary.3",
"p-safe": "^1.0.0",
"pino": "^9.1.0",
"pino-pretty": "^11.0.0",
"pretty-bytes": "^6.1.1",
"qrcode": "^1.5.3",
"storage-box": "^1.0.0-canary.4",
"svelte-french-toast": "^1.2.0",
"tailwind-merge": "^2.2.1",
"tailwind-variants": "^0.2.0"
"tailwind-merge": "^2.3.0",
"tailwind-variants": "^0.2.1"
}
}

1129
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,9 @@
}
body {
@apply bg-background text-foreground;
font-feature-settings:
'rlig' 1,
'calt' 1;
}
}

View File

@@ -3,7 +3,6 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="stylesheet" href="%sveltekit.assets%/fontawesome/all.min.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>

View File

@@ -1,25 +1,25 @@
import { type Handle, redirect } from '@sveltejs/kit';
import { verifyToken } from '$lib/auth';
import { AUTH_COOKIE } from '$lib/constants';
import 'dotenv/config';
import { redirect, type Handle } from '@sveltejs/kit';
import { verifyToken } from '$lib/auth';
import { AUTH_COOKIE } from '$lib/constants';
import logger from '$lib/logger';
export const handle: Handle = async ({ event, resolve }) => {
if (!AUTH_EXCEPTION.includes(event.url.pathname)) {
const token = event.cookies.get(AUTH_COOKIE);
const token_valid = await verifyToken(token ?? '');
logger.debug(`-> ${event.request.method} ${event.url.pathname}`);
const is_login_page = event.url.pathname === '/login';
if (!token_valid && !is_login_page) {
// return redirect;
throw redirect(303, '/login');
}
const token = event.cookies.get(AUTH_COOKIE);
const token_valid = await verifyToken(token ?? '');
if (token_valid && is_login_page) {
throw redirect(303, '/');
}
const is_login_page = event.url.pathname === '/login';
if (!token_valid && !is_login_page) {
throw redirect(303, '/login');
}
if (token_valid && is_login_page) {
throw redirect(303, '/');
}
return resolve(event);
};
const AUTH_EXCEPTION = ['/api/health'];

View File

@@ -1,34 +1,42 @@
import jwt from 'jsonwebtoken';
import { client } from '$lib/storage';
import { env } from '$lib/env';
export async function generateToken(): Promise<string> {
import { WG_AUTH_PATH } from '$lib/constants';
import { env } from '$lib/env';
import { storage } from '$lib/storage';
import { sha256 } from '$lib/utils/hash';
interface GenerateTokenParams {
expiresIn: number;
}
export async function generateToken(params: GenerateTokenParams): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
const token = jwt.sign(
{
ok: true,
iat: now,
exp: now + oneHour,
exp: now + params.expiresIn,
},
env.AUTH_SECRET,
env.AUTH_SECRET
);
client.setex(token, '1', oneHour);
await storage.lpushex(WG_AUTH_PATH, sha256(token), params.expiresIn);
return token;
}
export async function verifyToken(token: string): Promise<boolean> {
try {
const decode = jwt.verify(token, env.AUTH_SECRET);
if (!decode) return false;
if (!token || !(await storage.lexists(WG_AUTH_PATH, sha256(token)))) return false;
const exists = client.exists(token);
return exists;
return !!jwt.verify(token, env.AUTH_SECRET);
} catch (e) {
return false;
}
}
export async function revokeToken(token: string): Promise<void> {
client.del(token);
if (!token) return;
const index = await storage
.lgetall(WG_AUTH_PATH)
.then((l) => l.findIndex((t) => t === sha256(token)));
await storage.ldel(WG_AUTH_PATH, index);
}

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { ClipboardCopyIcon } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
export let showInHover: boolean = false;
export let rootClass: string | undefined = undefined;
@@ -12,20 +14,23 @@
};
</script>
<div class={cn('group flex items-center', rootClass)}>
<div class={cn('group flex items-center gap-3', rootClass)}>
<slot />
<i
<Button
aria-roledescription="Copy to clipboard"
role="button"
tabindex="0"
class={cn(
'ml-2 mb-0.5 far fa-copy cursor-pointer text-gray-400/80 hover:text-primary',
showInHover && 'group-hover:opacity-100 opacity-0',
className,
)}
size="none"
variant="ghost"
on:click={handleCopy}
on:keydown={(e) => {
if (e.key === 'Enter') handleCopy();
}}
/>
>
<ClipboardCopyIcon
class={cn(
'h-4 w-4 cursor-pointer text-gray-400/80 hover:text-primary',
showInHover && 'group-hover:opacity-100 opacity-0',
className
)}
/>
</Button>
</div>

View File

@@ -2,6 +2,8 @@
import { cn } from '$lib/utils';
import { createEventDispatcher } from 'svelte';
import type { ZodEffects, ZodString } from 'zod';
import { SquarePenIcon } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
export let editMode: boolean = false;
export let schema: ZodString | ZodEffects<any>;
@@ -40,7 +42,7 @@
editMode ? 'block' : 'hidden',
'w-full ring-2 ring-neutral-800 ring-offset-2 rounded transition-colors duration-200 ease-in-out outline-transparent',
inputClass,
error && 'ring-red-500 rounded',
error && 'ring-red-500 rounded'
)}
{value}
on:keydown={(e) => {
@@ -60,14 +62,16 @@
}}
/>
<i
class="fal fa-pen-to-square text-sm opacity-0 group-hover:opacity-100 text-neutral-400 hover:text-primary cursor-pointer"
role="button"
tabindex="0"
<Button
class="opacity-0 group-hover:opacity-100 text-gray-400/80 group-hover:text-primary"
aria-roledescription="Edit"
size="none"
variant="ghost"
on:click={handleEnterEditMode}
on:keydown={(e) => {
if (e.key === 'Enter') handleEnterEditMode();
}}
/>
>
<SquarePenIcon class="h-4 w-4" />
</Button>
</div>

View File

@@ -1,7 +1,8 @@
import Root from './empty.svelte';
import type { HTMLAttributes } from 'svelte/elements';
import Description from './empty-description.svelte';
import SimpleImage from './empty-simple-img.svelte';
import type { HTMLAttributes } from 'svelte/elements';
import Root from './empty.svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
description?: string | null;

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import Icon from '$lib/components/iconset/icon.svelte';
import type { Props } from '$lib/components/iconset';
type $$Props = Props;
</script>
<Icon {...$$restProps} viewBox="0 0 56 30" fill="currentColor">
<path
d="M47.084 5.32128C42.17 2.30228 35.377 0.436279 27.878 0.436279C20.378 0.436279 13.588 2.30228 8.67402 5.32128C3.75902 8.33728 0.718018 12.5053 0.718018 17.1103C0.718018 21.7143 3.75902 25.8823 8.67402 28.8983C13.588 31.9183 20.378 24.6273 27.878 24.6273C35.377 24.6273 42.17 31.9183 47.084 28.8983C51.998 25.8823 55.039 21.7133 55.039 17.1103C55.039 12.5043 51.998 8.33728 47.084 5.32128ZM17.083 17.4883C14.385 17.4883 12.198 16.5663 12.198 15.4273C12.198 14.2873 14.385 13.3643 17.083 13.3643C19.78 13.3643 21.968 14.2883 21.968 15.4273C21.968 16.5663 19.78 17.4883 17.083 17.4883ZM38.584 17.6793C35.898 17.6793 33.723 16.8233 33.723 15.7673C33.723 14.7133 35.899 13.8563 38.584 13.8563C41.269 13.8563 43.447 14.7133 43.447 15.7673C43.447 16.8233 41.269 17.6793 38.584 17.6793Z"
fill="#6700AD"
aria-label="Dnsmasq"
/>
</Icon>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
export let name: string | undefined = undefined;
export let color = 'currentColor';
export let size: number | string = 24;
export let strokeWidth: number | string = 2;
export let absoluteStrokeWidth: boolean = false;
</script>
<svg
{...$$restProps}
width={size}
height={size}
stroke={color}
stroke-width={absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth}
class={cn('lucide-icon', 'lucide', name ? `lucide-${name}` : '', $$props.class)}
>
<slot />
</svg>

View File

@@ -0,0 +1,24 @@
import type { SVGAttributes } from 'svelte/elements';
import Dnsmasq from './dnsmasq-icon.svelte';
import Root from './icon.svelte';
import Onion from './onion-icon.svelte';
interface Props extends SVGAttributes<SVGSVGElement> {
color?: string;
size?: number | string;
strokeWidth?: number | string;
absoluteStrokeWidth?: boolean;
class?: string;
}
export {
Root,
Dnsmasq,
Onion,
type Props,
//
Root as Icon,
Dnsmasq as DnsmasqIcon,
Onion as OnionIcon,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import Icon from '$lib/components/iconset/icon.svelte';
import type { Props } from '$lib/components/iconset';
type $$Props = Props;
</script>
<Icon {...$$restProps} viewBox="0 0 512 512" fill="currentColor">
<path
d="M256 502C391.862 502 502 391.862 502 256C502 120.138 391.862 10 256 10C120.138 10 10 120.138 10 256C10 391.862 120.138 502 256 502Z"
fill="#F2E4FF"
fill-opacity="0.5"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M256.525 465.44V434.407C354.826 434.123 434.421 354.365 434.421 255.993C434.421 157.628 354.826 77.8702 256.525 77.5863V46.5532C371.964 46.8442 465.447 140.49 465.447 255.993C465.447 371.503 371.964 465.156 256.525 465.44ZM256.525 356.82C311.97 356.529 356.849 311.516 356.849 255.993C356.849 200.477 311.97 155.464 256.525 155.173V124.147C329.115 124.43 387.882 183.339 387.882 255.993C387.882 328.654 329.115 387.562 256.525 387.846V356.82ZM256.525 201.719C286.267 202.003 310.303 226.18 310.303 255.993C310.303 285.812 286.267 309.99 256.525 310.274V201.719ZM0 255.993C0 397.384 114.609 512 256 512C397.384 512 512 397.384 512 255.993C512 114.609 397.384 0 256 0C114.609 0 0 114.609 0 255.993Z"
fill="#7D4698"
/>
</Icon>

View File

@@ -12,7 +12,7 @@
</a>
<DotDivider className="font-bold text-gray-400" />
<a
href={'https://github.com/shahradelahi/wireadmin'}
href={'https://github.com/wireadmin/wireadmin'}
title={'Github'}
class={'px-2 font-medium text-gray-400/80 hover:text-gray-500 text-xs'}
>

View File

@@ -4,7 +4,7 @@
import { toggleMode } from 'mode-watcher';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import { LogOutIcon } from 'lucide-svelte';
export let showLogout: boolean = false;
</script>
@@ -18,12 +18,12 @@
<div class={'flex items-center gap-x-3'}>
<a
href={'https://github.com/shahradelahi/wireadmin'}
href={'https://github.com/wireadmin/wireadmin'}
title={'Giv me a star on Github'}
class="hidden md:block"
>
<img
src={'https://img.shields.io/github/stars/shahradelahi/wireadmin.svg?style=social&label=Star'}
src={'https://img.shields.io/github/stars/wireadmin/wireadmin.svg?style=social&label=Star'}
alt={'Gimme a Star'}
/>
</a>
@@ -41,17 +41,8 @@
{#if showLogout}
<a href="/logout" rel="external" title="Logout">
<Button variant="ghost" class="group text-sm/2 gap-x-2 font-medium">
<i
class={cn(
'far fa-arrow-right-from-arc text-sm mr-0.5',
'text-neutral-500 group-hover:text-neutral-800',
'dark:text-neutral-400 dark:group-hover:text-neutral-100',
)}
></i>
<span
class="text-neutral-700 hover:text-neutral-800 dark:text-neutral-100 dark:hover:text-neutral-100"
>Logout</span
>
<LogOutIcon class={'w-4 h-4 mr-0.5'} />
Logout
</Button>
</a>
{/if}

View File

@@ -1,4 +1,5 @@
import { tv, type VariantProps } from 'tailwind-variants';
export { default as Badge } from './badge.svelte';
export const badgeVariants = tv({

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement> & {
el?: HTMLSpanElement;
};
export let el: $$Props['el'] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<span
bind:this={el}
role="presentation"
aria-hidden="true"
class={cn('flex h-9 w-9 items-center justify-center', className)}
{...$$restProps}
>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">More</span>
</span>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLLiAttributes & {
el?: HTMLLIElement;
};
export let el: $$Props['el'] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<li bind:this={el} class={cn('inline-flex items-center gap-1.5', className)}>
<slot />
</li>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAnchorAttributes & {
el?: HTMLAnchorElement;
asChild?: boolean;
};
export let href: $$Props['href'] = undefined;
export let el: $$Props['el'] = undefined;
export let asChild: $$Props['asChild'] = false;
let className: $$Props['class'] = undefined;
export { className as class };
let attrs: Record<string, unknown>;
$: attrs = {
class: cn('transition-colors hover:text-foreground', className),
href,
...$$restProps,
};
</script>
{#if asChild}
<slot {attrs} />
{:else}
<a bind:this={el} {...attrs} {href}>
<slot {attrs} />
</a>
{/if}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLOlAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLOlAttributes & {
el?: HTMLOListElement;
};
export let el: $$Props['el'] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<ol
bind:this={el}
class={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className
)}
{...$$restProps}
>
<slot />
</ol>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement> & {
el?: HTMLSpanElement;
};
export let el: $$Props['el'] = undefined;
export let className: $$Props['class'] = undefined;
export { className as class };
</script>
<span
bind:this={el}
role="link"
aria-disabled="true"
aria-current="page"
class={cn('font-normal text-foreground', className)}
{...$$restProps}
>
<slot />
</span>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
type $$Props = HTMLLiAttributes & {
el?: HTMLLIElement;
};
export let el: $$Props['el'] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<li
role="presentation"
aria-hidden="true"
class={cn('[&>svg]:size-3.5', className)}
bind:this={el}
{...$$restProps}
>
<slot>
<ChevronRight />
</slot>
</li>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
type $$Props = HTMLAttributes<HTMLElement> & {
el?: HTMLElement;
};
export let el: $$Props['el'] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<nav class={className} bind:this={el} aria-label="breadcrumb" {...$$restProps}>
<slot />
</nav>

View File

@@ -0,0 +1,25 @@
import Ellipsis from './breadcrumb-ellipsis.svelte';
import Item from './breadcrumb-item.svelte';
import Link from './breadcrumb-link.svelte';
import List from './breadcrumb-list.svelte';
import Page from './breadcrumb-page.svelte';
import Separator from './breadcrumb-separator.svelte';
import Root from './breadcrumb.svelte';
export {
Root,
Ellipsis,
Item,
Separator,
Link,
List,
Page,
//
Root as Breadcrumb,
Ellipsis as BreadcrumbEllipsis,
Item as BreadcrumbItem,
Separator as BreadcrumbSeparator,
Link as BreadcrumbLink,
List as BreadcrumbList,
Page as BreadcrumbPage,
};

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Button as ButtonPrimitive } from 'bits-ui';
import { type Events, type Props, buttonVariants } from './index';
import { cn } from '$lib/utils';
import { type Events, type Props, buttonVariants } from '.';
type $$Props = Props;
type $$Events = Events;

View File

@@ -1,5 +1,6 @@
import { type VariantProps, tv } from 'tailwind-variants';
import type { Button as ButtonPrimitive } from 'bits-ui';
import { tv, type VariantProps } from 'tailwind-variants';
import Root from './button.svelte';
const buttonVariants = tv({
@@ -8,7 +9,6 @@ const buttonVariants = tv({
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
success: 'bg-green-500 text-white hover:bg-green-500/90 hover:text-gray-50',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
@@ -19,6 +19,7 @@ const buttonVariants = tv({
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
none: '',
},
},
defaultVariants: {

View File

@@ -1,9 +1,9 @@
import Root from './card.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Root from './card.svelte';
export {
Root,

View File

@@ -15,7 +15,7 @@
<CheckboxPrimitive.Root
class={cn(
'peer box-content h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[disabled=true]:opacity-50',
className,
className
)}
bind:checked
{...$$restProps}

View File

@@ -1,4 +1,5 @@
import Root from './checkbox.svelte';
export {
Root,
//

View File

@@ -1,4 +1,5 @@
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
import Content from './collapsible-content.svelte';
const Root = CollapsiblePrimitive.Root;

View File

@@ -21,7 +21,7 @@
{transitionConfig}
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
className,
className
)}
{...$$restProps}
>

View File

@@ -1,17 +1,17 @@
import { Dialog as DialogPrimitive } from 'bits-ui';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Portal from './dialog-portal.svelte';
import Title from './dialog-title.svelte';
const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger;
const Close = DialogPrimitive.Close;
import Title from './dialog-title.svelte';
import Portal from './dialog-portal.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
export {
Root,
Title,

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import { getFormField } from 'formsnap';
import type { Checkbox as CheckboxPrimitive } from 'bits-ui';
import { Checkbox } from '$lib/components/ui/checkbox';
type $$Props = CheckboxPrimitive.Props;
type $$Events = CheckboxPrimitive.Events;
export let onCheckedChange: $$Props['onCheckedChange'] = undefined;
const { name, setValue, attrStore, value } = getFormField();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { name: nameAttr, value: valueAttr, ...rest } = $attrStore;
</script>
<Checkbox
{...rest}
checked={typeof $value === 'boolean' ? $value : false}
onCheckedChange={(v) => {
onCheckedChange?.(v);
setValue(v);
}}
{...$$restProps}
on:click
on:keydown
/>
<input hidden {name} value={$value} />

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import * as FormPrimitive from 'formsnap';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = HTMLAttributes<HTMLSpanElement>;
let className: string | undefined | null = undefined;

View File

@@ -1,6 +1,5 @@
<script lang="ts" context="module">
import type { FormPathLeaves, SuperForm } from 'sveltekit-superforms';
type T = Record<string, unknown>;
type U = FormPathLeaves<T>;
</script>
@@ -8,7 +7,7 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
import type { HTMLAttributes } from 'svelte/elements';
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.ElementFieldProps<T, U> & HTMLAttributes<HTMLElement>;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.FieldErrorsProps & {
errorClasses?: string | undefined | null;

View File

@@ -1,6 +1,5 @@
<script lang="ts" context="module">
import type { FormPath, SuperForm } from 'sveltekit-superforms';
type T = Record<string, unknown>;
type U = FormPath<T>;
</script>
@@ -8,7 +7,7 @@
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import type { HTMLAttributes } from 'svelte/elements';
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.FieldProps<T, U> & HTMLAttributes<HTMLElement>;

View File

@@ -1,13 +1,12 @@
<script lang="ts" context="module">
import type { FormPath, SuperForm } from 'sveltekit-superforms';
type T = Record<string, unknown>;
type U = FormPath<T>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.FieldsetProps<T, U>;

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { getFormField } from 'formsnap';
import type { HTMLInputAttributes } from 'svelte/elements';
import { Input, type InputEvents } from '$lib/components/ui/input';
type $$Props = HTMLInputAttributes;
type $$Events = InputEvents;
const { attrStore, value } = getFormField();
</script>
<Input
{...$attrStore}
bind:value={$value}
{...$$restProps}
on:blur
on:change
on:click
on:focus
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:paste
on:input
/>

View File

@@ -1,12 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<div class={cn('space-y-2', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import type { Label as LabelPrimitive } from 'bits-ui';
import { getFormControl } from 'formsnap';
import { cn } from '$lib/utils';
import { Label } from '$lib/components/ui/label';
import { cn } from '$lib/utils.js';
import { Label } from '$lib/components/ui/label/index.js';
type $$Props = LabelPrimitive.Props;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as FormPrimitive from 'formsnap';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = FormPrimitive.LegendProps;

View File

@@ -1,24 +0,0 @@
<script lang="ts">
import { Form as FormPrimitive } from 'formsnap';
import { buttonVariants } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import { ChevronDown } from 'lucide-svelte';
import type { HTMLSelectAttributes } from 'svelte/elements';
type $$Props = HTMLSelectAttributes;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<FormPrimitive.Select
class={cn(
buttonVariants({ variant: 'outline' }),
'appearance-none bg-transparent font-normal',
className,
)}
{...$$restProps}
>
<slot />
</FormPrimitive.Select>
<ChevronDown class="absolute right-3 top-2.5 h-4 w-4 opacity-50" />

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { getFormField } from 'formsnap';
import type { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import * as RadioGroup from '$lib/components/ui/radio-group';
type $$Props = RadioGroupPrimitive.Props;
const { attrStore, setValue, name, value } = getFormField();
export let onValueChange: $$Props['onValueChange'] = undefined;
</script>
<RadioGroup.Root
{...$attrStore}
onValueChange={(v) => {
onValueChange?.(v);
setValue(v);
}}
{...$$restProps}
>
<slot />
<input hidden {name} value={$value} />
</RadioGroup.Root>

View File

@@ -1,17 +0,0 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import type { Select as SelectPrimitive } from 'bits-ui';
import { getFormField } from 'formsnap';
type $$Props = SelectPrimitive.TriggerProps & {
placeholder?: string;
};
type $$Events = SelectPrimitive.TriggerEvents;
const { attrStore } = getFormField();
export let placeholder = '';
</script>
<Select.Trigger {...$$restProps} {...$attrStore} on:click on:keydown>
<Select.Value {placeholder} />
<slot />
</Select.Trigger>

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { getFormField } from 'formsnap';
import type { Select as SelectPrimitive } from 'bits-ui';
type $$Props = SelectPrimitive.Props;
const { setValue, name, value } = getFormField();
export let onSelectedChange: $$Props['onSelectedChange'] = undefined;
</script>
<Select.Root
onSelectedChange={(v) => {
onSelectedChange?.(v);
setValue(v ? v.value : undefined);
}}
{...$$restProps}
>
<slot />
<input hidden {name} value={$value} />
</Select.Root>

View File

@@ -1,25 +0,0 @@
<script lang="ts">
import { getFormField } from 'formsnap';
import type { Switch as SwitchPrimitive } from 'bits-ui';
import { Switch } from '$lib/components/ui/switch';
type $$Props = SwitchPrimitive.Props;
type $$Events = SwitchPrimitive.Events;
export let onCheckedChange: $$Props['onCheckedChange'] = undefined;
const { name, setValue, attrStore, value } = getFormField();
</script>
<Switch
{...$attrStore}
checked={typeof $value === 'boolean' ? $value : false}
onCheckedChange={(v) => {
onCheckedChange?.(v);
setValue(v);
}}
{...$$restProps}
on:click
on:keydown
/>
<input hidden {name} value={$value} />

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import { getFormField } from 'formsnap';
import type { HTMLTextareaAttributes } from 'svelte/elements';
import type { TextareaGetFormField } from './index';
import { Textarea, type TextareaEvents } from '$lib/components/ui/textarea';
type $$Props = HTMLTextareaAttributes;
type $$Events = TextareaEvents;
const { attrStore, value } = getFormField() as TextareaGetFormField;
</script>
<Textarea
{...$attrStore}
bind:value={$value}
{...$$restProps}
on:blur
on:change
on:click
on:focus
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:paste
on:input
/>

View File

@@ -1,14 +0,0 @@
<script lang="ts">
import { Form as FormPrimitive } from 'formsnap';
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
type $$Props = HTMLAttributes<HTMLParagraphElement>;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<FormPrimitive.Validation
class={cn('text-sm font-medium text-destructive', className)}
{...$$restProps}
/>

View File

@@ -1,12 +1,13 @@
import * as FormPrimitive from 'formsnap';
import Button from './form-button.svelte';
import Description from './form-description.svelte';
import Label from './form-label.svelte';
import ElementField from './form-element-field.svelte';
import FieldErrors from './form-field-errors.svelte';
import Field from './form-field.svelte';
import Fieldset from './form-fieldset.svelte';
import Label from './form-label.svelte';
import Legend from './form-legend.svelte';
import ElementField from './form-element-field.svelte';
import Button from './form-button.svelte';
const Control = FormPrimitive.Control;

View File

@@ -14,7 +14,7 @@
<input
class={cn(
'flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-foreground file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
className
)}
bind:value
on:blur

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = LabelPrimitive.Props;
type $$Events = LabelPrimitive.Events;
@@ -12,7 +12,7 @@
<LabelPrimitive.Root
class={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
className
)}
{...$$restProps}
on:mousedown

View File

@@ -1,7 +1,8 @@
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
import Root from './radio-group.svelte';
import Item from './radio-group-item.svelte';
import Root from './radio-group.svelte';
const Input = RadioGroupPrimitive.Input;
export {

View File

@@ -15,7 +15,7 @@
{value}
class={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
className
)}
{...$$restProps}
on:click

View File

@@ -1,10 +1,10 @@
import { Select as SelectPrimitive } from 'bits-ui';
import Label from './select-label.svelte';
import Item from './select-item.svelte';
import Content from './select-content.svelte';
import Trigger from './select-trigger.svelte';
import Item from './select-item.svelte';
import Label from './select-label.svelte';
import Separator from './select-separator.svelte';
import Trigger from './select-trigger.svelte';
const Root = SelectPrimitive.Root;
const Group = SelectPrimitive.Group;

View File

@@ -28,7 +28,7 @@
{sideOffset}
class={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md outline-none',
className,
className
)}
{...$$restProps}
on:keydown

View File

@@ -19,7 +19,7 @@
{label}
class={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className,
className
)}
{...$$restProps}
on:click

View File

@@ -13,7 +13,7 @@
<SelectPrimitive.Trigger
class={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
className
)}
{...$$restProps}
let:builder

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn } from '$lib/utils';
import { cn } from '$lib/utils.js';
type $$Props = SwitchPrimitive.Props;
type $$Events = SwitchPrimitive.Events;
@@ -14,7 +14,7 @@
bind:checked
class={cn(
'peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
className
)}
{...$$restProps}
on:click
@@ -22,7 +22,7 @@
>
<SwitchPrimitive.Thumb
class={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>

View File

@@ -12,7 +12,7 @@
<textarea
class={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
className
)}
bind:value
on:blur

View File

@@ -1,5 +1,6 @@
export const WG_PATH = '/etc/wireguard';
export const WG_SEVER_PATH = `WG::SERVERS`;
export const WG_SEVER_PATH = `WG::SERVER`;
export const WG_AUTH_PATH = `WG::AUTH`;
export const AUTH_COOKIE = 'authorization';

View File

@@ -1,18 +1,26 @@
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
import { sha256 } from '$lib/hash';
import { randomUUID } from 'node:crypto';
import 'dotenv/config';
import { sha256 } from '$lib/utils/hash';
export const env = createEnv({
runtimeEnv: process.env,
emptyStringAsUndefined: true,
server: {
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
STORAGE_PATH: z.string().default('/data/storage.pack'),
AUTH_SECRET: z.string().default(sha256(randomUUID())),
HASHED_PASSWORD: z.string().default(sha256('insecure-password')),
ADMIN_PASSWORD: z.string().default('insecure-password'),
// -----
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
ORIGIN: z.string().optional(),
PORT: z.string().optional(),
HOST: z.string().optional(),
// -----
LOG_LEVEL: z.string().default('trace'),
LOG_FILE_PATH: z.string().default('/var/log/wireadmin/web.log'),
LOG_COLORS: z.string().default('true'),
},
});

View File

@@ -1,4 +1,4 @@
export default class ServerError extends Error {
export default class HTTPError extends Error {
statusCode;
constructor(message: string, statusCode: number = 500) {

View File

@@ -1,15 +1,14 @@
import { createWriteStream, promises } from 'node:fs';
import { dirname } from 'node:path';
import { trySafe } from 'p-safe';
import pino, { type Logger, type LoggerOptions } from 'pino';
import pretty from 'pino-pretty';
import { createWriteStream } from 'node:fs';
import { resolve } from 'node:path';
import { fsAccess, fsTouch } from '$lib/fs-extra';
const LOG_LEVEL = process.env.LOG_LEVEL || 'trace';
const LOG_FILE_PATH = process.env.LOG_FILE_PATH || '/var/vlogs/web';
const LOG_COLORS = process.env.LOG_COLORS || 'true';
import { env } from '$lib/env';
import { fsAccess } from '$lib/utils/fs-extra';
const options: LoggerOptions = {
level: LOG_LEVEL,
level: env.LOG_LEVEL,
customLevels: {
trace: 10,
info: 30,
@@ -25,25 +24,41 @@ const jsonLevels = JSON.stringify(options.customLevels);
const levelsInString = jsonLevels.replaceAll('"', '').slice(0, -1).slice(1);
const prettyStream = pretty({
colorize: LOG_COLORS === 'true',
colorize: env.LOG_COLORS === 'true',
customLevels: levelsInString,
});
let logger: Logger = pino(options, pino.multistream([prettyStream]));
if (fsAccess(LOG_FILE_PATH)) {
fsTouch(LOG_FILE_PATH).then(() => {
logger = pino(
options,
pino.multistream([
prettyStream,
createWriteStream(resolve(LOG_FILE_PATH), {
flags: 'a',
}),
]),
);
});
} else {
export function errorBox<T = Error>(e: T) {
console.error('');
console.error('---------------- ERROR ----------------');
logger.error(e);
console.error('---------------- ERROR ----------------');
console.error('');
}
const { error } = await trySafe(async () => {
const logDir = dirname(env.LOG_FILE_PATH);
if (!fsAccess(logDir)) {
await promises.mkdir(logDir, { recursive: true });
}
if (!fsAccess(env.LOG_FILE_PATH)) {
await promises.writeFile(env.LOG_FILE_PATH, '', { encoding: 'utf-8' });
}
logger = pino(
options,
pino.multistream([
prettyStream,
createWriteStream(env.LOG_FILE_PATH, {
flags: 'a',
}),
])
);
});
if (error) {
logger.warn('Log file is not accessible');
}

View File

@@ -1,17 +1,6 @@
import { execa } from 'execa';
import { ip } from 'node-netkit';
export default class Network {
public static async dropInterface(inet: string) {
await execa(`ip link delete dev ${inet}`, { shell: true });
}
public static async defaultInterface(): Promise<string> {
const route = await ip.route.defaultRoute();
if (!route) throw new Error('No default route found');
return route.dev;
}
public static async interfaceExists(inet: string): Promise<boolean> {
try {
const { stdout: o } = await execa(`ip link show | grep ${inet}`, { shell: true });
@@ -25,7 +14,7 @@ export default class Network {
const ports = [];
const { stdout: output } = await execa(
`netstat -tulpn | grep LISTEN | awk '{print $4}' | awk -F ':' '{print $NF}'`,
{ shell: true },
{ shell: true }
);
for (const line of output.split('\n')) {
const clean = Number(line.trim());

View File

@@ -1,31 +1,72 @@
import { promises } from 'node:fs';
import { execa } from 'execa';
import logger from '$lib/logger';
import { fsTouch } from '$lib/utils/fs-extra';
export const SERVICES = <const>{
tor: {
name: 'Tor',
command: {
start: 'screen -dmS "tor" tor -f /etc/tor/torrc',
stop: 'pkill tor',
logs: 'logs tor',
start:
'screen -L -Logfile /var/log/wireadmin/tor.log -dmS tor bash -c "screen -S tor -X wrap off; tor -f /etc/tor/torrc"',
stop: 'pkill tor',
logfile: '/var/log/wireadmin/tor.log',
health: async () => {
try {
const { stdout } = await execa('screen', ['-ls'], { shell: true });
return stdout.includes('tor');
} catch (_) {
return false;
}
},
},
dnsmasq: {
name: 'Dnsmasq',
start: 'dnsmasq',
stop: 'pkill dnsmasq',
logfile: '/var/log/dnsmasq/dnsmasq.log',
health: async () => {
try {
const { stdout } = await execa('ps cax | grep dnsmasq', { shell: true });
return stdout.search(/dnsmasq .* dnsmasq$/gm) !== -1;
} catch (_) {
return false;
}
},
},
};
export type ServiceName = keyof typeof SERVICES;
export type Service = (typeof SERVICES)[ServiceName];
export function getService(service: ServiceName | string | undefined): Service | undefined {
if (!!service && service in SERVICES) {
return SERVICES[service as ServiceName];
}
}
export function restart(serviceName: ServiceName) {
const service = SERVICES[serviceName];
// Stop
const { exitCode: stopExitCode } = execa(SERVICES[serviceName].command.stop, { shell: true });
const { exitCode: stopCode } = execa(service.stop, { shell: true });
// Start
const { exitCode: startExitCode } = execa(SERVICES[serviceName].command.start, { shell: true });
const { exitCode: startCode } = execa(service.start, { shell: true });
logger.info({
message: `Restarted ${serviceName} service`,
stopExitCode,
startExitCode,
});
logger.info(`Restarted ${serviceName} service. Stats: ${stopCode}:${startCode}`);
return stopExitCode === 0 && startExitCode === 0;
return stopCode === 0 && startCode === 0;
}
export async function logs(serviceName: ServiceName): Promise<string> {
const file = SERVICES[serviceName].logfile;
await fsTouch(file);
return await promises.readFile(file, { encoding: 'utf-8' });
}
export async function clearLogs(serviceName: ServiceName): Promise<void> {
const file = SERVICES[serviceName].logfile;
const cleared = new Date().toISOString();
await promises.writeFile(file, `${cleared} [notice] Log file cleared\n`, { encoding: 'utf-8' });
}

View File

@@ -1,8 +1,40 @@
import { Client, FsDriver, MSGPack } from '@litehex/storage-box';
import { resolve } from 'node:path';
import { Client, HashMap, MSGPack } from 'storage-box';
import { FsDriver } from 'storage-box/node';
import { WG_SEVER_PATH } from '$lib/constants';
import { env } from '$lib/env';
import logger from '$lib/logger';
import type { WgServer } from '$lib/typings';
import { isJson } from '$lib/utils';
const storagePath = resolve(env.STORAGE_PATH);
const driver = new FsDriver(storagePath, { parser: MSGPack });
const driver = new FsDriver(env.STORAGE_PATH, { parser: MSGPack });
export const client = new Client(driver);
const storage = new Client(driver);
storage._state = 'ready';
export const WG_STORE = storage.createHashMap(
WG_SEVER_PATH,
class extends HashMap<string, WgServer> {
async listServers(): Promise<WgServer[]> {
const raw = await this.getall();
const list = [];
for (const [key, value] of Object.entries(raw)) {
if (typeof value === 'string' && isJson(value)) {
list.push(JSON.parse(value));
} else if (typeof value === 'object') {
list.push(value);
} else {
logger.warn(`WireGuard: ListServers: malformed server: ${key}`);
}
}
return list;
}
async findByHash(hash: string): Promise<WgServer | undefined> {
const servers = await this.listServers();
return servers.find((s) => s.confHash === hash);
}
}
);
export { storage };

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import { NameSchema, TorSchema } from '$lib/wireguard/schema';
import { IPV4_REGEX } from 'node-netkit/ip';
import { z } from 'zod';
import { NameSchema, TorSchema } from '$lib/wireguard/schema';
export const WgKeySchema = z.object({
privateKey: z.string(),

View File

@@ -1,7 +1,7 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { clsx, type ClassValue } from 'clsx';
import { cubicOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
import { twMerge } from 'tailwind-merge';
export function isJson(data: any): boolean {
if (typeof data !== 'string') {
@@ -36,7 +36,7 @@ type FlyAndScaleParams = {
export const flyAndScale = (
node: Element,
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 },
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;

View File

@@ -1,18 +1,19 @@
import fs from 'node:fs';
import fs, { promises } from 'node:fs';
import path from 'path';
import deepmerge from 'deepmerge';
import type { Peer, WgKey, WgServer } from '$lib/typings';
import Network from '$lib/network';
import { WG_PATH, WG_SEVER_PATH } from '$lib/constants';
import { dynaJoin, isJson, sleep } from '$lib/utils';
import { getPeerConf } from '$lib/wireguard/utils';
import logger from '$lib/logger';
import { sha256 } from '$lib/hash';
import { fsAccess } from '$lib/fs-extra';
import { client } from '$lib/storage';
import { execa } from 'execa';
import { ip } from 'node-netkit';
import { WG_PATH } from '$lib/constants';
import logger from '$lib/logger';
import Network from '$lib/network';
import { WG_STORE } from '$lib/storage';
import type { Peer, WgKey, WgServer } from '$lib/typings';
import { dynaJoin, sleep } from '$lib/utils';
import { fsAccess } from '$lib/utils/fs-extra';
import { sha256 } from '$lib/utils/hash';
import { getPeerConf } from '$lib/wireguard/utils';
export class WGServer {
readonly id: string;
readonly peers: WGPeers;
@@ -30,34 +31,31 @@ export class WGServer {
this.peers = new WGPeers(this);
}
static exists(id: string): boolean {
const serverIds = getServers().map((s) => s.id);
const exists = serverIds.includes(id);
static async exists(id: string): Promise<boolean> {
const exists = await WG_STORE.exists(id);
if (!exists) {
logger.debug({
message: `WGServer: Exists: server by id of ${id} does not exists`,
servers: serverIds,
});
logger.debug(`WireGuard: ServerExists: server does not exists. Id: ${id}`);
logger.debug(await WG_STORE.getall());
return false;
}
return exists;
return true;
}
async get(): Promise<WgServer> {
if (!fsAccess(WG_PATH)) {
logger.debug('WGServer: get: creating wg path');
fs.mkdirSync(WG_PATH, { recursive: true, mode: 0o600 });
logger.debug(`WireGuard: Get: path does not exists. Path: ${WG_PATH}`);
await promises.mkdir(WG_PATH, { recursive: true, mode: 0o600 });
}
const server = await findServer(this.id);
const server = await WG_STORE.get(this.id);
if (!server) {
throw new Error('WGServer: get: server not found');
throw new Error('WireGuard: Server not found');
}
if (!fsAccess(resolveConfigPath(server.confId))) {
logger.debug('WGServer: get: creating config file');
const confPath = resolveConfigPath(server.confId);
if (!fsAccess(confPath)) {
logger.debug(`WireGuard: Get: config file does not exists. Path: ${confPath}`);
await this.writeConfigFile(server);
}
@@ -80,7 +78,7 @@ export class WGServer {
return true;
}
async start(): Promise<boolean> {
async start(): Promise<void> {
const server = await this.get();
const HASH = getConfigHash(server.confId);
@@ -97,40 +95,31 @@ export class WGServer {
await execa(`wg-quick up wg${server.confId}`, { shell: true });
await this.update({ status: 'up' });
return true;
}
async remove(): Promise<boolean> {
const server = await this.get();
await this.stop();
async remove(): Promise<void> {
const server = await WG_STORE.get(this.id);
if (!server) {
logger.warn(`WireGuard: Remove: server not found. Id: ${this.id}`);
return;
}
if (wgConfExists(server.confId)) {
logger.debug('WGServer:Remove: removing config file');
fs.unlinkSync(resolveConfigPath(server.confId));
logger.debug(`WireGuard: Remove: deleting config file. Id: ${this.id}`);
await promises.unlink(resolveConfigPath(server.confId));
}
const index = await findServerIndex(this.id);
if (typeof index !== 'number') {
logger.warn('WGServer:Remove: server index not found');
return true;
}
client.ldel(WG_SEVER_PATH, index);
return true;
await WG_STORE.del(this.id);
}
async update(update: Partial<WgServer>): Promise<boolean> {
const server = await this.get();
const index = await findServerIndex(this.id);
if (typeof index !== 'number') {
logger.warn('WGServer:Update: server index not found');
const server = await WG_STORE.get(this.id);
if (!server) {
logger.warn(`WireGuard: Update: server not found. Id: ${this.id}`);
return true;
}
client.lset(WG_SEVER_PATH, index, {
await WG_STORE.set(this.id, {
...deepmerge(server, update),
peers: update?.peers || server?.peers || [],
updatedAt: new Date().toISOString(),
@@ -190,7 +179,7 @@ export class WGServer {
}
static async getFreePeerIp(serverId: string): Promise<string | undefined> {
const server = await findServer(serverId);
const server = await WG_STORE.get(serverId);
if (!server) {
logger.error('WGServer: GetFreePeerIP: no server found');
return undefined;
@@ -243,18 +232,12 @@ class WGPeers {
peer.preSharedKey && `PresharedKey = ${peer.preSharedKey}`,
`AllowedIPs = ${peer.allowedIps}/32`,
peer.persistentKeepalive && `PersistentKeepalive = ${peer.persistentKeepalive}`,
]),
])
);
fs.writeFileSync(confPath, lines.join('\n'), { mode: 0o600 });
await this.server.update({ confHash: getConfigHash(server.confId) });
const index = await findServerIndex(this.server.id);
if (typeof index !== 'number') {
logger.warn('WGPeers:Add: server index not found');
return true;
}
client.lset(WG_SEVER_PATH, index, {
await WG_STORE.set(this.server.id, {
...server,
peers: [...server.peers, peer],
});
@@ -271,13 +254,7 @@ class WGPeers {
const server = await this.server.get();
const peers = wgPeersStr(server.confId);
const index = await findServerIndex(this.server.id);
if (typeof index !== 'number') {
logger.warn('WGPeers:Remove: server index not found');
return true;
}
client.lset(WG_SEVER_PATH, index, {
await WG_STORE.set(this.server.id, {
...server,
peers: server.peers.filter((p) => p.publicKey !== publicKey),
});
@@ -306,18 +283,12 @@ class WGPeers {
async update(publicKey: string, update: Partial<Peer>): Promise<boolean> {
const server = await this.server.get();
const index = await findServerIndex(this.server.id);
if (typeof index !== 'number') {
logger.warn('WGPeers:Update: server index not found');
return true;
}
const updatedPeers = server.peers.map((p) => {
if (p.publicKey !== publicKey) return p;
return deepmerge(p, update);
});
client.lset(WG_SEVER_PATH, index, { ...server, peers: updatedPeers });
await WG_STORE.set(this.server.id, { ...server, peers: updatedPeers });
await this.storePeers(publicKey, updatedPeers);
if (server.status === 'up') {
@@ -334,7 +305,7 @@ class WGPeers {
}
async generateConfig(peerId: string): Promise<string | undefined> {
const server = await findServer(this.server.id);
const server = await WG_STORE.get(this.server.id);
if (!server) {
logger.error('WGPeers:GeneratePeerConfig: server not found');
return undefined;
@@ -476,28 +447,6 @@ function wgConfExists(configId: number): boolean {
return fsAccess(confPath);
}
/**
* Used to read /etc/wireguard/*.conf and sync them with our
* redis server.
*/
async function syncServers(): Promise<boolean> {
// get files in /etc/wireguard
const files = fs.readdirSync(WG_PATH);
// filter files that start with wg and end with .conf
const reg = new RegExp(/^wg(\d+)\.conf$/);
const confs = files.filter((f) => reg.test(f));
// read all confs
const servers = await Promise.all(confs.map((f) => readWgConf(parseInt(f.match(reg)![1]))));
// remove old servers
client.del(WG_SEVER_PATH);
// save all servers to redis
client.lpush(WG_SEVER_PATH, servers);
return true;
}
function wgPeersStr(configId: number): string[] {
const confPath = path.resolve(WG_PATH, `wg${configId}.conf`);
const conf = fs.readFileSync(confPath, 'utf-8');
@@ -531,7 +480,7 @@ export async function generateWgServer(config: GenerateWgServerParams): Promise<
const uuid = crypto.randomUUID();
logger.debug(
`WireGuard: GenerateWgServer: creating server with id: ${uuid} and confId: ${confId}`,
`WireGuard: GenerateWgServer: creating server with id: ${uuid} and confId: ${confId}`
);
let server: WgServer = {
@@ -572,7 +521,7 @@ export async function generateWgServer(config: GenerateWgServerParams): Promise<
// save server config
logger.debug('WireGuard: GenerateWgServer: saving server to storage');
logger.debug(server);
client.lpush(WG_SEVER_PATH, server);
await WG_STORE.set(uuid, server);
const CONFIG_PATH = resolveConfigPath(confId);
@@ -598,17 +547,20 @@ export async function generateWgServer(config: GenerateWgServerParams): Promise<
}
export async function isIPReserved(ip: string): Promise<boolean> {
const addresses = getServers().map((s) => s.address);
const severs = await WG_STORE.listServers();
const addresses = severs.map((s) => s.address);
return addresses.includes(ip);
}
export async function isPortReserved(port: number): Promise<boolean> {
const inUsePorts = [await Network.inUsePorts(), getServers().map((s) => Number(s.listen))].flat();
const severs = await WG_STORE.listServers();
const inUsePorts = [await Network.inUsePorts(), severs.map((s) => Number(s.listen))].flat();
return inUsePorts.includes(port);
}
export async function isConfigIdReserved(id: number): Promise<boolean> {
const ids = getServers().map((s) => s.confId);
const severs = await WG_STORE.listServers();
const ids = severs.map((s) => s.confId);
return ids.includes(id);
}
@@ -651,52 +603,12 @@ export function maxConfId(): number {
return Math.max(0, ...ids);
}
export function getServers(): WgServer[] {
const rawServers = (client.list(WG_SEVER_PATH) || []) as string[];
return rawServers.map((s) => {
if (isJson(s)) {
return JSON.parse(s);
}
if (typeof s === 'object') {
return s;
}
logger.warn('WireGuard: GetServers: invalid server found');
return null;
});
}
export async function findServerIndex(id: string): Promise<number | undefined> {
let index = 0;
const servers = getServers();
for (const s of servers) {
if (s.id === id) {
return index;
}
index++;
}
return undefined;
}
export async function findServer(
id: string | undefined,
hash?: string,
): Promise<WgServer | undefined> {
const servers = getServers();
return id
? servers.find((s) => s.id === id)
: hash && isJson(hash)
? servers.find((s) => JSON.stringify(s) === hash)
: undefined;
}
export async function makeWgIptables(s: WgServer): Promise<{ up: string; down: string }> {
const source = `${s.address}/24`;
const route = await ip.route.defaultRoute();
const wg_inet = `wg${s.confId}`;
const loopback = '127.0.0.1';
if (!route) {
throw new Error('No default route found');
@@ -705,18 +617,44 @@ export async function makeWgIptables(s: WgServer): Promise<{ up: string; down: s
const { stdout: inet_address } = await execa(`hostname -i | awk '{print $1}'`, { shell: true });
if (s.tor) {
const up = dynaJoin([
`iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT`,
`iptables -A INPUT -i ${wg_inet} -s ${source} -m state --state NEW -j ACCEPT`,
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} --dport 53 -j DNAT --to-destination ${inet_address}:53530`,
`iptables -t nat -A PREROUTING -i ${wg_inet} -p tcp -s ${source} -j DNAT --to-destination ${inet_address}:59040`,
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} -j DNAT --to-destination ${inet_address}:59040`,
`iptables -t nat -A OUTPUT -o lo -j RETURN`,
`iptables -A OUTPUT -m conntrack --ctstate INVALID -j DROP`,
`iptables -A OUTPUT -m state --state INVALID -j DROP`,
`iptables -A OUTPUT ! -o lo ! -d 127.0.0.1 ! -s 127.0.0.1 -p tcp -m tcp --tcp-flags ACK,FIN ACK,FIN -j DROP`,
]).join('; ');
return { up, down: up.replace(/-A/g, '-D') };
// https://trac.torproject.org/projects/tor/wiki/doc/TransparentProxy#WARNING
// https://lists.torproject.org/pipermail/tor-talk/2014-March/032503.html
const out_iface = route.dev;
const virt_addr = '10.192.0.0/10';
const trans_port = '59040';
const dns_port = '53530';
const non_tor = '127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16'.split(' ');
const resv_iana =
'0.0.0.0/8 100.64.0.0/10 169.254.0.0/16 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 255.255.255.255/32'.split(
' '
);
const up = dynaJoin(
[
`iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT`,
`iptables -t nat -A INPUT -i lo -j ACCEPT`,
`iptables -A OUTPUT -d 127.0.0.1/32 -o lo -j ACCEPT`,
`iptables -A INPUT -i ${wg_inet} -s ${source} -m state --state NEW -j ACCEPT`,
// nat dns requests to Tor
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp --dport 53 -j DNAT --to-destination ${inet_address}:${dns_port}`,
// Redirect all other pre-routing and output to Tor's TransPort
`iptables -t nat -A PREROUTING -i ${wg_inet} -p tcp -j DNAT --to-destination ${inet_address}:${trans_port}`,
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -j DNAT --to-destination ${inet_address}:${trans_port}`,
// Allow Tor process output
`iptables -A OUTPUT -o ${out_iface} -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -m state --state NEW -j ACCEPT`,
// nat .onion addresses
`iptables -t nat -A OUTPUT -d ${virt_addr} -p tcp -m tcp -j DNAT --to-destination ${inet_address}:${trans_port}`,
`iptables -A OUTPUT -m state --state ESTABLISHED -j ACCEPT`,
`iptables -A OUTPUT -m conntrack --ctstate INVALID -j DROP`,
`iptables -A OUTPUT -m state --state INVALID -j DROP`,
// Allow lan access for hosts in $non_tor
[non_tor, resv_iana].flat().map((n) => `iptables -t nat -A OUTPUT -d ${n} -j RETURN`),
// Don't nat the Tor process, the loopback, or the local network
`iptables -t nat -A OUTPUT -o lo -j RETURN`,
`iptables -A OUTPUT ! -o lo ! -d ${loopback} ! -s ${loopback} -p tcp -m tcp --tcp-flags ACK,FIN ACK,FIN -j DROP`,
`iptables -A OUTPUT ! -o lo ! -d ${loopback} ! -s ${loopback} -p tcp -m tcp --tcp-flags ACK,RST ACK,RST -j DROP`,
].flat()
).join('; ');
return { up, down: up.replace(/ -A /g, ' -D ') };
}
const up = dynaJoin([
@@ -755,7 +693,7 @@ export async function genServerConf(server: WgServer): Promise<string> {
lines.push(`${peer.preSharedKey ? `PresharedKey = ${peer.preSharedKey}` : 'OMIT'}`);
lines.push(`AllowedIPs = ${peer.allowedIps}/32`);
lines.push(
`${peer.persistentKeepalive ? `PersistentKeepalive = ${peer.persistentKeepalive}` : 'OMIT'}`,
`${peer.persistentKeepalive ? `PersistentKeepalive = ${peer.persistentKeepalive}` : 'OMIT'}`
);
});

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import { isBetween } from '$lib/utils/number';
import { IPV4_REGEX, isPrivateIP } from '$lib/utils/ip';
import { isBetween } from '$lib/utils/number';
export const NameSchema = z
.string()
@@ -29,7 +30,7 @@ export const PortSchema = z
},
{
message: 'Port must be a valid port number',
},
}
);
export const TorSchema = z.boolean().default(false);
@@ -39,6 +40,7 @@ export const DnsSchema = z
.regex(IPV4_REGEX, {
message: 'DNS must be a valid IPv4 address',
})
.default('9.9.9.11')
.optional();
export const MtuSchema = z

View File

@@ -1,15 +1,5 @@
import type { ZodError } from 'zod';
// export function zodErrorToResponse(res: NextApiResponse, z: ZodError) {
// return res
// .status(400)
// .json({
// ok: false,
// message: 'Bad Request',
// details: zodErrorMessage(z)
// })
// }
export function zodEnumError(message: string) {
return { message };
}

View File

@@ -4,6 +4,6 @@
import { ModeWatcher } from 'mode-watcher';
</script>
<ModeWatcher />
<ModeWatcher defaultMode="system" />
<slot />
<Toaster />

View File

@@ -1,22 +1,18 @@
import { type Actions, error, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import {
findServer,
generateWgServer,
getServers,
isIPReserved,
isPortReserved,
WGServer,
} from '$lib/wireguard';
import { error, fail, type Actions } from '@sveltejs/kit';
import { setError, superValidate } from 'sveltekit-superforms';
import { createServerSchema } from './schema';
import { NameSchema } from '$lib/wireguard/schema';
import logger from '$lib/logger';
import { zod } from 'sveltekit-superforms/adapters';
import logger, { errorBox } from '$lib/logger';
import { WG_STORE } from '$lib/storage';
import { generateWgServer, isIPReserved, isPortReserved, WGServer } from '$lib/wireguard';
import { NameSchema } from '$lib/wireguard/schema';
import type { PageServerLoad } from './$types';
import { createServerSchema } from './schema';
export const load: PageServerLoad = async () => {
return {
servers: getServers(),
servers: await WG_STORE.listServers(),
form: await superValidate(zod(createServerSchema)),
};
};
@@ -27,15 +23,15 @@ export const actions: Actions = {
const serverId = (form.get('id') ?? '').toString();
const name = (form.get('name') ?? '').toString();
const server = await findServer(serverId ?? '');
const server = await WG_STORE.get(serverId);
if (!server) {
logger.error('Actions: RenameServer: Server not found');
return error(404, 'Not found');
logger.error(`WebUI: Actions: RenameServer: Server not found. Id: ${serverId}`);
throw error(404, 'Not found');
}
if (!NameSchema.safeParse(name).success) {
logger.error('Actions: RenameServer: Server name is invalid');
return error(400, 'Bad Request');
throw error(400, 'Bad Request');
}
const wg = new WGServer(server.id);
@@ -76,7 +72,7 @@ export const actions: Actions = {
serverId,
};
} catch (e) {
logger.error(e);
errorBox(e);
return setError(form, 'Unhandled Exception');
}
},

View File

@@ -5,9 +5,11 @@
import Service from './Service.svelte';
import { Empty } from '$lib/components/empty';
import Server from './Server.svelte';
import fetchAction from '$lib/fetch-action';
import fetchAction from '$lib/utils/fetch-action';
import CreateServerDialog from './CreateServerDialog.svelte';
import { Button } from '$lib/components/ui/button';
import { PlusIcon } from 'lucide-svelte';
import { DnsmasqIcon, OnionIcon } from '$lib/components/iconset';
export let data: PageData;
@@ -35,8 +37,8 @@
<h2 class={'font-bold text-xl'}>Hello there 👋</h2>
<CreateServerDialog data={data.form} let:builder>
<Button builders={[builder]}>
<i class="fas fa-plus mr-2"></i>
<Button builders={[builder]} size="sm">
<PlusIcon class="mr-2" />
Create Server
</Button>
</CreateServerDialog>
@@ -60,6 +62,7 @@
</CardContent>
{/if}
</Card>
<Card>
<CardHeader>
<CardTitle>Services</CardTitle>
@@ -67,10 +70,15 @@
<CardContent>
<Service name="Tor" slug="tor">
<svelte:fragment slot="icon">
<i class={'fa-solid fa-onion text-purple-700 text-xl'} />
<OnionIcon class="h-6 w-6" />
</svelte:fragment>
</Service>
<Service name="Dnsmasq" slug="dnsmasq">
<svelte:fragment slot="icon">
<DnsmasqIcon class="h-6 w-6" />
</svelte:fragment>
</Service>
</CardContent>
</Card>
</div></BasePage
>
</div>
</BasePage>

View File

@@ -28,8 +28,8 @@
import { Input } from '$lib/components/ui/input';
import { zodClient } from 'sveltekit-superforms/adapters';
import { Switch } from '$lib/components/ui/switch';
import { ChevronRightIcon, LoaderCircle } from 'lucide-svelte';
let loading: boolean = false;
let dialogOpen = false;
export let data: SuperValidated<Infer<CreateServerSchemaType>>;
@@ -37,15 +37,10 @@
const form = superForm(data, {
dataType: 'json',
validators: zodClient(createServerSchema),
onSubmit: () => {
loading = true;
},
onError: (e) => {
loading = false;
console.error('Client-side: FormError:', e);
},
onResult: ({ result }) => {
loading = false;
if (result.type === 'success') {
dialogOpen = false;
toast.success('Server created successfully!');
@@ -56,7 +51,7 @@
},
});
const { form: formData, enhance } = form;
const { form: formData, enhance, submitting } = form;
</script>
<Dialog bind:open={dialogOpen}>
@@ -101,16 +96,20 @@
<FormLabel>Port</FormLabel>
<Input {...attrs} bind:value={$formData.port} placeholder={'e.g. 51820'} type={'text'} />
</FormControl>
<FormDescription>This is the port that the WireGuard server will listen on.</FormDescription
>
<FormDescription>
This is the port that the WireGuard server will listen on.
</FormDescription>
<FormFieldErrors />
</FormField>
<Collapsible>
<CollapsibleTrigger asChild let:builder>
<Button builders={[builder]} variant="ghost" size="sm" class="mb-4 -mr-2">
<i
class={cn('far fa-cog mr-2', builder['data-state'] === 'open' ? 'animate-spin' : '')}
<ChevronRightIcon
class={cn(
'mr-2 h-4 w-4 duration-200 ease-in-out transform',
builder['data-state'] === 'open' ? 'rotate-90' : 'rotate-0'
)}
/>
<span>Advanced Options</span>
</Button>
@@ -118,21 +117,28 @@
<CollapsibleContent class="space-y-6">
<FormField {form} name={'tor'}>
<FormControl let:attrs>
<Switch {...attrs} bind:checked={$formData.tor} />
<FormLabel>Use Tor</FormLabel>
</FormControl>
<div class="flex items-center space-x-2">
<FormControl let:attrs>
<Switch {...attrs} bind:checked={$formData.tor} />
<FormLabel>Use Tor</FormLabel>
</FormControl>
</div>
<FormDescription>This will route all outgoing traffic through Tor.</FormDescription>
</FormField>
<FormField {form} name={'dns'}>
<FormControl let:attrs>
<FormLabel>DNS</FormLabel>
<Input placeholder={'e.g. 1.1.1.1'} type={'text'} />
<Input
{...attrs}
bind:value={$formData.dns}
placeholder={'e.g. 9.9.9.9, 9.9.9.10'}
type={'text'}
/>
</FormControl>
<FormDescription
>Optional. This is the DNS server that will be pushed to clients.</FormDescription
>
<FormDescription>
Optional. This is the DNS server that will be pushed to clients.
</FormDescription>
<FormFieldErrors />
</FormField>
@@ -148,9 +154,8 @@
</Collapsible>
<DialogFooter>
<FormButton>
<i class={cn(loading ? 'far fa-arrow-rotate-right animate-spin' : 'far fa-plus', 'mr-2')}
></i>
<FormButton disabled={$submitting}>
<LoaderCircle class={cn('mr-2 h-4 w-4 animate-spin', !$submitting && 'hidden')} />
Create
</FormButton>
</DialogFooter>

View File

@@ -7,6 +7,8 @@
import { Badge } from '$lib/components/ui/badge';
import { createEventDispatcher } from 'svelte';
import { cn } from '$lib/utils';
import { LayersIcon } from 'lucide-svelte';
import { OnionIcon } from '$lib/components/iconset';
export let server: WgServer;
export let addressPort: string = `${server.address}:${server.listen}`;
@@ -19,15 +21,15 @@
<div class="flex grow">
<div
class={cn(
'relative w-12 aspect-square',
'relative w-12 h-12 aspect-square',
'flex items-center justify-center mr-4 ',
'bg-gray-200 max-md:hidden',
'rounded-full',
'rounded-full'
)}
>
<i class={'fa-solid fa-server text-gray-400 text-xl'} />
<LayersIcon class="text-gray-400 text-xl" />
{#if server.tor}
<i class="absolute bottom-3.5 right-2 w-3 h-3 fa-solid fa-onion text-purple-700" />
<OnionIcon class="absolute bottom-2 right-2 w-4 h-4" />
{/if}
</div>

View File

@@ -2,6 +2,7 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { onMount } from 'svelte';
import { LoaderCircle } from 'lucide-svelte';
export let name: string;
export let slug: string;
@@ -37,7 +38,7 @@
</div>
<div class={'flex col-span-4 items-center justify-end'}>
{#if healthy === undefined}
<i class="fas fa-spinner animate-spin" />
<LoaderCircle class={'h-4 w-4 animate-spin'} />
{:else}
<Badge variant={healthy ? 'success' : 'destructive'} />
{/if}

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Empty } from '$lib/components/empty';
import { Button } from '$lib/components/ui/button';
</script>
<Card>
<CardHeader>
<CardTitle>Server</CardTitle>
</CardHeader>
<CardContent class="flex flex-col items-center justify-center">
<Empty description={'Server not found!'} />
</CardContent>
<CardFooter>
<Button size="sm" on:click={() => window.history.back()}>Go Back</Button>
</CardFooter>
</Card>

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import BasePage from '$lib/components/page/BasePage.svelte';
</script>

View File

@@ -1,19 +1,22 @@
import { type Actions, error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { findServer, generateWgKey, WGServer } from '$lib/wireguard';
import { NameSchema } from '$lib/wireguard/schema';
import { error, redirect, type Actions } from '@sveltejs/kit';
import { setError, superValidate } from 'sveltekit-superforms';
import { createPeerSchema } from './schema';
import logger from '$lib/logger';
import { zod } from 'sveltekit-superforms/adapters';
import logger, { errorBox } from '$lib/logger';
import { WG_STORE } from '$lib/storage';
import { generateWgKey, WGServer } from '$lib/wireguard';
import { NameSchema } from '$lib/wireguard/schema';
import type { PageServerLoad } from './$types';
import { createPeerSchema } from './schema';
export const load: PageServerLoad = async ({ params }) => {
const { serverId } = params;
const exists = WGServer.exists(serverId ?? '');
const exists = await WGServer.exists(serverId ?? '');
if (!exists) {
logger.warn(`Server not found. Redirecting to home page. ServerId: ${serverId}`);
throw redirect(303, '/');
error(404, { message: 'Not found' });
}
const wg = new WGServer(serverId);
@@ -39,9 +42,9 @@ export const load: PageServerLoad = async ({ params }) => {
export const actions: Actions = {
rename: async ({ request, params }) => {
const { serverId } = params;
const server = await findServer(serverId ?? '');
const server = await WG_STORE.get(serverId ?? '');
if (!server) {
logger.error('Server not found');
logger.error(`Actions: Rename: Server not found. ServerId: ${serverId}`);
throw error(404, 'Not found');
}
@@ -65,16 +68,16 @@ export const actions: Actions = {
return { ok: true };
} catch (e) {
logger.error('Exception:', e);
errorBox(e);
throw error(500, 'Unhandled Exception');
}
},
remove: async ({ request, params }) => {
const { serverId } = params;
const server = await findServer(serverId ?? '');
const server = await WG_STORE.get(serverId ?? '');
if (!server) {
console.error('Server not found');
logger.error(`Actions: Remove: Server not found. ServerId: ${serverId}`);
throw error(404, 'Not found');
}
@@ -89,7 +92,7 @@ export const actions: Actions = {
return { ok: true };
} catch (e) {
console.error('Exception:', e);
errorBox(e);
throw error(500, 'Unhandled Exception');
}
},
@@ -97,13 +100,13 @@ export const actions: Actions = {
const { serverId } = params;
try {
const server = await findServer(serverId ?? '');
const server = await WG_STORE.get(serverId ?? '');
if (server) {
const wg = new WGServer(server.id);
await wg.remove();
}
} catch (e) {
logger.error(e);
errorBox(e);
throw error(500, 'Unhandled Exception');
}
@@ -112,7 +115,7 @@ export const actions: Actions = {
'change-server-state': async ({ request, params }) => {
const { serverId } = params;
const server = await findServer(serverId ?? '');
const server = await WG_STORE.get(serverId ?? '');
if (!server) {
logger.warn(`Action: ChangeState: Server not found. ServerId: ${serverId}`);
throw redirect(303, '/');
@@ -157,7 +160,7 @@ export const actions: Actions = {
create: async (event) => {
const form = await superValidate(event, zod(createPeerSchema));
if (!form.valid) {
logger.warn('CreatePeer: Bad Request: failed to validate form');
logger.warn('Action: Create: failed to validate form.');
return setError(form, 'Bad Request');
}
@@ -165,15 +168,15 @@ export const actions: Actions = {
const { name } = form.data;
try {
const server = await findServer(serverId ?? '');
const server = await WG_STORE.get(serverId ?? '');
if (!server) {
logger.error(`Server not found. ServerId: ${serverId}`);
logger.error(`Action: Create: Server not found. ServerId: ${serverId}`);
return setError(form, 'Server not found');
}
const freeAddress = await WGServer.getFreePeerIp(server.id);
if (!freeAddress) {
logger.error(`No free addresses. ServerId: ${serverId}`);
logger.error('No free addresses.');
return setError(form, 'No free addresses');
}
@@ -191,16 +194,13 @@ export const actions: Actions = {
});
if (!addedPeer) {
logger.error(`Failed to add peer. ServerId: ${serverId}`);
logger.error(`Action: Create: Failed to add peer. ServerId: ${serverId}`);
return setError(form, 'Failed to add peer');
}
return { form };
} catch (e) {
logger.error({
message: `Exception: CreatePeer. ServerId: ${serverId}`,
exception: e,
});
errorBox(e);
return setError(form, 'Unhandled Exception');
}
},

View File

@@ -4,7 +4,7 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import Peer from './Peer.svelte';
import fetchAction from '$lib/fetch-action';
import fetchAction from '$lib/utils/fetch-action';
import DetailRow from './DetailRow.svelte';
import { Badge } from '$lib/components/ui/badge';
import { CopiableText } from '$lib/components/copiable-text';
@@ -13,6 +13,15 @@
import { Empty } from '$lib/components/empty';
import prettyBytes from 'pretty-bytes';
import { onDestroy } from 'svelte';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '$lib/components/ui/breadcrumb';
import { ArrowUpIcon } from 'lucide-svelte';
export let data: PageData;
let dialogOpen = false;
@@ -87,16 +96,17 @@
</script>
<div class="flex items-center gap-2 py-4 px-2 leading-none">
<a
href="/"
title="Home"
class="space-x-1 font-bold text-sm text-primary hover:text-primary/80 tracking-tight"
>
<i class="fa-solid fa-chevron-left"></i>
<span> Back to Home </span>
</a>
<i class="fa-regular fa-slash-forward"></i>
<span class="mb-0.5"> {data.server.name} </span>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{data.server.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div class="space-y-3.5">
@@ -106,11 +116,13 @@
</CardHeader>
<CardContent>
{#if data.server.tor}
<DetailRow label={'Mode'}>
<DetailRow label={'Mode'}>
{#if data.server.tor}
<Badge variant="tor">Tor</Badge>
</DetailRow>
{/if}
{:else}
<Badge variant="default">Normal</Badge>
{/if}
</DetailRow>
<DetailRow label={'IP address'}>
<pre> {data.server.address}/24 </pre>
@@ -123,11 +135,11 @@
<DetailRow label={'Total Usage'}>
<div class="flex items-center gap-3 text-sm">
<div class="flex items-center gap-x-1.5">
<i class="fas fa-arrow-up text-gray-500"></i>
<ArrowUpIcon class="h-4 w-4 text-gray-500" />
<span>{prettyBytes(data.usage.total.tx)}</span>
</div>
<div class="flex items-center gap-x-1.5">
<i class="fas fa-arrow-down text-gray-500"></i>
<ArrowUpIcon class="h-4 w-4 text-gray-500 rotate-180" />
<span>{prettyBytes(data.usage.total.rx)}</span>
</div>
</div>
@@ -139,7 +151,7 @@
<DetailRow label={'Public Key'}>
<CopiableText value={data.server.publicKey}>
<MiddleEllipsis content={data.server.publicKey} maxLength={12} />
<MiddleEllipsis content={data.server.publicKey} maxLength={10} />
</CopiableText>
</DetailRow>
</CardContent>
@@ -152,33 +164,38 @@
size="sm"
on:click={() => {
handleChangeState('restart');
}}>Restart</Button
>
}}
>Restart
</Button>
<Button
variant="destructive"
class="max-md:w-full"
size="sm"
on:click={() => {
handleChangeState('stop');
}}>Stop</Button
>
}}
>Stop
</Button>
{:else}
<Button
variant="success"
class="max-md:w-full bg-green-500"
class="max-md:w-full"
size="sm"
on:click={() => {
handleChangeState('start');
}}>Start</Button
}}
>
Start
</Button>
<Button
variant="destructive"
class="max-md:w-full"
size="sm"
on:click={() => {
handleChangeState('remove');
}}>Remove</Button
}}
>
Remove
</Button>
{/if}
</CardFooter>
</Card>
@@ -192,9 +209,7 @@
{#each data.server.peers as peer}
<Peer
{peer}
serverKey={data.server.publicKey}
serverPort={data.server.listen}
serverDNS={data.server.dns}
server={data.server}
on:rename={({ detail }) => {
handleRename(peer.id.toString(), detail);
}}

View File

@@ -21,6 +21,7 @@
import toast from 'svelte-french-toast';
import { zodClient } from 'sveltekit-superforms/adapters';
import { Input } from '$lib/components/ui/input';
import { LoaderCircle } from 'lucide-svelte';
let loading: boolean = false;
export let open = false;
@@ -48,7 +49,7 @@
},
});
const { form: formData, enhance } = form;
const { form: formData, enhance, submitting } = form;
const handleSuccess = async () => {
await invalidateAll();
@@ -80,9 +81,8 @@
</FormField>
<DialogFooter>
<FormButton>
<i class={cn(loading ? 'far fa-arrow-rotate-right animate-spin' : 'far fa-plus', 'mr-2')}
></i>
<FormButton disabled={$submitting}>
<LoaderCircle class={cn('mr-2 h-4 w-4 animate-spin', !$submitting && 'hidden')} />
Create
</FormButton>
</DialogFooter>

View File

@@ -9,7 +9,7 @@
'flex flex-wrap items-center justify-between gap-2 py-3.5',
'relative overflow-ellipsis',
'leading-none',
'border-b border-gray-200/80 last:border-none',
'border-b border-border last:border-none'
)}
>
<div class={'flex items-center text-gray-400 text-sm col-span-12 md:col-span-3'}>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Peer } from '$lib/typings';
import type { Peer, WgServer } from '$lib/typings';
import { CopiableText } from '$lib/components/copiable-text';
import { EditableText } from '$lib/components/editable-text';
import { NameSchema } from '$lib/wireguard/schema';
@@ -8,12 +8,10 @@
import { getPeerConf } from '$lib/wireguard/utils';
import { QRCodeDialog } from '$lib/components/qrcode-dialog';
import { cn } from '$lib/utils';
import { DownloadIcon, QrCodeIcon, Trash2Icon, UserIcon } from 'lucide-svelte';
export let peer: Peer;
export let serverKey: string;
export let serverPort: number;
export let serverDNS: string | null;
export let server: WgServer;
export let conf: string | undefined = undefined;
@@ -22,9 +20,9 @@
onMount(async () => {
conf = await getPeerConf({
...peer,
serverPublicKey: serverKey,
port: serverPort,
dns: serverDNS,
serverPublicKey: server.publicKey,
port: server.listen,
dns: server.dns,
});
});
@@ -59,14 +57,14 @@
<div
class={cn(
'flex items-center justify-between p-4 rounded-md',
'border border-input bg-background hover:bg-accent/30 hover:text-accent-foreground',
'border border-input bg-background hover:bg-accent/30 hover:text-accent-foreground'
)}
>
<div class="flex items-center gap-x-2">
<div
class={'w-12 aspect-square flex items-center justify-center mr-4 rounded-full bg-gray-200 max-md:hidden'}
class={'w-12 h-12 flex items-center justify-center mr-4 rounded-full bg-gray-200 max-md:hidden'}
>
<i class={'fas fa-user text-gray-400 text-lg'} />
<UserIcon class="text-gray-400 text-lg" />
</div>
<div class="h-full flex flex-col justify-between col-span-4 gap-y-1.5">
@@ -88,18 +86,18 @@
<!-- QRCode -->
<QRCodeDialog let:builder content={conf}>
<PeerActionButton builders={[builder]} disabled={isLoading}>
<i class={'fal fa-qrcode'} />
<QrCodeIcon class="w-4 h-4" />
</PeerActionButton>
</QRCodeDialog>
<!-- Download -->
<PeerActionButton disabled={isLoading} on:click={handleDownload}>
<i class={'fal fa-download'} />
<DownloadIcon class="w-4 h-4" />
</PeerActionButton>
<!-- Remove -->
<PeerActionButton loading={isLoading} on:click={handleRemove}>
<i class={'fal fa-trash-can'} />
<Trash2Icon class="w-4 h-4" />
</PeerActionButton>
</div>
</div>

View File

@@ -2,6 +2,8 @@
import { cn } from '$lib/utils';
import { createEventDispatcher } from 'svelte';
import { Button } from 'bits-ui';
import { LoaderCircle } from 'lucide-svelte';
import { FormButton } from '$lib/components/ui/form';
type $$Props = Button.Props & {
disabled?: boolean;
@@ -31,7 +33,7 @@
'transition-colors duration-200 ease-in-out',
'cursor-pointer',
disabled && 'opacity-50 cursor-not-allowed',
loading && 'animate-pulse',
loading && 'animate-pulse'
)}
on:click={handleClick}
on:keydown={(e) => {
@@ -39,7 +41,7 @@
}}
>
{#if loading}
<i class="far fa-spinner-third fa-spin" />
<LoaderCircle class={'h-4 w-4 animate-spin'} />
{:else}
<slot />
{/if}

View File

@@ -1,6 +1,7 @@
import { NameSchema } from '$lib/wireguard/schema';
import { z } from 'zod';
import { NameSchema } from '$lib/wireguard/schema';
export const createPeerSchema = z.object({
name: NameSchema,
});

View File

@@ -1,10 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit';
import { getServers, WGServer } from '$lib/wireguard';
import logger from '$lib/logger';
import { errorBox } from '$lib/logger';
import { WG_STORE } from '$lib/storage';
import { WGServer } from '$lib/wireguard';
export const GET: RequestHandler = async () => {
try {
for (const { id } of await getServers()) {
for (const { id } of await WG_STORE.listServers()) {
const wg = new WGServer(id);
const server = await wg.get();
const hasInterface = await wg.isUp();
@@ -19,7 +21,7 @@ export const GET: RequestHandler = async () => {
}
}
} catch (e) {
logger.error('APIFailed: HealthCheck:', e);
errorBox(e);
return new Response('FAILED', { status: 500, headers: { 'Content-Type': 'text/plain' } });
}

View File

@@ -1,13 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { execa } from 'execa';
export const GET: RequestHandler = async ({ params }) => {
try {
const { stdout } = await execa('screen', ['-ls'], { shell: true });
const isRunning = stdout.includes(params.serviceName!);
return json({ healthy: isRunning });
} catch (e) {
return json({ healthy: false });
}
};

View File

@@ -0,0 +1,19 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { errorBox } from '$lib/logger';
import { getService } from '$lib/services';
export const GET: RequestHandler = async ({ params }) => {
try {
const service = getService(params.service);
if (!service) {
return json({ healthy: false });
}
const healthy = await service.health();
return json({ healthy });
} catch (e) {
errorBox(e);
return json({ healthy: false });
}
};

View File

@@ -1,8 +1,10 @@
import type { RequestHandler } from '@sveltejs/kit';
import logger from '$lib/logger';
import { execa } from 'execa';
import 'dotenv/config';
import type { RequestHandler } from '@sveltejs/kit';
import { execa } from 'execa';
import logger from '$lib/logger';
export const GET: RequestHandler = async () => {
let { WG_HOST } = process.env;

View File

@@ -1,14 +1,15 @@
import type { Actions } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { error, fail, type Actions } from '@sveltejs/kit';
import { setError, superValidate } from 'sveltekit-superforms';
import { formSchema } from './schema';
import { generateToken } from '$lib/auth';
import logger from '$lib/logger';
import { zod } from 'sveltekit-superforms/adapters';
import { env } from '$lib/env';
import { generateToken } from '$lib/auth';
import { AUTH_COOKIE } from '$lib/constants';
import { sha256 } from '$lib/hash';
import { env } from '$lib/env';
import logger, { errorBox } from '$lib/logger';
import { sha256 } from '$lib/utils/hash';
import type { PageServerLoad } from './$types';
import { formSchema } from './schema';
export const load: PageServerLoad = async () => {
return {
@@ -18,36 +19,37 @@ export const load: PageServerLoad = async () => {
export const actions: Actions = {
default: async (event) => {
const { cookies } = event;
const form = await superValidate(event, zod(formSchema));
try {
const { cookies } = event;
const form = await superValidate(event, zod(formSchema));
if (!form.valid) {
return fail(400, { ok: false, message: 'Bad Request', form });
}
if (!form.valid) {
logger.warn('Action: Login: failed to validate form.');
return fail(400, { ok: false, message: 'Bad Request', form });
}
const { HASHED_PASSWORD } = env;
if (HASHED_PASSWORD && HASHED_PASSWORD !== '') {
const { ADMIN_PASSWORD } = env;
const { password } = form.data;
const hashed = HASHED_PASSWORD.toLowerCase();
const receivedHashed = sha256(password).toLowerCase();
if (hashed !== receivedHashed) {
if (sha256(ADMIN_PASSWORD).toLowerCase() !== sha256(password).toLowerCase()) {
logger.debug('Action: Login: failed to validate password.');
return setError(form, 'password', 'Incorrect password.');
}
} else {
logger.warn('No password is set!');
logger.debug(`Action: Login: success. generating token.`);
const hour = 60 * 60;
const token = await generateToken({ expiresIn: hour });
cookies.set(AUTH_COOKIE, token, {
maxAge: hour,
httpOnly: true,
path: '/',
});
return { form, ok: true };
} catch (e) {
errorBox(e);
throw error(500, 'Unhandled Exception');
}
const token = await generateToken();
const secure = env.ORIGIN?.startsWith('https://') ?? false;
cookies.set(AUTH_COOKIE, token, {
secure,
httpOnly: true,
path: '/',
});
return { form, ok: true };
},
};

Some files were not shown because too many files have changed in this diff Show More