Version 15.0.0-beta.1: Rewrite in Nuxt and Typescript, Move to UI (#1333)

* Add Nuxt, ESM, Typescript (#1244)

* wip: add nuxt

* basic implementation

* add changes from c9ff248

* update workflow, add eslint

* add types, fix wrong error message

* install correct bcrypt, move eslint to dev modules

* add docker dev script

* fix styling

* add wireguard routes

* typescript, vendors

* fix lint workflow

* lint fixes

* add prettier, format

* fix lint, add vscode settings

* better typescript

* use auto imports

* add prettier eslint config

* cache config

* fix styling issue, fix formatting

* fix tailwind problems

* fix logout not showing

* fix lint action

* Fix session middleware

* split files into correct methods

* use type safe api, fix typescript errors

* better return types

not tested

* change default working directory

* update workflows

* fix error

* correct session middleware, type safe session

* convert undefined to boolean

* correct key for api errors

* use zod to validate input

* add more jobs to check for good code

* add pinia

Co-authored-by: Sergei Birukov <suxscribe@gmail.com>
Co-authored-by: Bernd Storath <bernd.storath@offizium.de>

* use color mode plugin

* !! use better storage key name

Breaking as if old key exists it breaks as "auto" is not compatible with new "system"

* better local dev while dev container is running

use `docker compose -f docker-compose.dev.yml up`
or after changing dockerfile
`docker compose -f docker-compose.dev.yml up --build`

* update translation to match new theme mode

* improve dx

new devs get extensions recommended to catch errors, etc directly in vscode

* reduce errors, improve typing

* Split components (#1)

* update: introduce pages & components

 fix lint

* update: starting split components

* use auto imports

* Improve workflows and docker

workflow fix step naming

simplify docker dev

simplify docker prod

revert to node 18

dockerfile naming scheme

* Split components (#2)

* update: starting split components

* upd: rebase & continue splitting components

- layouts: header & footer
- components: basic buttton
- pages: login page

* update: login page

* package.json: remove dev:pass script

* Split into Components, migrate to nuxt

fixup

shutdown wireguard properly

fix styling, fix store

split even more

clear interval

split even more

split even more

handle auth middleware on server

avoid flicker of login page

* fix: buttons spaces & move layouts to components (#3)

* update: icons into components

- fix: header login page

* fix: tailwind handle btn class

* Split into icons

fix avatar

move class to view not icon itself

fix icon

format

* invalidate cache to make restoreConfig work

* fix apexchart

* use different color mode module

other one resulted in hydration mismatch

* fix dialog

* fix bad i18n merge

* use nuxt 4

* fix typing, fix redirect, latest release on server

* start wireguard on start

* wait for shutdown

* improve zod errors, consistent server errors

* migrate to useFetch

this makes sure that there is no double fetching

* fix hydration issues, remove unnecessary state, rename function

* fetch globalstore globally

otherwise this will load on login to homepage

* migrate to useFetch

no javascript support

TODO: not properly tested

* update backend

* wip: frontend

* update frontend

* update pnpm lock

---------

Co-authored-by: Sergei Birukov <suxscribe@gmail.com>
Co-authored-by: Bernd Storath <bernd.storath@offizium.de>
Co-authored-by: tetuaoro <65575727+tetuaoro@users.noreply.github.com>

* Fix various issues

fix router param

fix max age

unit is seconds not ms

fix regressions

fix missing expire date in client create dialog
fix wrong type rules
fix wrong api endpoint

properly catch error running cron job

fix type issues

* add database (#1330)

* add: database abstraction

* update: get lang from database

* udpate: with repositories

* add: interfaces to connect a database provider

- easy swapping between database provider

* add: setup page

- add: in-memory database provider
- create a new account (signup)
- login with username and password
- first setup page to create an account
- PASSWORD_HASH was removed from environment and files was updated/removed due to that change

* update: Dockerfile

* fix: review done

- remove: REQUIRES_PASSWORD & RELEASE environment variables

* fix: i18n translation

- rename directories

* update: use database

* fix: typecheck

* fix: review

* rebase & add: persistent lowdb provider

* update: french translation

* revert: due to rebase

* remove & document

* Refactor New UI (#1342)

* refactor code

* refactor code

* add some todos

* update pnpm, start migrating to database

* add missing i18n key

* add todo

* basic setup styling

* nuxt 4 folder structure, update packages

* Feat: Migrations (#1344)

* add migrations

* improve migration runner

* improve migration runner

* document what each migration does

* Feat: Rewrite Wireguard to use Database (#1345)

* update wireguard

* update

* update

* remove all config

* move all features into one route

* improve code

* fix some issues

add wg_path, update documentation

* Feat: Cidr Support (#1347)

* cidr support

* add cidr

* fix some errors

fix server config

missing cidr block in server config

* Fix: Database Date type (#1349)

* Feat: IPv6 (#1354)

* start supporting ipv6

* add ipv6 support

* build server with es2020

es2019 doesn't support bigint

* fix issues, better naming

* Fix: Security (#1355)

* separate route for onboarding

* zse zod for validation

* use argon2id

* add build tools

* Feat: Server AllowedIPs, MTU (#1356)

* add wireguard helpers

* improve wireguard helpers

* add server mtu

* fix wg0.conf formatting

* add ipv6 support to docker compose and readme

* Feat: Docs (#1361)

* basic docs

* use semver versioning

* Feat: Migration (#1363)

* start migration

* improve migration

* remove endpoint from client

* improve docker

* Chore: Deprecate Dockerless (#1377)

* deprecate dockerless

* Feat: Improve Repository pattern (#1380)

* improve repository pattern

* fix errors

* Feat: Improve Database Handling (#1383)

* improve docker build

* build doc workflow

* Feat: Changelog, Release Notes (#1385)

* add changelog, use semver for update message

* use first line of release for short changelog

* load ipv6 iptables module

* Feat: Show Version in Footer (#1389)

update ui logic, always store release in global store.

new release logic uses rate limited github api, avoid using cache

* use i18n ally (#1391)

* improve gh actions

* Setup UI (#1392)

* update: setup ui page

* rebase

* remove script addition

* Fixed usage of Ukrainian instead of Russian in ru.json (#1414)

* Added translations for the Belarusian language (#1472)

* Install kmod from alpine repository (#1553)

Because the busybox modprobe utility is unable to load zstd compressed modules.

Co-authored-by: Matt <mmoore2012@users.noreply.github.com>

* WIP: Feat: UI, General Improvements (#1397)

* update: setup ui page

* remove script addition

* add admin panel

* basic user menu and admin page

* make usable admin panel

* add radix vue, improve ui

* fix features, add toast

* rewrite middleware logic, support basic auth

* add todo marker

* active tailwind forms

* remove some console.logs

* check if user is enabled

frontend doesn't handle this state yet, nothing will work as api routes will fail

* add email to user, basic account page

* better group database

* group even more

* basic statistics page

* update: admin ui

- add: common panel components to get same UI
- i18n: french

* update: setup page error handle

- use fetch error data to provide error message
- use translation to provider error message

* update: me page

* fix: :text props

* update: login page

* update: i18n french support

* fix: use radix toast duration

* update: reduce templates

- remake: setup page to add others step configuration (host/port/migration)

* udpate: setup page use wizard form step

* update: ui

* update: step page

- first step to choose a language
- use red color in light mode
- validate step before move toward

* update: setup page

- use radix select component to reduce boilerplate

* update: setup page

- add: database langugage method
- update: api lang & export supported languages

* update: setup page

- update ui select language
- change lang on selection

* fix: use global store

* fix: initial value

- update: sort langs by value

* fix: ui center paragraph

* fix: remove file extension & some revert

- add: script to run checks script

* update: setup page

- add: host/port section
- i18n: french
- fix: fallback translation

* refactor: split setup into files

* update: setup page

- redirect to login when the setup is done
- allow user to return to previous steps
- prompt error message
- i18n french

* add: migration UI step

- rename: components
- fix: label for & form child id
- i18n french sup

* add: migration server

* fix: use string instead of File

* improve: with zod validation

* restore: clients

* rework setup

* add client page, move api routes

* improve setup

* switch to agpl

* add step back

* update licensed under texts

cc -> agpl

* make db results readonly

avoid weird side effects, when modifying the db object as its only allowed inside e.g. lowdb.ts

* update footer links

* improve client edit page, add mtu

* reorder tailwind classes

* update packages

* update comments

* better toast, better avatar

* delete feature toggle

* remove chart, statistics from server

let user decide what he wants to display

* move into own components

* switch from AGPL-3.0-or-later to AGPL-3.0-only

AGPL-3.0-or-later is not OSI approved

* fix building source

fixes https://github.com/wg-easy/wg-easy/issues/1563

* update packages

---------

Co-authored-by: tetuaoro <65575727+tetuaoro@users.noreply.github.com>

* update readme

* Feat: Settings, UI, General Improvements (#1572)

* deprecate other languages

new ui has too many new strings

* fix wrong license in readme

* properly fetch release

* order safe data structure for migrations

* empty server allowed ips by default

* show userconfig in admin panel

* remove routes, fix config

* add ability to update clients

* handle form submit using js

avoid weird behavior with FormData

* global toast, be able to update client

* update packages

* fix date field

* delete client using radix dialog

* remove lang from backend, let users decide

* be able to change interface and general

* be able to update user config

* consistent allowedips

* fix array field

* improve avatar, code cleanup

* basic metrics support

* remove dateTime helper

* be able to change hooks

* start cidr update

* be able to update cidr

* Feat: SQLite (#1619)

* start drizzle migration

* split schema

* improve schema

* improve schema, cascade, unique

* improve structure, start migration

* migrate to sqlite

* work in prod docker

* start adding a better permission handler

* permission matrix, permission handler

* update packages

* move session timeout to session config, use new permission handler

* improve docker dev

only install dependencies if changed

* implement setup

* migrate to sqlite

* improve debug, fix custom migration

* migrate to sqlite

* regenerate migrations

* ignore autogenerated migrations from prettier

* migrate to sqlite

* migrate to sqlite

* Migrate to sqlite

* fix prod error

* move nuxt middleware from server to nuxt

* update corepack in prod dockerfile

* use correct branch for workflow

* make docker file build on armv6/v7

* fix client update

* update zod locales

* cancel pr workflow if new commit

* test concurrency

* Feat: Account (#1645)

* be able to change name, email

* be able to change password

* consistent naming

zod is a schema not a type

* use transaction instance

* add zod strings

* Feat: Prometheus (#1655)

* check metrics password

* rewrite prometheus and json metric endpoints

* move metrics to general

metrics is not per interface

* change metrics settings in admin panel

* add i18n keys

* Chore: Remove multi interface support (#1657)

* streamline references to wg0

database wg0 name makes no sense anymore
wg0 only in database, could be easily replaced, or support for custom name added

* fix default key gen

* Feat: Permission System (#1660)

* wip: add abac

* wip: add admin abac

* add me abac

* fix type issue

* move from role check

avoid authStore.userData?.role === roles.ADMIN

* Feat: Zod Generic String (#1661)

* start improving zod translations

* update zod translations

* Feat: Migration (#1663)

* show error for old env vars

* reorder setup, be able to migrate

* fix type issue

* footer and header in setup, remove lang setup step

* remove backup / restore

* refactor dialog (#1665)

* fixed Dockerfile HEALTHCHECK syntax (#1686)

HEALTHCHECK options should always come before the CMD instruction

* Feat: Info (#1666)

* add tooltip info, extract strings

* multi type toast

* improve useSubmit, i18n

* better login screen

* improve

* consistent folder casing

* consistent casing

* fix even more stuff

* temp

* fix type errors

* remove armv6/7 support for now

* add information to client page

* optimize dockerfile

* update base image in Dockerfile to use node:lts-alpine

* fix build stage

* Chore: TODOs (#1695)

* verify setup step

* improve readme

* format todos

* move id

* remove objectMessage

* style array field

* Chore: TODOs (#1696)

* fix chart

* replace localstorage with cookies

* Chore: Improvments (#1697)

* update packages

* fix tab issues

* consistent imports

* use eslint module

* update date

* improve docs

* update docs

* format

* fix docs, fix cookie

* recognize timing attack potential

* improve gh actions, issue templates (#1700)

* Feat improv (#1702)

* add insecure option, link readme to docs

* improve docs

* update version

* add warning to readme

---------

Co-authored-by: Sergei Birukov <suxscribe@gmail.com>
Co-authored-by: Bernd Storath <bernd.storath@offizium.de>
Co-authored-by: tetuaoro <65575727+tetuaoro@users.noreply.github.com>
Co-authored-by: laperuz92 <31198184+laperuz92@users.noreply.github.com>
Co-authored-by: Siomkin Alexander <siomkin.alexander@gmail.com>
Co-authored-by: Matt <102529127+mmoore2012@users.noreply.github.com>
Co-authored-by: Matt <mmoore2012@users.noreply.github.com>
Co-authored-by: Denis Kazimirov <rokiden@users.noreply.github.com>
This commit is contained in:
Bernd Storath
2025-03-05 13:06:31 +01:00
committed by GitHub
parent c6dce0f6fb
commit 9b29d72991
271 changed files with 21066 additions and 11474 deletions

View File

@@ -0,0 +1,4 @@
export default definePermissionEventHandler('admin', 'any', async () => {
const generalConfig = await Database.general.getConfig();
return generalConfig;
});

View File

@@ -0,0 +1,14 @@
import { GeneralUpdateSchema } from '#db/repositories/general/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(GeneralUpdateSchema, event)
);
await Database.general.update(data);
return { success: true };
}
);

View File

@@ -0,0 +1,4 @@
export default definePermissionEventHandler('admin', 'any', async () => {
const hooks = await Database.hooks.get();
return hooks;
});

View File

@@ -0,0 +1,15 @@
import { HooksUpdateSchema } from '#db/repositories/hooks/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(HooksUpdateSchema, event)
);
await Database.hooks.update(data);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,16 @@
import { InterfaceCidrUpdateSchema } from '#db/repositories/interface/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(InterfaceCidrUpdateSchema, event)
);
await Database.interfaces.updateCidr(data);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,8 @@
export default definePermissionEventHandler('admin', 'any', async () => {
const wgInterface = await Database.interfaces.get();
return {
...wgInterface,
privateKey: undefined,
};
});

View File

@@ -0,0 +1,15 @@
import { InterfaceUpdateSchema } from '#db/repositories/interface/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(InterfaceUpdateSchema, event)
);
await Database.interfaces.update(data);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,4 @@
export default definePermissionEventHandler('admin', 'any', async () => {
const userConfig = await Database.userConfigs.get();
return userConfig;
});

View File

@@ -0,0 +1,15 @@
import { UserConfigUpdateSchema } from '#db/repositories/userConfig/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(UserConfigUpdateSchema, event)
);
await Database.userConfigs.update(data);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,37 @@
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'view',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
if (!client) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
});
}
const config = await WireGuard.getClientConfiguration({ clientId });
const configName = client.name
.replace(/[^a-zA-Z0-9_=+.-]/g, '-')
.replace(/(-{2,}|-$)/g, '-')
.replace(/-$/, '')
.substring(0, 32);
setHeader(
event,
'Content-Disposition',
`attachment; filename="${configName || clientId}.conf"`
);
setHeader(event, 'Content-Type', 'text/plain');
return config;
}
);

View File

@@ -0,0 +1,19 @@
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.toggle(clientId, false);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,19 @@
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.toggle(clientId, false);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,18 @@
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.oneTimeLinks.generate(clientId);
return { success: true };
}
);

View File

@@ -0,0 +1,19 @@
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'delete',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.delete(clientId);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,23 @@
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'view',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const result = await Database.clients.get(clientId);
checkPermissions(result);
if (!result) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
});
}
return result;
}
);

View File

@@ -0,0 +1,28 @@
import {
ClientGetSchema,
ClientUpdateSchema,
} from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'update',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const data = await readValidatedBody(
event,
validateZod(ClientUpdateSchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
await Database.clients.update(clientId, data);
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,19 @@
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'view',
async ({ event, checkPermissions }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGetSchema, event)
);
const client = await Database.clients.get(clientId);
checkPermissions(client);
const svg = await WireGuard.getClientQRCodeSVG({ clientId });
setHeader(event, 'Content-Type', 'image/svg+xml');
return svg;
}
);

View File

@@ -0,0 +1,6 @@
export default definePermissionEventHandler('clients', 'custom', ({ user }) => {
if (user.role === roles.ADMIN) {
return WireGuard.getAllClients();
}
return WireGuard.getClientsForUser(user.id);
});

View File

@@ -0,0 +1,16 @@
import { ClientCreateSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'create',
async ({ event }) => {
const { name, expiresAt } = await readValidatedBody(
event,
validateZod(ClientCreateSchema, event)
);
await Database.clients.create({ name, expiresAt });
await WireGuard.saveConfig();
return { success: true };
}
);

View File

@@ -0,0 +1,17 @@
import { UserUpdateSchema } from '#db/repositories/user/types';
export default definePermissionEventHandler(
'me',
'update',
async ({ event, user, checkPermissions }) => {
const { name, email } = await readValidatedBody(
event,
validateZod(UserUpdateSchema, event)
);
checkPermissions(user);
await Database.users.update(user.id, name, email);
return { success: true };
}
);

View File

@@ -0,0 +1,17 @@
import { UserUpdatePasswordSchema } from '#db/repositories/user/types';
export default definePermissionEventHandler(
'me',
'update',
async ({ event, user, checkPermissions }) => {
const { newPassword, currentPassword } = await readValidatedBody(
event,
validateZod(UserUpdatePasswordSchema, event)
);
checkPermissions(user);
await Database.users.updatePassword(user.id, currentPassword, newPassword);
return { success: true };
}
);

View File

@@ -0,0 +1,11 @@
import { gt } from 'semver';
export default defineEventHandler(async () => {
const latestRelease = await cachedFetchLatestRelease();
const updateAvailable = gt(latestRelease.version, RELEASE);
return {
currentRelease: RELEASE,
latestRelease: latestRelease,
updateAvailable,
};
});

View File

@@ -0,0 +1,16 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
const sessionId = session.id;
if (sessionId === undefined) {
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});
}
await session.clear();
SERVER_DEBUG(`Deleted Session: ${sessionId}`);
return { success: true };
});

View File

@@ -0,0 +1,25 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
if (!session.data.userId) {
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});
}
const user = await Database.users.get(session.data.userId);
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'Not found in Database',
});
}
return {
id: user.id,
role: user.role,
username: user.username,
name: user.name,
email: user.email,
};
});

View File

@@ -0,0 +1,38 @@
import { UserLoginSchema } from '#db/repositories/user/types';
export default defineEventHandler(async (event) => {
const { username, password, remember } = await readValidatedBody(
event,
validateZod(UserLoginSchema, event)
);
// TODO: timing can be used to enumerate usernames
const user = await Database.users.getByUsername(username);
if (!user)
throw createError({
statusCode: 401,
statusMessage: 'Incorrect credentials',
});
const userHashPassword = user.password;
const passwordValid = await isPasswordValid(password, userHashPassword);
if (!passwordValid) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect credentials',
});
}
const session = await useWGSession(event, remember);
const data = await session.update({
userId: user.id,
});
// TODO?: create audit log
SERVER_DEBUG(`New Session: ${data.id} for ${user.id} (${user.username})`);
return { success: true };
});

View File

@@ -0,0 +1,13 @@
import { UserSetupSchema } from '#db/repositories/user/types';
export default defineSetupEventHandler(2, async ({ event }) => {
const { username, password } = await readValidatedBody(
event,
validateZod(UserSetupSchema, event)
);
await Database.users.create(username, password);
await Database.general.setSetupStep(3);
return { success: true };
});

View File

@@ -0,0 +1,13 @@
import { UserConfigSetupSchema } from '#db/repositories/userConfig/types';
export default defineSetupEventHandler(4, async ({ event }) => {
const { host, port } = await readValidatedBody(
event,
validateZod(UserConfigSetupSchema, event)
);
await Database.userConfigs.updateHostPort(host, port);
await Database.general.setSetupStep(0);
return { success: true };
});

View File

@@ -0,0 +1,76 @@
import { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import { z } from 'zod';
export default defineSetupEventHandler('migrate', async ({ event }) => {
const { file } = await readValidatedBody(
event,
validateZod(FileSchema, event)
);
const schema = z.object({
server: z.object({
privateKey: z.string(),
publicKey: z.string(),
// only used for cidr
address: z.string(),
}),
clients: z.record(
z.string(),
z.object({
// not used
id: z.string(),
name: z.string(),
address: z.string(),
privateKey: z.string(),
publicKey: z.string(),
preSharedKey: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
enabled: z.boolean(),
})
),
});
const res = await schema.safeParseAsync(JSON.parse(file));
if (!res.success) {
throw new Error('Invalid Config');
}
const oldConfig = res.data;
await Database.interfaces.updateKeyPair(
oldConfig.server.privateKey,
oldConfig.server.publicKey
);
const ipv4Cidr = parseCidr(oldConfig.server.address + '/24');
const ipv6Cidr = parseCidr('fdcc:ad94:bacf:61a4::cafe:0/112');
await Database.interfaces.updateCidr({
ipv4Cidr:
stringifyIp({ number: ipv4Cidr.start, version: 4 }) +
`/${ipv4Cidr.prefix}`,
ipv6Cidr: ipv6Cidr.cidr,
});
for (const clientId in oldConfig.clients) {
const clientConfig = oldConfig.clients[clientId];
if (!clientConfig) {
continue;
}
const clients = await Database.clients.getAll();
const ipv6Address = nextIP(6, ipv6Cidr, clients);
await Database.clients.createFromExisting({
...clientConfig,
ipv4Address: clientConfig.address,
ipv6Address,
});
}
await Database.general.setSetupStep(0);
return { success: true };
});

View File

@@ -0,0 +1,97 @@
CREATE TABLE `clients_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`ipv4_address` text NOT NULL,
`ipv6_address` text NOT NULL,
`private_key` text NOT NULL,
`public_key` text NOT NULL,
`pre_shared_key` text NOT NULL,
`expires_at` text,
`allowed_ips` text NOT NULL,
`server_allowed_ips` text NOT NULL,
`persistent_keepalive` integer NOT NULL,
`mtu` integer NOT NULL,
`dns` text NOT NULL,
`enabled` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE cascade ON DELETE restrict
);
--> statement-breakpoint
CREATE UNIQUE INDEX `clients_table_ipv4_address_unique` ON `clients_table` (`ipv4_address`);--> statement-breakpoint
CREATE UNIQUE INDEX `clients_table_ipv6_address_unique` ON `clients_table` (`ipv6_address`);--> statement-breakpoint
CREATE TABLE `general_table` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
`setup_step` integer NOT NULL,
`session_password` text NOT NULL,
`session_timeout` integer NOT NULL,
`metrics_prometheus` integer NOT NULL,
`metrics_json` integer NOT NULL,
`metrics_password` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `hooks_table` (
`id` text PRIMARY KEY NOT NULL,
`pre_up` text NOT NULL,
`post_up` text NOT NULL,
`pre_down` text NOT NULL,
`post_down` text NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`id`) REFERENCES `interfaces_table`(`name`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `interfaces_table` (
`name` text PRIMARY KEY NOT NULL,
`device` text NOT NULL,
`port` integer NOT NULL,
`private_key` text NOT NULL,
`public_key` text NOT NULL,
`ipv4_cidr` text NOT NULL,
`ipv6_cidr` text NOT NULL,
`mtu` integer NOT NULL,
`enabled` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `interfaces_table_port_unique` ON `interfaces_table` (`port`);--> statement-breakpoint
CREATE TABLE `one_time_links_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`one_time_link` text NOT NULL,
`expires_at` text NOT NULL,
`client_id` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `clients_table`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `one_time_links_table_one_time_link_unique` ON `one_time_links_table` (`one_time_link`);--> statement-breakpoint
CREATE TABLE `users_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password` text NOT NULL,
`email` text,
`name` text NOT NULL,
`role` integer NOT NULL,
`enabled` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
CREATE TABLE `user_configs_table` (
`id` text PRIMARY KEY NOT NULL,
`default_mtu` integer NOT NULL,
`default_persistent_keepalive` integer NOT NULL,
`default_dns` text NOT NULL,
`default_allowed_ips` text NOT NULL,
`host` text NOT NULL,
`port` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`id`) REFERENCES `interfaces_table`(`name`) ON UPDATE cascade ON DELETE cascade
);

View File

@@ -0,0 +1,18 @@
PRAGMA journal_mode=WAL;--> statement-breakpoint
INSERT INTO `general_table` (`setup_step`, `session_password`, `session_timeout`, `metrics_prometheus`, `metrics_json`)
VALUES (1, hex(randomblob(256)), 3600, 0, 0);
--> statement-breakpoint
INSERT INTO `interfaces_table` (`name`, `device`, `port`, `private_key`, `public_key`, `ipv4_cidr`, `ipv6_cidr`, `mtu`, `enabled`)
VALUES ('wg0', 'eth0', 51820, '---default---', '---default---', '10.8.0.0/24', 'fdcc:ad94:bacf:61a4::cafe:0/112', 1420, 1);
--> statement-breakpoint
INSERT INTO `hooks_table` (`id`, `pre_up`, `post_up`, `pre_down`, `post_down`)
VALUES (
'wg0',
'',
'iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT;',
'',
'iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT;'
);
--> statement-breakpoint
INSERT INTO `user_configs_table` (`id`, `default_mtu`, `default_persistent_keepalive`, `default_dns`, `default_allowed_ips`, `host`, `port`)
VALUES ('wg0', 1420, 0, '["1.1.1.1","2606:4700:4700::1111"]', '["0.0.0.0/0","::/0"]', '', 51820)

View File

@@ -0,0 +1,674 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b1dde023-d141-4eab-9226-89a832b2ed2b",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"clients_table": {
"name": "clients_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv4_address": {
"name": "ipv4_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv6_address": {
"name": "ipv6_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pre_shared_key": {
"name": "pre_shared_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_ips": {
"name": "allowed_ips",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"server_allowed_ips": {
"name": "server_allowed_ips",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"persistent_keepalive": {
"name": "persistent_keepalive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mtu": {
"name": "mtu",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dns": {
"name": "dns",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"clients_table_ipv4_address_unique": {
"name": "clients_table_ipv4_address_unique",
"columns": [
"ipv4_address"
],
"isUnique": true
},
"clients_table_ipv6_address_unique": {
"name": "clients_table_ipv6_address_unique",
"columns": [
"ipv6_address"
],
"isUnique": true
}
},
"foreignKeys": {
"clients_table_user_id_users_table_id_fk": {
"name": "clients_table_user_id_users_table_id_fk",
"tableFrom": "clients_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"general_table": {
"name": "general_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false,
"default": 1
},
"setup_step": {
"name": "setup_step",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_password": {
"name": "session_password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_timeout": {
"name": "session_timeout",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metrics_prometheus": {
"name": "metrics_prometheus",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metrics_json": {
"name": "metrics_json",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metrics_password": {
"name": "metrics_password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"hooks_table": {
"name": "hooks_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"pre_up": {
"name": "pre_up",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_up": {
"name": "post_up",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pre_down": {
"name": "pre_down",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_down": {
"name": "post_down",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {
"hooks_table_id_interfaces_table_name_fk": {
"name": "hooks_table_id_interfaces_table_name_fk",
"tableFrom": "hooks_table",
"tableTo": "interfaces_table",
"columnsFrom": [
"id"
],
"columnsTo": [
"name"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"interfaces_table": {
"name": "interfaces_table",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device": {
"name": "device",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"port": {
"name": "port",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv4_cidr": {
"name": "ipv4_cidr",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv6_cidr": {
"name": "ipv6_cidr",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mtu": {
"name": "mtu",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"interfaces_table_port_unique": {
"name": "interfaces_table_port_unique",
"columns": [
"port"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"one_time_links_table": {
"name": "one_time_links_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"one_time_link": {
"name": "one_time_link",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"one_time_links_table_one_time_link_unique": {
"name": "one_time_links_table_one_time_link_unique",
"columns": [
"one_time_link"
],
"isUnique": true
}
},
"foreignKeys": {
"one_time_links_table_client_id_clients_table_id_fk": {
"name": "one_time_links_table_client_id_clients_table_id_fk",
"tableFrom": "one_time_links_table",
"tableTo": "clients_table",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_configs_table": {
"name": "user_configs_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"default_mtu": {
"name": "default_mtu",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"default_persistent_keepalive": {
"name": "default_persistent_keepalive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"default_dns": {
"name": "default_dns",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"default_allowed_ips": {
"name": "default_allowed_ips",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"port": {
"name": "port",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {
"user_configs_table_id_interfaces_table_name_fk": {
"name": "user_configs_table_id_interfaces_table_name_fk",
"tableFrom": "user_configs_table",
"tableTo": "interfaces_table",
"columnsFrom": [
"id"
],
"columnsTo": [
"name"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,674 @@
{
"id": "720d420c-361f-4427-a45b-db0ca613934d",
"prevId": "b1dde023-d141-4eab-9226-89a832b2ed2b",
"version": "6",
"dialect": "sqlite",
"tables": {
"clients_table": {
"name": "clients_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv4_address": {
"name": "ipv4_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv6_address": {
"name": "ipv6_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pre_shared_key": {
"name": "pre_shared_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_ips": {
"name": "allowed_ips",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"server_allowed_ips": {
"name": "server_allowed_ips",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"persistent_keepalive": {
"name": "persistent_keepalive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mtu": {
"name": "mtu",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dns": {
"name": "dns",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"clients_table_ipv4_address_unique": {
"name": "clients_table_ipv4_address_unique",
"columns": [
"ipv4_address"
],
"isUnique": true
},
"clients_table_ipv6_address_unique": {
"name": "clients_table_ipv6_address_unique",
"columns": [
"ipv6_address"
],
"isUnique": true
}
},
"foreignKeys": {
"clients_table_user_id_users_table_id_fk": {
"name": "clients_table_user_id_users_table_id_fk",
"tableFrom": "clients_table",
"columnsFrom": [
"user_id"
],
"tableTo": "users_table",
"columnsTo": [
"id"
],
"onUpdate": "cascade",
"onDelete": "restrict"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"general_table": {
"name": "general_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false,
"default": 1
},
"setup_step": {
"name": "setup_step",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_password": {
"name": "session_password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_timeout": {
"name": "session_timeout",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metrics_prometheus": {
"name": "metrics_prometheus",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metrics_json": {
"name": "metrics_json",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metrics_password": {
"name": "metrics_password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"hooks_table": {
"name": "hooks_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"pre_up": {
"name": "pre_up",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_up": {
"name": "post_up",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pre_down": {
"name": "pre_down",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_down": {
"name": "post_down",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {
"hooks_table_id_interfaces_table_name_fk": {
"name": "hooks_table_id_interfaces_table_name_fk",
"tableFrom": "hooks_table",
"columnsFrom": [
"id"
],
"tableTo": "interfaces_table",
"columnsTo": [
"name"
],
"onUpdate": "cascade",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"interfaces_table": {
"name": "interfaces_table",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device": {
"name": "device",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"port": {
"name": "port",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv4_cidr": {
"name": "ipv4_cidr",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipv6_cidr": {
"name": "ipv6_cidr",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mtu": {
"name": "mtu",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"interfaces_table_port_unique": {
"name": "interfaces_table_port_unique",
"columns": [
"port"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"one_time_links_table": {
"name": "one_time_links_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"one_time_link": {
"name": "one_time_link",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"one_time_links_table_one_time_link_unique": {
"name": "one_time_links_table_one_time_link_unique",
"columns": [
"one_time_link"
],
"isUnique": true
}
},
"foreignKeys": {
"one_time_links_table_client_id_clients_table_id_fk": {
"name": "one_time_links_table_client_id_clients_table_id_fk",
"tableFrom": "one_time_links_table",
"columnsFrom": [
"client_id"
],
"tableTo": "clients_table",
"columnsTo": [
"id"
],
"onUpdate": "cascade",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_configs_table": {
"name": "user_configs_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"default_mtu": {
"name": "default_mtu",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"default_persistent_keepalive": {
"name": "default_persistent_keepalive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"default_dns": {
"name": "default_dns",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"default_allowed_ips": {
"name": "default_allowed_ips",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"port": {
"name": "port",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {
"user_configs_table_id_interfaces_table_name_fk": {
"name": "user_configs_table_id_interfaces_table_name_fk",
"tableFrom": "user_configs_table",
"columnsFrom": [
"id"
],
"tableTo": "interfaces_table",
"columnsTo": [
"name"
],
"onUpdate": "cascade",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1739266828300,
"tag": "0000_short_skin",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1739266837347,
"tag": "0001_classy_the_stranger",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,47 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { oneTimeLink, user } from '../../schema';
export const client = sqliteTable('clients_table', {
id: int().primaryKey({ autoIncrement: true }),
userId: int('user_id')
.notNull()
.references(() => user.id, {
onDelete: 'restrict',
onUpdate: 'cascade',
}),
name: text().notNull(),
ipv4Address: text('ipv4_address').notNull().unique(),
ipv6Address: text('ipv6_address').notNull().unique(),
privateKey: text('private_key').notNull(),
publicKey: text('public_key').notNull(),
preSharedKey: text('pre_shared_key').notNull(),
expiresAt: text('expires_at'),
allowedIps: text('allowed_ips', { mode: 'json' }).$type<string[]>().notNull(),
serverAllowedIps: text('server_allowed_ips', { mode: 'json' })
.$type<string[]>()
.notNull(),
persistentKeepalive: int('persistent_keepalive').notNull(),
mtu: int().notNull(),
dns: text({ mode: 'json' }).$type<string[]>().notNull(),
enabled: int({ mode: 'boolean' }).notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const clientsRelations = relations(client, ({ one }) => ({
oneTimeLink: one(oneTimeLink, {
fields: [client.id],
references: [oneTimeLink.clientId],
}),
user: one(user, {
fields: [client.userId],
references: [user.id],
}),
}));

View File

@@ -0,0 +1,179 @@
import { eq, sql } from 'drizzle-orm';
import { parseCidr } from 'cidr-tools';
import { client } from './schema';
import type {
ClientCreateFromExistingType,
ClientCreateType,
UpdateClientType,
} from './types';
import type { DBType } from '#db/sqlite';
import { wgInterface, userConfig } from '#db/schema';
function createPreparedStatement(db: DBType) {
return {
findAll: db.query.client
.findMany({
with: {
oneTimeLink: true,
},
})
.prepare(),
findById: db.query.client
.findFirst({ where: eq(client.id, sql.placeholder('id')) })
.prepare(),
findByUserId: db.query.client
.findMany({
where: eq(client.userId, sql.placeholder('userId')),
with: { oneTimeLink: true },
})
.prepare(),
toggle: db
.update(client)
.set({ enabled: sql.placeholder('enabled') as never as boolean })
.where(eq(client.id, sql.placeholder('id')))
.prepare(),
delete: db
.delete(client)
.where(eq(client.id, sql.placeholder('id')))
.prepare(),
};
}
export class ClientService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async getForUser(userId: ID) {
const result = await this.#statements.findByUserId.execute({ userId });
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
async getAll() {
const result = await this.#statements.findAll.execute();
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
get(id: ID) {
return this.#statements.findById.execute({ id });
}
async create({ name, expiresAt }: ClientCreateType) {
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
const preSharedKey = await wg.generatePreSharedKey();
let parsedExpiresAt = expiresAt;
if (parsedExpiresAt) {
const expiresAtDate = new Date(parsedExpiresAt);
expiresAtDate.setHours(23);
expiresAtDate.setMinutes(59);
expiresAtDate.setSeconds(59);
parsedExpiresAt = expiresAtDate.toISOString();
}
return this.#db.transaction(async (tx) => {
const clients = await tx.query.client.findMany().execute();
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
})
.execute();
if (!clientInterface) {
throw new Error('WireGuard interface not found');
}
const clientConfig = await tx.query.userConfig
.findFirst({
where: eq(userConfig.id, clientInterface.name),
})
.execute();
if (!clientConfig) {
throw new Error('WireGuard interface configuration not found');
}
const ipv4Cidr = parseCidr(clientInterface.ipv4Cidr);
const ipv4Address = nextIP(4, ipv4Cidr, clients);
const ipv6Cidr = parseCidr(clientInterface.ipv6Cidr);
const ipv6Address = nextIP(6, ipv6Cidr, clients);
await tx
.insert(client)
.values({
name,
// TODO: properly assign user id
userId: 1,
expiresAt: parsedExpiresAt,
privateKey,
publicKey,
preSharedKey,
ipv4Address,
ipv6Address,
mtu: clientConfig.defaultMtu,
allowedIps: clientConfig.defaultAllowedIps,
dns: clientConfig.defaultDns,
persistentKeepalive: clientConfig.defaultPersistentKeepalive,
serverAllowedIps: [],
enabled: true,
})
.execute();
});
}
toggle(id: ID, enabled: boolean) {
return this.#statements.toggle.execute({ id, enabled });
}
delete(id: ID) {
return this.#statements.delete.execute({ id });
}
update(id: ID, data: UpdateClientType) {
return this.#db.update(client).set(data).where(eq(client.id, id)).execute();
}
async createFromExisting({
name,
enabled,
ipv4Address,
ipv6Address,
preSharedKey,
privateKey,
publicKey,
}: ClientCreateFromExistingType) {
const clientConfig = await Database.userConfigs.get();
return this.#db
.insert(client)
.values({
name,
userId: 1,
privateKey,
publicKey,
preSharedKey,
ipv4Address,
ipv6Address,
mtu: clientConfig.defaultMtu,
allowedIps: clientConfig.defaultAllowedIps,
dns: clientConfig.defaultDns,
persistentKeepalive: clientConfig.defaultPersistentKeepalive,
serverAllowedIps: [],
enabled,
})
.execute();
}
}

View File

@@ -0,0 +1,83 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { client } from './schema';
export type ClientType = InferSelectModel<typeof client>;
export type ClientNextIpType = Pick<ClientType, 'ipv4Address' | 'ipv6Address'>;
export type CreateClientType = Omit<
ClientType,
'createdAt' | 'updatedAt' | 'id'
>;
export type UpdateClientType = Omit<
CreateClientType,
'privateKey' | 'publicKey' | 'preSharedKey' | 'userId'
>;
const name = z
.string({ message: t('zod.client.name') })
.min(1, t('zod.client.name'))
.pipe(safeStringRefine);
const expiresAt = z
.string({ message: t('zod.client.expiresAt') })
.min(1, t('zod.client.expiresAt'))
.pipe(safeStringRefine)
.nullable();
const address4 = z
.string({ message: t('zod.client.address4') })
.min(1, { message: t('zod.client.address4') })
.pipe(safeStringRefine);
const address6 = z
.string({ message: t('zod.client.address6') })
.min(1, { message: t('zod.client.address6') })
.pipe(safeStringRefine);
const serverAllowedIps = z.array(AddressSchema, {
message: t('zod.client.serverAllowedIps'),
});
export const ClientCreateSchema = z.object({
name: name,
expiresAt: expiresAt,
});
export type ClientCreateType = z.infer<typeof ClientCreateSchema>;
export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
z.object({
name: name,
enabled: EnabledSchema,
expiresAt: expiresAt,
ipv4Address: address4,
ipv6Address: address6,
allowedIps: AllowedIpsSchema,
serverAllowedIps: serverAllowedIps,
mtu: MtuSchema,
persistentKeepalive: PersistentKeepaliveSchema,
dns: DnsSchema,
})
);
// TODO: investigate if coerce is bad
const clientId = z.number({ message: t('zod.client.id'), coerce: true });
export const ClientGetSchema = z.object({
clientId: clientId,
});
export type ClientCreateFromExistingType = Pick<
ClientType,
| 'name'
| 'ipv4Address'
| 'ipv6Address'
| 'privateKey'
| 'preSharedKey'
| 'publicKey'
| 'enabled'
>;

View File

@@ -0,0 +1,23 @@
import { sql } from 'drizzle-orm';
import { sqliteTable, text, int } from 'drizzle-orm/sqlite-core';
export const general = sqliteTable('general_table', {
id: int().primaryKey({ autoIncrement: false }).default(1),
setupStep: int('setup_step').notNull(),
sessionPassword: text('session_password').notNull(),
sessionTimeout: int('session_timeout').notNull(),
metricsPrometheus: int('metrics_prometheus', { mode: 'boolean' }).notNull(),
metricsJson: int('metrics_json', { mode: 'boolean' }).notNull(),
metricsPassword: text('metrics_password'),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});

View File

@@ -0,0 +1,123 @@
import { sql } from 'drizzle-orm';
import { general } from './schema';
import type { GeneralUpdateType } from './types';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
return {
getSetupStep: db.query.general
.findFirst({
columns: {
setupStep: true,
},
})
.prepare(),
getSessionConfig: db.query.general
.findFirst({
columns: {
sessionPassword: true,
sessionTimeout: true,
},
})
.prepare(),
getMetricsConfig: db.query.general
.findFirst({
columns: {
metricsPrometheus: true,
metricsJson: true,
metricsPassword: true,
},
})
.prepare(),
getConfig: db.query.general
.findFirst({
columns: {
sessionTimeout: true,
metricsPrometheus: true,
metricsJson: true,
metricsPassword: true,
},
})
.prepare(),
updateSetupStep: db
.update(general)
.set({
setupStep: sql.placeholder('setupStep') as never as number,
})
.prepare(),
};
}
export class GeneralService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
/**
* @throws
*/
async getSetupStep() {
const result = await this.#statements.getSetupStep.execute();
if (!result) {
throw new Error('General Config not found');
}
return { step: result.setupStep, done: result.setupStep === 0 };
}
setSetupStep(step: number) {
return this.#statements.updateSetupStep.execute({ setupStep: step });
}
/**
* @throws
*/
async getSessionConfig() {
const result = await this.#statements.getSessionConfig.execute();
if (!result) {
throw new Error('General Config not found');
}
return {
sessionPassword: result.sessionPassword,
sessionTimeout: result.sessionTimeout,
};
}
/**
* @throws
*/
async getMetricsConfig() {
const result = await this.#statements.getMetricsConfig.execute();
if (!result) {
throw new Error('General Config not found');
}
return {
prometheus: result.metricsPrometheus,
json: result.metricsJson,
password: result.metricsPassword,
};
}
update(data: GeneralUpdateType) {
return this.#db.update(general).set(data).execute();
}
async getConfig() {
const result = await this.#statements.getConfig.execute();
if (!result) {
throw new Error('General Config not found');
}
return result;
}
}

View File

@@ -0,0 +1,26 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { general } from './schema';
export type GeneralType = InferSelectModel<typeof general>;
const sessionTimeout = z.number({ message: t('zod.general.sessionTimeout') });
const metricsEnabled = z.boolean({ message: t('zod.general.metricsEnabled') });
const metricsPassword = z
.string({ message: t('zod.general.metricsPassword') })
.min(1, { message: t('zod.general.metricsPassword') })
// TODO?: validate argon2 regex
.nullable();
export const GeneralUpdateSchema = z.object({
sessionTimeout: sessionTimeout,
metricsPrometheus: metricsEnabled,
metricsJson: metricsEnabled,
metricsPassword: metricsPassword,
});
export type GeneralUpdateType = z.infer<typeof GeneralUpdateSchema>;
export type SetupStepType = { step: number; done: boolean };

View File

@@ -0,0 +1,24 @@
import { sql } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../../schema';
export const hooks = sqliteTable('hooks_table', {
id: text()
.primaryKey()
.references(() => wgInterface.name, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),
preUp: text('pre_up').notNull(),
postUp: text('post_up').notNull(),
preDown: text('pre_down').notNull(),
postDown: text('post_down').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});

View File

@@ -0,0 +1,38 @@
import { eq, sql } from 'drizzle-orm';
import { hooks } from './schema';
import type { HooksUpdateType } from './types';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
return {
get: db.query.hooks
.findFirst({ where: eq(hooks.id, sql.placeholder('interface')) })
.prepare(),
};
}
export class HooksService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async get() {
const hooks = await this.#statements.get.execute({ interface: 'wg0' });
if (!hooks) {
throw new Error('Hooks not found');
}
return hooks;
}
update(data: HooksUpdateType) {
return this.#db
.update(hooks)
.set(data)
.where(eq(hooks.id, 'wg0'))
.execute();
}
}

View File

@@ -0,0 +1,18 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { hooks } from './schema';
export type HooksType = InferSelectModel<typeof hooks>;
export type HooksUpdateType = Omit<HooksType, 'id' | 'createdAt' | 'updatedAt'>;
const hook = z.string({ message: t('zod.hook') }).pipe(safeStringRefine);
export const HooksUpdateSchema = schemaForType<HooksUpdateType>()(
z.object({
preUp: hook,
postUp: hook,
preDown: hook,
postDown: hook,
})
);

View File

@@ -0,0 +1,36 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { userConfig, hooks } from '../../schema';
// maybe support multiple interfaces in the future
export const wgInterface = sqliteTable('interfaces_table', {
name: text().primaryKey(),
device: text().notNull(),
port: int().notNull().unique(),
privateKey: text('private_key').notNull(),
publicKey: text('public_key').notNull(),
ipv4Cidr: text('ipv4_cidr').notNull(),
ipv6Cidr: text('ipv6_cidr').notNull(),
mtu: int().notNull(),
// does nothing yet
enabled: int({ mode: 'boolean' }).notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const wgInterfaceRelations = relations(wgInterface, ({ one }) => ({
hooks: one(hooks, {
fields: [wgInterface.name],
references: [hooks.id],
}),
userConfig: one(userConfig, {
fields: [wgInterface.name],
references: [userConfig.id],
}),
}));

View File

@@ -0,0 +1,91 @@
import isCidr from 'is-cidr';
import { eq, sql } from 'drizzle-orm';
import { parseCidr } from 'cidr-tools';
import { wgInterface } from './schema';
import type { InterfaceCidrUpdateType, InterfaceUpdateType } from './types';
import { client as clientSchema } from '#db/schema';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
return {
get: db.query.wgInterface
.findFirst({ where: eq(wgInterface.name, sql.placeholder('interface')) })
.prepare(),
updateKeyPair: db
.update(wgInterface)
.set({
privateKey: sql.placeholder('privateKey') as never as string,
publicKey: sql.placeholder('publicKey') as never as string,
})
.where(eq(wgInterface.name, sql.placeholder('interface')))
.prepare(),
};
}
export class InterfaceService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async get() {
const wgInterface = await this.#statements.get.execute({
interface: 'wg0',
});
if (!wgInterface) {
throw new Error('Interface not found');
}
return wgInterface;
}
updateKeyPair(privateKey: string, publicKey: string) {
return this.#statements.updateKeyPair.execute({
interface: 'wg0',
privateKey,
publicKey,
});
}
update(data: InterfaceUpdateType) {
return this.#db
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.execute();
}
updateCidr(data: InterfaceCidrUpdateType) {
if (!isCidr(data.ipv4Cidr) || !isCidr(data.ipv6Cidr)) {
throw new Error('Invalid CIDR');
}
return this.#db.transaction(async (tx) => {
await tx
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.execute();
const clients = await tx.query.client.findMany().execute();
for (const client of clients) {
// TODO: optimize
const clients = await tx.query.client.findMany().execute();
const nextIpv4 = nextIP(4, parseCidr(data.ipv4Cidr), clients);
const nextIpv6 = nextIP(6, parseCidr(data.ipv6Cidr), clients);
await tx
.update(clientSchema)
.set({
ipv4Address: nextIpv4,
ipv6Address: nextIpv6,
})
.where(eq(clientSchema.id, client.id))
.execute();
}
});
}
}

View File

@@ -0,0 +1,49 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { wgInterface } from './schema';
export type InterfaceType = InferSelectModel<typeof wgInterface>;
export type InterfaceCreateType = Omit<
InterfaceType,
'createdAt' | 'updatedAt'
>;
export type InterfaceUpdateType = Omit<
InterfaceCreateType,
'name' | 'createdAt' | 'updatedAt' | 'privateKey' | 'publicKey'
>;
const device = z
.string({ message: t('zod.interface.device') })
.min(1, t('zod.interface.device'))
.pipe(safeStringRefine);
const cidr = z
.string({ message: t('zod.interface.cidr') })
.min(1, { message: t('zod.interface.cidr') })
.pipe(safeStringRefine);
export const InterfaceUpdateSchema = schemaForType<InterfaceUpdateType>()(
z.object({
ipv4Cidr: cidr,
ipv6Cidr: cidr,
mtu: MtuSchema,
port: PortSchema,
device: device,
enabled: EnabledSchema,
})
);
export type InterfaceCidrUpdateType = {
ipv4Cidr: string;
ipv6Cidr: string;
};
export const InterfaceCidrUpdateSchema =
schemaForType<InterfaceCidrUpdateType>()(
z.object({
ipv4Cidr: cidr,
ipv6Cidr: cidr,
})
);

View File

@@ -0,0 +1,27 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { client } from '../../schema';
export const oneTimeLink = sqliteTable('one_time_links_table', {
id: int().primaryKey({ autoIncrement: true }),
oneTimeLink: text('one_time_link').notNull().unique(),
expiresAt: text('expires_at').notNull(),
clientId: int('client_id')
.notNull()
.references(() => client.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const oneTimeLinksRelations = relations(oneTimeLink, ({ one }) => ({
client: one(client, {
fields: [oneTimeLink.clientId],
references: [client.id],
}),
}));

View File

@@ -0,0 +1,51 @@
import { eq, sql } from 'drizzle-orm';
import CRC32 from 'crc-32';
import { oneTimeLink } from './schema';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
return {
delete: db
.delete(oneTimeLink)
.where(eq(oneTimeLink.id, sql.placeholder('id')))
.prepare(),
create: db
.insert(oneTimeLink)
.values({
clientId: sql.placeholder('id'),
oneTimeLink: sql.placeholder('oneTimeLink'),
expiresAt: sql.placeholder('expiresAt'),
})
.prepare(),
erase: db
.update(oneTimeLink)
.set({ expiresAt: sql.placeholder('expiresAt') as never as string })
.where(eq(oneTimeLink.clientId, sql.placeholder('id')))
.prepare(),
};
}
export class OneTimeLinkService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
delete(id: ID) {
return this.#statements.delete.execute({ id });
}
generate(id: ID) {
const key = `${id}-${Math.floor(Math.random() * 1000)}`;
const oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
return this.#statements.create.execute({ id, oneTimeLink, expiresAt });
}
erase(id: ID) {
const expiresAt = Date.now() + 10 * 1000;
return this.#statements.erase.execute({ id, expiresAt });
}
}

View File

@@ -0,0 +1,14 @@
import type { InferSelectModel } from 'drizzle-orm';
import { z } from 'zod';
import type { oneTimeLink } from './schema';
export type OneTimeLinkType = InferSelectModel<typeof oneTimeLink>;
const oneTimeLinkType = z
.string({ message: t('zod.otl') })
.min(1, t('zod.otl'))
.pipe(safeStringRefine);
export const OneTimeLinkGetSchema = z.object({
oneTimeLink: oneTimeLinkType,
});

View File

@@ -0,0 +1,25 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { client } from '../../schema';
export const user = sqliteTable('users_table', {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
password: text().notNull(),
email: text(),
name: text().notNull(),
role: int().$type<Role>().notNull(),
enabled: int({ mode: 'boolean' }).notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const usersRelations = relations(user, ({ many }) => ({
clients: many(client),
}));

View File

@@ -0,0 +1,108 @@
import { eq, sql } from 'drizzle-orm';
import { user } from './schema';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
return {
findAll: db.query.user.findMany().prepare(),
findById: db.query.user
.findFirst({ where: eq(user.id, sql.placeholder('id')) })
.prepare(),
findByUsername: db.query.user
.findFirst({
where: eq(user.username, sql.placeholder('username')),
})
.prepare(),
update: db
.update(user)
.set({
name: sql.placeholder('name') as never as string,
email: sql.placeholder('email') as never as string,
})
.where(eq(user.id, sql.placeholder('id')))
.prepare(),
};
}
export class UserService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async getAll() {
return this.#statements.findAll.execute();
}
async get(id: ID) {
return this.#statements.findById.execute({ id });
}
async getByUsername(username: string) {
return this.#statements.findByUsername.execute({ username });
}
async create(username: string, password: string) {
const hash = await hashPassword(password);
return this.#db.transaction(async (tx) => {
const oldUser = await tx.query.user
.findFirst({
where: eq(user.username, username),
})
.execute();
if (oldUser) {
throw new Error('User already exists');
}
const userCount = await tx.$count(user);
await tx.insert(user).values({
password: hash,
username,
email: null,
name: 'Administrator',
role: userCount === 0 ? roles.ADMIN : roles.CLIENT,
enabled: true,
});
});
}
async update(id: ID, name: string, email: string | null) {
return this.#statements.update.execute({ id, name, email });
}
async updatePassword(id: ID, currentPassword: string, newPassword: string) {
const hash = await hashPassword(newPassword);
return this.#db.transaction(async (tx) => {
// get user again to avoid password changing while request
const txUser = await tx.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser) {
throw new Error('User not found');
}
const passwordValid = await isPasswordValid(
currentPassword,
txUser.password
);
if (!passwordValid) {
throw new Error('Invalid password');
}
await tx
.update(user)
.set({ password: hash })
.where(eq(user.id, id))
.execute();
});
}
}

View File

@@ -0,0 +1,59 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { user } from './schema';
export type UserType = InferSelectModel<typeof user>;
const username = z
.string({ message: t('zod.user.username') })
.min(8, t('zod.user.username'))
.pipe(safeStringRefine);
const password = z
.string({ message: t('zod.user.password') })
.min(12, t('zod.user.password'))
.regex(/[A-Z]/, t('zod.user.passwordUppercase'))
.regex(/[a-z]/, t('zod.user.passwordLowercase'))
.regex(/\d/, t('zod.user.passwordNumber'))
.regex(/[!@#$%^&*(),.?":{}|<>]/, t('zod.user.passwordSpecial'))
.pipe(safeStringRefine);
const remember = z.boolean({ message: t('zod.user.remember') });
export const UserLoginSchema = z.object({
username: username,
password: password,
remember: remember,
});
export const UserSetupSchema = z.object({
username: username,
password: password,
});
const name = z
.string({ message: t('zod.user.name') })
.min(1, 'zod.user.name')
.pipe(safeStringRefine);
const email = z
.string({ message: t('zod.user.email') })
.min(5, t('zod.user.email'))
.email({ message: t('zod.user.emailInvalid') })
.pipe(safeStringRefine)
.nullable();
export const UserUpdateSchema = z.object({
name: name,
email: email,
});
export const UserUpdatePasswordSchema = z
.object({
currentPassword: password,
newPassword: password,
confirmPassword: password,
})
.refine((val) => val.newPassword === val.confirmPassword, {
message: t('zod.user.passwordMatch'),
});

View File

@@ -0,0 +1,29 @@
import { sql } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../../schema';
// default* means clients store it themselves
export const userConfig = sqliteTable('user_configs_table', {
id: text()
.primaryKey()
.references(() => wgInterface.name, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),
defaultMtu: int('default_mtu').notNull(),
defaultPersistentKeepalive: int('default_persistent_keepalive').notNull(),
defaultDns: text('default_dns', { mode: 'json' }).$type<string[]>().notNull(),
defaultAllowedIps: text('default_allowed_ips', { mode: 'json' })
.$type<string[]>()
.notNull(),
host: text().notNull(),
port: int().notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});

View File

@@ -0,0 +1,56 @@
import { eq, sql } from 'drizzle-orm';
import { userConfig } from './schema';
import type { UserConfigUpdateType } from './types';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
return {
get: db.query.userConfig
.findFirst({ where: eq(userConfig.id, sql.placeholder('interface')) })
.prepare(),
updateHostPort: db
.update(userConfig)
.set({
host: sql.placeholder('host') as never as string,
port: sql.placeholder('port') as never as number,
})
.where(eq(userConfig.id, sql.placeholder('interface')))
.prepare(),
};
}
export class UserConfigService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async get() {
const userConfig = await this.#statements.get.execute({ interface: 'wg0' });
if (!userConfig) {
throw new Error('User config not found');
}
return userConfig;
}
updateHostPort(host: string, port: number) {
return this.#statements.updateHostPort.execute({
interface: 'wg0',
host,
port,
});
}
update(data: UserConfigUpdateType) {
return this.#db
.update(userConfig)
.set(data)
.where(eq(userConfig.id, 'wg0'))
.execute();
}
}

View File

@@ -0,0 +1,31 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { userConfig } from './schema';
export type UserConfigType = InferSelectModel<typeof userConfig>;
const host = z
.string({ message: t('zod.userConfig.host') })
.min(1, t('zod.userConfig.host'))
.pipe(safeStringRefine);
export const UserConfigSetupSchema = z.object({
host: host,
port: PortSchema,
});
export type UserConfigUpdateType = Omit<
UserConfigType,
'id' | 'createdAt' | 'updatedAt'
>;
export const UserConfigUpdateSchema = schemaForType<UserConfigUpdateType>()(
z.object({
port: PortSchema,
defaultMtu: MtuSchema,
defaultPersistentKeepalive: PersistentKeepaliveSchema,
defaultDns: DnsSchema,
defaultAllowedIps: AllowedIpsSchema,
host: host,
})
);

View File

@@ -0,0 +1,8 @@
// Make sure to not use any Path Aliases in these files
export * from './repositories/client/schema';
export * from './repositories/general/schema';
export * from './repositories/hooks/schema';
export * from './repositories/interface/schema';
export * from './repositories/oneTimeLink/schema';
export * from './repositories/user/schema';
export * from './repositories/userConfig/schema';

View File

@@ -0,0 +1,60 @@
import { drizzle } from 'drizzle-orm/libsql';
import { migrate as drizzleMigrate } from 'drizzle-orm/libsql/migrator';
import { createClient } from '@libsql/client';
import debug from 'debug';
import * as schema from './schema';
import { ClientService } from './repositories/client/service';
import { GeneralService } from './repositories/general/service';
import { UserService } from './repositories/user/service';
import { UserConfigService } from './repositories/userConfig/service';
import { InterfaceService } from './repositories/interface/service';
import { HooksService } from './repositories/hooks/service';
import { OneTimeLinkService } from './repositories/oneTimeLink/service';
const DB_DEBUG = debug('Database');
const client = createClient({ url: 'file:/etc/wireguard/wg-easy.db' });
const db = drizzle({ client, schema });
export async function connect() {
await migrate();
return new DBService(db);
}
class DBService {
clients: ClientService;
general: GeneralService;
users: UserService;
userConfigs: UserConfigService;
interfaces: InterfaceService;
hooks: HooksService;
oneTimeLinks: OneTimeLinkService;
constructor(db: DBType) {
this.clients = new ClientService(db);
this.general = new GeneralService(db);
this.users = new UserService(db);
this.userConfigs = new UserConfigService(db);
this.interfaces = new InterfaceService(db);
this.hooks = new HooksService(db);
this.oneTimeLinks = new OneTimeLinkService(db);
}
}
export type DBType = typeof db;
export type DBServiceType = DBService;
async function migrate() {
try {
DB_DEBUG('Migrating database...');
await drizzleMigrate(db, {
migrationsFolder: './server/database/migrations',
});
DB_DEBUG('Migration complete');
} catch (e) {
if (e instanceof Error) {
DB_DEBUG('Failed to migrate database:', e.message);
}
}
}

View File

@@ -0,0 +1,29 @@
/* First setup of wg-easy */
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
// User can't be logged in, and public routes can be accessed whenever
if (url.pathname.startsWith('/api/')) {
return;
}
const { step, done } = await Database.general.getSetupStep();
if (!done) {
const parsedSetup = url.pathname.match(/\/setup\/(\d)/);
if (!parsedSetup) {
return sendRedirect(event, `/setup/1`, 302);
}
const [_, currentSetup] = parsedSetup;
if (step.toString() === currentSetup) {
return;
}
return sendRedirect(event, `/setup/${step}`, 302);
} else {
// If already set up
if (!url.pathname.startsWith('/setup/')) {
return;
}
return sendRedirect(event, '/login', 302);
}
});

View File

@@ -0,0 +1,6 @@
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('close', async () => {
console.log('Shutting down');
await WireGuard.Shutdown();
});
});

View File

@@ -0,0 +1,29 @@
import { OneTimeLinkGetSchema } from '#db/repositories/oneTimeLink/types';
export default defineEventHandler(async (event) => {
const { oneTimeLink } = await getValidatedRouterParams(
event,
validateZod(OneTimeLinkGetSchema, event)
);
const clients = await WireGuard.getAllClients();
// TODO: filter on the database level
const client = clients.find(
(client) => client.oneTimeLink?.oneTimeLink === oneTimeLink
);
if (!client) {
throw createError({
statusCode: 404,
statusMessage: 'Invalid One Time Link',
});
}
const clientId = client.id;
const config = await WireGuard.getClientConfiguration({ clientId });
await Database.oneTimeLinks.erase(clientId);
setHeader(
event,
'Content-Disposition',
`attachment; filename="${client.name}.conf"`
);
setHeader(event, 'Content-Type', 'text/plain');
return config;
});

View File

@@ -0,0 +1,35 @@
export default defineMetricsHandler('json', async () => {
return getMetricsJSON();
});
async function getMetricsJSON() {
const clients = await WireGuard.getAllClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (isPeerConnected(client)) {
wireguardConnectedPeersCount++;
}
}
return {
wireguard_configured_peers: wireguardPeerCount,
wireguard_enabled_peers: wireguardEnabledPeersCount,
wireguard_connected_peers: wireguardConnectedPeersCount,
clients: clients.map((client) => ({
name: client.name,
enabled: client.enabled,
ipv4Address: client.ipv4Address,
ipv6Address: client.ipv6Address,
publicKey: client.publicKey,
endpoint: client.endpoint,
latestHandshakeAt: client.latestHandshakeAt,
transferRx: client.transferRx,
transferTx: client.transferTx,
})),
};
}

View File

@@ -0,0 +1,70 @@
export default defineMetricsHandler('prometheus', async ({ event }) => {
setHeader(event, 'Content-Type', 'text/plain');
return getPrometheusResponse();
});
async function getPrometheusResponse() {
const wgInterface = await Database.interfaces.get();
const clients = await WireGuard.getAllClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
const wireguardSentBytes = [];
const wireguardReceivedBytes = [];
const wireguardLatestHandshakeSeconds = [];
for (const client of clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (isPeerConnected(client)) {
wireguardConnectedPeersCount++;
}
const id = `interface="${wgInterface.name}",enabled="${client.enabled}",ipv4Address="${client.ipv4Address}",ipv6Address="${client.ipv6Address}",name="${client.name}"`;
wireguardSentBytes.push(
`wireguard_sent_bytes{${id}} ${client.transferTx ?? 0}`
);
wireguardReceivedBytes.push(
`wireguard_received_bytes{${id}} ${client.transferRx ?? 0}`
);
// TODO: if latestHandshakeAt is null this would result in client showing as online?
wireguardLatestHandshakeSeconds.push(
`wireguard_latest_handshake_seconds{${id}} ${client.latestHandshakeAt ? (Date.now() - client.latestHandshakeAt.getTime()) / 1000 : 0}`
);
}
const id = `interface="${wgInterface.name}"`;
const returnText = [
'# HELP wg-easy and wireguard metrics',
'',
'# HELP wireguard_configured_peers',
'# TYPE wireguard_configured_peers gauge',
`wireguard_configured_peers{${id}} ${wireguardPeerCount}`,
'',
'# HELP wireguard_enabled_peers',
'# TYPE wireguard_enabled_peers gauge',
`wireguard_enabled_peers{${id}} ${wireguardEnabledPeersCount}`,
'',
'# HELP wireguard_connected_peers',
'# TYPE wireguard_connected_peers gauge',
`wireguard_connected_peers{${id}} ${wireguardConnectedPeersCount}`,
'',
'# HELP wireguard_sent_bytes Bytes sent to the peer',
'# TYPE wireguard_sent_bytes counter',
`${wireguardSentBytes.join('\n')}`,
'',
'# HELP wireguard_received_bytes Bytes received from the peer',
'# TYPE wireguard_received_bytes counter',
`${wireguardReceivedBytes.join('\n')}`,
'',
'# HELP wireguard_latest_handshake_seconds UNIX timestamp seconds of the last handshake',
'# TYPE wireguard_latest_handshake_seconds gauge',
`${wireguardLatestHandshakeSeconds.join('\n')}`,
];
return returnText.join('\n');
}

3
src/server/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

View File

@@ -0,0 +1,24 @@
/**
* Changing the Database Provider
* This design allows for easy swapping of different database implementations.
*/
import { connect, type DBServiceType } from '#db/sqlite';
const nullObject = new Proxy(
{},
{
get() {
throw new Error('Database not yet initialized');
},
}
);
// eslint-disable-next-line import/no-mutable-exports
let provider = nullObject as never as DBServiceType;
connect().then((db) => {
provider = db;
WireGuard.Startup();
});
export default provider;

View File

@@ -0,0 +1,236 @@
import fs from 'node:fs/promises';
import debug from 'debug';
import QRCode from 'qrcode';
import type { InterfaceType } from '#db/repositories/interface/types';
const WG_DEBUG = debug('WireGuard');
class WireGuard {
/**
* Save and sync config
*/
async saveConfig() {
const wgInterface = await Database.interfaces.get();
await this.#saveWireguardConfig(wgInterface);
await this.#syncWireguardConfig(wgInterface);
}
/**
* Generates and saves WireGuard config from database
*
* Make sure to pass an updated InterfaceType object
*/
async #saveWireguardConfig(wgInterface: InterfaceType) {
const clients = await Database.clients.getAll();
const hooks = await Database.hooks.get();
const result = [];
result.push(wg.generateServerInterface(wgInterface, hooks));
for (const client of clients) {
if (!client.enabled) {
continue;
}
result.push(wg.generateServerPeer(client));
}
WG_DEBUG('Saving Config...');
await fs.writeFile(
`/etc/wireguard/${wgInterface.name}.conf`,
result.join('\n\n'),
{
mode: 0o600,
}
);
WG_DEBUG('Config saved successfully.');
}
async #syncWireguardConfig(wgInterface: InterfaceType) {
WG_DEBUG('Syncing Config...');
await wg.sync(wgInterface.name);
WG_DEBUG('Config synced successfully.');
}
async getClientsForUser(userId: ID) {
const wgInterface = await Database.interfaces.get();
const dbClients = await Database.clients.getForUser(userId);
const clients = dbClients.map((client) => ({
...client,
latestHandshakeAt: null as Date | null,
endpoint: null as string | null,
transferRx: null as number | null,
transferTx: null as number | null,
}));
// Loop WireGuard status
const dump = await wg.dump(wgInterface.name);
dump.forEach(
({ publicKey, latestHandshakeAt, endpoint, transferRx, transferTx }) => {
const client = clients.find((client) => client.publicKey === publicKey);
if (!client) {
return;
}
client.latestHandshakeAt = latestHandshakeAt;
client.endpoint = endpoint;
client.transferRx = transferRx;
client.transferTx = transferTx;
}
);
return clients;
}
async getAllClients() {
const wgInterface = await Database.interfaces.get();
const dbClients = await Database.clients.getAll();
const clients = dbClients.map((client) => ({
...client,
latestHandshakeAt: null as Date | null,
endpoint: null as string | null,
transferRx: null as number | null,
transferTx: null as number | null,
}));
// Loop WireGuard status
const dump = await wg.dump(wgInterface.name);
dump.forEach(
({ publicKey, latestHandshakeAt, endpoint, transferRx, transferTx }) => {
const client = clients.find((client) => client.publicKey === publicKey);
if (!client) {
return;
}
client.latestHandshakeAt = latestHandshakeAt;
client.endpoint = endpoint;
client.transferRx = transferRx;
client.transferTx = transferTx;
}
);
return clients;
}
async getClientConfiguration({ clientId }: { clientId: ID }) {
const wgInterface = await Database.interfaces.get();
const userConfig = await Database.userConfigs.get();
const client = await Database.clients.get(clientId);
if (!client) {
throw new Error('Client not found');
}
return wg.generateClientConfig(wgInterface, userConfig, client);
}
async getClientQRCodeSVG({ clientId }: { clientId: ID }) {
const config = await this.getClientConfiguration({ clientId });
return QRCode.toString(config, {
type: 'svg',
width: 512,
});
}
async Startup() {
WG_DEBUG('Starting WireGuard...');
// let as it has to refetch if keys change
let wgInterface = await Database.interfaces.get();
// default interface has no keys
if (
wgInterface.privateKey === '---default---' &&
wgInterface.publicKey === '---default---'
) {
WG_DEBUG('Generating new Wireguard Keys...');
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
await Database.interfaces.updateKeyPair(privateKey, publicKey);
wgInterface = await Database.interfaces.get();
WG_DEBUG('New Wireguard Keys generated successfully.');
}
WG_DEBUG(`Starting Wireguard Interface ${wgInterface.name}...`);
await this.#saveWireguardConfig(wgInterface);
await wg.down(wgInterface.name).catch(() => {});
await wg.up(wgInterface.name).catch((err) => {
if (
err &&
err.message &&
err.message.includes(`Cannot find device "${wgInterface.name}"`)
) {
throw new Error(
`WireGuard exited with the error: Cannot find device "${wgInterface.name}"\nThis usually means that your host's kernel does not support WireGuard!`,
{ cause: err.message }
);
}
throw err;
});
await this.#syncWireguardConfig(wgInterface);
WG_DEBUG(`Wireguard Interface ${wgInterface.name} started successfully.`);
WG_DEBUG('Starting Cron Job...');
await this.startCronJob();
WG_DEBUG('Cron Job started successfully.');
}
// TODO: handle as worker_thread
async startCronJob() {
setIntervalImmediately(() => {
this.cronJob().catch((err) => {
WG_DEBUG('Running Cron Job failed.');
console.error(err);
});
}, 60 * 1000);
}
// Shutdown wireguard
async Shutdown() {
const wgInterface = await Database.interfaces.get();
await wg.down(wgInterface.name).catch(() => {});
}
async cronJob() {
const clients = await Database.clients.getAll();
// Expires Feature
for (const client of clients) {
if (client.enabled !== true) continue;
if (
client.expiresAt !== null &&
new Date() > new Date(client.expiresAt)
) {
WG_DEBUG(`Client ${client.id} expired.`);
await Database.clients.toggle(client.id, false);
}
}
// One Time Link Feature
for (const client of clients) {
if (
client.oneTimeLink !== null &&
new Date() > new Date(client.oneTimeLink.expiresAt)
) {
WG_DEBUG(`Client ${client.id} One Time Link expired.`);
await Database.oneTimeLinks.delete(client.oneTimeLink.id);
}
}
await this.saveConfig();
}
}
if (OLD_ENV.PASSWORD || OLD_ENV.PASSWORD_HASH) {
// TODO: change url before release
throw new Error(
`
You are using an invalid Configuration for wg-easy
Please follow the instructions on https://wg-easy.github.io/wg-easy/ to migrate
`
);
}
// TODO: make static or object
export default new WireGuard();

32
src/server/utils/cmd.ts Normal file
View File

@@ -0,0 +1,32 @@
import childProcess from 'child_process';
import debug from 'debug';
const CMD_DEBUG = debug('CMD');
export function exec(
cmd: string,
{ log }: { log: boolean | string } = { log: true }
) {
if (typeof log === 'string') {
CMD_DEBUG(`$ ${log}`);
} else if (log === true) {
CMD_DEBUG(`$ ${cmd}`);
}
if (process.platform !== 'linux') {
return Promise.resolve('');
}
return new Promise<string>((resolve, reject) => {
childProcess.exec(
cmd,
{
shell: 'bash',
},
(err, stdout) => {
if (err) return reject(err);
return resolve(String(stdout).trim());
}
);
});
}

View File

@@ -0,0 +1,20 @@
import debug from 'debug';
import packageJson from '@@/package.json';
export const RELEASE = 'v' + packageJson.version;
export const SERVER_DEBUG = debug('Server');
export const OLD_ENV = {
/** @deprecated Only for migration purposes */
PASSWORD: process.env.PASSWORD,
/** @deprecated Only for migration purposes */
PASSWORD_HASH: process.env.PASSWORD_HASH,
};
export const WG_ENV = {
/** UI is hosted on HTTP instead of HTTPS */
INSECURE: process.env.INSECURE === 'true',
};
console.log(WG_ENV);

181
src/server/utils/handler.ts Normal file
View File

@@ -0,0 +1,181 @@
import type { EventHandlerRequest, EventHandlerResponse, H3Event } from 'h3';
import type { UserType } from '#db/repositories/user/types';
import type { SetupStepType } from '#db/repositories/general/types';
import {
type Permissions,
hasPermissionsWithData,
} from '#shared/utils/permissions';
type PermissionHandler<
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
Resource extends keyof Permissions,
> = {
(params: {
event: H3Event<TReq>;
user: UserType;
/**
* check if user has permissions to access the resource
*
* see: {@link hasPermissionsWithData}
*/
checkPermissions: (data?: Permissions[Resource]['dataType']) => true;
}): TRes;
};
/**
* get current user
*/
export const definePermissionEventHandler = <
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
Resource extends keyof Permissions,
>(
resource: Resource,
action: Permissions[Resource]['action'],
handler: PermissionHandler<TReq, TRes, Resource>
) => {
return defineEventHandler(async (event) => {
const user = await getCurrentUser(event);
const permissions = hasPermissionsWithData(user, resource, action);
// if no data is required, check permissions
if (permissions.isBoolean()) {
permissions.check();
}
const response = await handler({
event,
user,
checkPermissions: permissions.check,
});
// if data is required, make sure permissions were checked
if (!permissions.checked) {
throw createError({
statusCode: 500,
statusMessage: 'Permission was not checked',
});
}
return response;
});
};
// which api route is allowed for each setup step
// 0 is done, 1 is start
// 3 means step 2 is done
const ValidSetupSteps = {
1: [2] as const,
3: [4, 'migrate'] as const,
} as const;
type ValidSteps =
(typeof ValidSetupSteps)[keyof typeof ValidSetupSteps][number];
type SetupHandler<
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
> = { (params: { event: H3Event<TReq>; setup: SetupStepType }): TRes };
/**
* check if the setup is done, if not, run the handler
*/
export const defineSetupEventHandler = <
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
>(
step: ValidSteps,
handler: SetupHandler<TReq, TRes>
) => {
return defineEventHandler(async (event) => {
const setup = await Database.general.getSetupStep();
if (setup.done) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state',
});
}
const validSetupSteps =
ValidSetupSteps[setup.step as keyof typeof ValidSetupSteps];
if (!validSetupSteps) {
throw createError({
statusCode: 500,
statusMessage: 'Invalid setup step',
});
}
if (!validSetupSteps.includes(step as never)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid step',
});
}
return await handler({ event, setup });
});
};
type Metrics = 'prometheus' | 'json';
type MetricsHandler<
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
> = { (params: { event: H3Event<TReq> }): TRes };
/**
* check if the metrics are enabled and the token is correct
*/
export const defineMetricsHandler = <
TReq extends EventHandlerRequest,
TRes extends EventHandlerResponse,
>(
type: Metrics,
handler: MetricsHandler<TReq, TRes>
) => {
return defineEventHandler(async (event) => {
const auth = getHeader(event, 'Authorization');
if (!auth) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const [method, value] = auth.split(' ');
if (method !== 'Bearer' || !value) {
throw createError({
statusCode: 401,
statusMessage: 'Bearer Auth required',
});
}
const metricsConfig = await Database.general.getMetricsConfig();
if (metricsConfig[type] !== true) {
throw createError({
statusCode: 400,
statusMessage: 'Metrics not enabled',
});
}
if (metricsConfig.password) {
const tokenValid = await isPasswordValid(value, metricsConfig.password);
if (!tokenValid) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect token',
});
}
}
return await handler({ event });
});
};

33
src/server/utils/ip.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import type { ClientNextIpType } from '#db/repositories/client/types';
type ParsedCidr = ReturnType<typeof parseCidr>;
export function nextIP(
version: 4 | 6,
cidr: ParsedCidr,
clients: ClientNextIpType[]
) {
let address;
for (let i = cidr.start + 2n; i <= cidr.end - 1n; i++) {
const currentIp = stringifyIp({ number: i, version: version });
const client = clients.find((client) => {
return client[`ipv${version}Address`] === currentIp;
});
if (!client) {
address = currentIp;
break;
}
}
if (!address) {
throw new Error('Maximum number of clients reached', {
cause: `IPv${version} Address Pool exhausted`,
});
}
return address;
}

View File

@@ -0,0 +1,18 @@
import argon2 from 'argon2';
/**
* Checks if `password` matches the hash.
*/
export function isPasswordValid(
password: string,
hash: string
): Promise<boolean> {
return argon2.verify(hash, password);
}
/**
* Hashes a password.
*/
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password);
}

View File

@@ -0,0 +1,56 @@
type GithubRelease = {
tag_name: string;
body: string;
};
/**
* Cache function for 1 hour
*/
function cacheFunction<T>(fn: () => T): () => T {
let cache: { value: T; expiry: number } | null = null;
return (): T => {
const now = Date.now();
if (cache && cache.expiry > now) {
return cache.value;
}
const result = fn();
cache = {
value: result,
expiry: now + 3600000,
};
return result;
};
}
async function fetchLatestRelease() {
try {
const response = await $fetch<GithubRelease>(
'https://api.github.com/repos/wg-easy/wg-easy/releases/latest',
{ method: 'get', timeout: 5000 }
);
if (!response) {
throw new Error('Empty Response');
}
const changelog = response.body.split('\r\n\r\n')[0] ?? '';
return {
version: response.tag_name,
changelog,
};
} catch (e) {
SERVER_DEBUG('Failed to fetch latest releases: ', e);
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch latest release',
});
}
}
/**
* Fetch latest release from GitHub
* @cache Response is cached for 1 hour
*/
export const cachedFetchLatestRelease = cacheFunction(fetchLatestRelease);

111
src/server/utils/session.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { H3Event } from 'h3';
import type { UserType } from '#db/repositories/user/types';
export type WGSession = Partial<{
userId: ID;
}>;
const name = 'wg-easy';
export async function useWGSession(event: H3Event, rememberMe = false) {
const sessionConfig = await Database.general.getSessionConfig();
return useSession<WGSession>(event, {
password: sessionConfig.sessionPassword,
name,
// TODO: add session expiration
// maxAge: undefined
cookie: {
maxAge: rememberMe ? sessionConfig.sessionTimeout : undefined,
secure: !WG_ENV.INSECURE,
},
});
}
export async function getWGSession(event: H3Event) {
const sessionConfig = await Database.general.getSessionConfig();
return getSession<WGSession>(event, {
password: sessionConfig.sessionPassword,
name,
cookie: {
secure: !WG_ENV.INSECURE,
},
});
}
/**
* @throws
*/
export async function getCurrentUser(event: H3Event) {
const session = await getWGSession(event);
const authorization = getHeader(event, 'Authorization');
let user: UserType | undefined = undefined;
if (session.data.userId) {
// Handle if authenticating using Session
user = await Database.users.get(session.data.userId);
} else if (authorization) {
// Handle if authenticating using Header
const [method, value] = authorization.split(' ');
// Support Basic Authentication
// TODO: support personal access token or similar
if (method !== 'Basic' || !value) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid Basic Authorization',
});
}
const basicValue = Buffer.from(value, 'base64').toString('utf-8');
// Split by first ":"
const index = basicValue.indexOf(':');
const username = basicValue.substring(0, index);
const password = basicValue.substring(index + 1);
if (!username || !password) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid Basic Authorization',
});
}
// TODO: timing can be used to enumerate usernames
const foundUser = await Database.users.getByUsername(username);
if (!foundUser) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
}
const userHashPassword = foundUser.password;
const passwordValid = await isPasswordValid(password, userHashPassword);
if (!passwordValid) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
}
user = foundUser;
}
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed. User not found',
});
}
if (!user.enabled) {
throw createError({
statusCode: 403,
statusMessage: 'User is disabled',
});
}
return user;
}

View File

@@ -0,0 +1,26 @@
import type { InterfaceType } from '#db/repositories/interface/types';
/**
* Replace all {{key}} in the template with the values[key]
*/
export function template(templ: string, values: Record<string, string>) {
return templ.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return values[key] !== undefined ? values[key] : match;
});
}
/**
* Available keys:
* - ipv4Cidr: IPv4 CIDR
* - ipv6Cidr: IPv6 CIDR
* - device: Network device
* - port: Port number
*/
export function iptablesTemplate(templ: string, wgInterface: InterfaceType) {
return template(templ, {
ipv4Cidr: wgInterface.ipv4Cidr,
ipv6Cidr: wgInterface.ipv6Cidr,
device: wgInterface.device,
port: wgInterface.port.toString(),
});
}

145
src/server/utils/types.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { ZodSchema } from 'zod';
import z from 'zod';
import type { H3Event, EventHandlerRequest } from 'h3';
export type ID = number;
/**
* return the string as is
*
* used for i18n ally
*/
export const t = (v: string) => v;
export const safeStringRefine = z
.string()
.refine(
(v) => v !== '__proto__' && v !== 'constructor' && v !== 'prototype',
{ message: t('zod.stringMalformed') }
);
export const EnabledSchema = z.boolean({ message: t('zod.enabled') });
export const MtuSchema = z
.number({ message: t('zod.mtu') })
.min(1280, { message: t('zod.mtu') })
.max(9000, { message: t('zod.mtu') });
export const PortSchema = z
.number({ message: t('zod.port') })
.min(1, { message: t('zod.port') })
.max(65535, { message: t('zod.port') });
export const PersistentKeepaliveSchema = z
.number({ message: t('zod.persistentKeepalive') })
.min(0, t('zod.persistentKeepalive'))
.max(65535, t('zod.persistentKeepalive'));
export const AddressSchema = z
.string({ message: t('zod.address') })
.min(1, { message: t('zod.address') })
.pipe(safeStringRefine);
export const DnsSchema = z
.array(AddressSchema, { message: t('zod.dns') })
.min(1, t('zod.dns'));
export const AllowedIpsSchema = z
.array(AddressSchema, { message: t('zod.allowedIps') })
.min(1, { message: t('zod.allowedIps') });
export const FileSchema = z.object({
file: z.string({ message: t('zod.file') }),
});
export const schemaForType =
<T>() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<S extends z.ZodType<T, any, any>>(arg: S) => {
return arg;
};
export function validateZod<T>(
schema: ZodSchema<T>,
event: H3Event<EventHandlerRequest>
) {
return async (data: unknown) => {
try {
return await schema.parseAsync(data);
} catch (error) {
let message = 'Unexpected Error';
if (error instanceof z.ZodError) {
const t = await useTranslation(event);
message = error.issues
.map((v) => {
let m = v.message;
if (t) {
let newMessage = null;
if (v.message.startsWith('zod.')) {
switch (v.code) {
case 'too_small':
switch (v.type) {
case 'string':
newMessage = t('zod.generic.stringMin', [
t(v.message),
v.minimum,
]);
break;
case 'number':
newMessage = t('zod.generic.numberMin', [
t(v.message),
v.minimum,
]);
break;
}
break;
case 'invalid_type': {
if (v.received === 'null' || v.received === 'undefined') {
newMessage = t('zod.generic.required', [
v.path.join('.'),
]);
} else {
switch (v.expected) {
case 'string':
newMessage = t('zod.generic.validString', [
t(v.message),
]);
break;
case 'boolean':
newMessage = t('zod.generic.validBoolean', [
t(v.message),
]);
break;
case 'number':
newMessage = t('zod.generic.validNumber', [
t(v.message),
]);
break;
case 'array':
newMessage = t('zod.generic.validArray', [
t(v.message),
]);
break;
}
}
break;
}
}
}
if (newMessage) {
m = newMessage;
} else {
m = t(v.message);
}
}
return m;
})
.join('; ');
}
throw new Error(message);
}
};
}

View File

@@ -0,0 +1,140 @@
import { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import type { ClientType } from '#db/repositories/client/types';
import type { InterfaceType } from '#db/repositories/interface/types';
import type { UserConfigType } from '#db/repositories/userConfig/types';
import type { HooksType } from '#db/repositories/hooks/types';
export const wg = {
generateServerPeer: (client: Omit<ClientType, 'createdAt' | 'updatedAt'>) => {
const allowedIps = [
`${client.ipv4Address}/32`,
`${client.ipv6Address}/128`,
...(client.serverAllowedIps ?? []),
];
return `# Client: ${client.name} (${client.id})
[Peer]
PublicKey = ${client.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${allowedIps.join(', ')}`;
},
generateServerInterface: (wgInterface: InterfaceType, hooks: HooksType) => {
const cidr4 = parseCidr(wgInterface.ipv4Cidr);
const cidr6 = parseCidr(wgInterface.ipv6Cidr);
const ipv4Addr = stringifyIp({ number: cidr4.start + 1n, version: 4 });
const ipv6Addr = stringifyIp({ number: cidr6.start + 1n, version: 6 });
return `# Note: Do not edit this file directly.
# Your changes will be overwritten!
# Server
[Interface]
PrivateKey = ${wgInterface.privateKey}
Address = ${ipv4Addr}/${cidr4.prefix}, ${ipv6Addr}/${cidr6.prefix}
ListenPort = ${wgInterface.port}
MTU = ${wgInterface.mtu}
PreUp = ${iptablesTemplate(hooks.preUp, wgInterface)}
PostUp = ${iptablesTemplate(hooks.postUp, wgInterface)}
PreDown = ${iptablesTemplate(hooks.preDown, wgInterface)}
PostDown = ${iptablesTemplate(hooks.postDown, wgInterface)}`;
},
generateClientConfig: (
wgInterface: InterfaceType,
userConfig: UserConfigType,
client: ClientType
) => {
const cidr4Block = parseCidr(wgInterface.ipv4Cidr).prefix;
const cidr6Block = parseCidr(wgInterface.ipv6Cidr).prefix;
return `[Interface]
PrivateKey = ${client.privateKey}
Address = ${client.ipv4Address}/${cidr4Block}, ${client.ipv6Address}/${cidr6Block}
DNS = ${client.dns.join(', ')}
MTU = ${client.mtu}
[Peer]
PublicKey = ${wgInterface.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIps.join(', ')}
PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${userConfig.host}:${userConfig.port}`;
},
generatePrivateKey: () => {
return exec('wg genkey');
},
getPublicKey: (privateKey: string) => {
return exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey',
});
},
generatePreSharedKey: () => {
return exec('wg genpsk');
},
up: (infName: string) => {
return exec(`wg-quick up ${infName}`);
},
down: (infName: string) => {
return exec(`wg-quick down ${infName}`);
},
sync: (infName: string) => {
return exec(`wg syncconf ${infName} <(wg-quick strip ${infName})`);
},
dump: async (infName: string) => {
const rawDump = await exec(`wg show ${infName} dump`, {
log: false,
});
type wgDumpLine = [
string,
string,
string,
string,
string,
string,
string,
string,
];
return rawDump
.trim()
.split('\n')
.slice(1)
.map((line) => {
const splitLines = line.split('\t');
const [
publicKey,
preSharedKey,
endpoint,
allowedIps,
latestHandshakeAt,
transferRx,
transferTx,
persistentKeepalive,
] = splitLines as wgDumpLine;
return {
publicKey,
preSharedKey,
endpoint: endpoint === '(none)' ? null : endpoint,
allowedIps,
latestHandshakeAt:
latestHandshakeAt === '0'
? null
: new Date(Number.parseInt(`${latestHandshakeAt}000`)),
transferRx: Number.parseInt(transferRx),
transferTx: Number.parseInt(transferTx),
persistentKeepalive: persistentKeepalive,
};
});
},
};