Browse Source

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
pull/1648/head
Bernd Storath 6 months ago
committed by Bernd Storath
parent
commit
f05dcabd1b
  1. 2
      .github/workflows/deploy-development.yml
  2. 6
      .github/workflows/deploy-pr.yml
  3. 11
      .vscode/settings.json
  4. 13
      Dockerfile
  5. 20
      Dockerfile.dev
  6. 4
      package.json
  7. 2
      src/.gitignore
  8. 1
      src/.npmrc
  9. 1
      src/.prettierignore
  10. 4
      src/app/app.vue
  11. 2
      src/app/components/ClientCard/Address.vue
  12. 6
      src/app/components/ClientCard/OneTimeLinkBtn.vue
  13. 14
      src/app/components/admin/CidrDialog.vue
  14. 2
      src/app/components/ui/UserMenu.vue
  15. 28
      src/app/middleware/auth.global.ts
  16. 49
      src/app/pages/admin/config.vue
  17. 8
      src/app/pages/admin/hooks.vue
  18. 34
      src/app/pages/admin/interface.vue
  19. 12
      src/app/pages/clients/[id].vue
  20. 13
      src/app/stores/auth.ts
  21. 8
      src/app/stores/modal.ts
  22. 19
      src/app/utils/api.ts
  23. 10
      src/drizzle.config.ts
  24. 2
      src/eslint.config.mjs
  25. 75
      src/i18n/locales/en.json
  26. 9
      src/nuxt.config.ts
  27. 30
      src/package.json
  28. 3112
      src/pnpm-lock.yaml
  29. 8
      src/server/api/admin/general.get.ts
  30. 13
      src/server/api/admin/general.post.ts
  31. 9
      src/server/api/admin/hooks.get.ts
  32. 13
      src/server/api/admin/hooks.post.ts
  33. 8
      src/server/api/admin/hostport.post.ts
  34. 4
      src/server/api/admin/interface.get.ts
  35. 9
      src/server/api/admin/interface.post.ts
  36. 15
      src/server/api/admin/interface/cidr.post.ts
  37. 12
      src/server/api/admin/interface/index.get.ts
  38. 14
      src/server/api/admin/interface/index.post.ts
  39. 7
      src/server/api/admin/userconfig.get.ts
  40. 14
      src/server/api/admin/userconfig.post.ts
  41. 9
      src/server/api/admin/userconfig/cidr.post.ts
  42. 4
      src/server/api/admin/userconfig/index.get.ts
  43. 9
      src/server/api/admin/userconfig/index.post.ts
  44. 19
      src/server/api/client/[clientId]/configuration.get.ts
  45. 14
      src/server/api/client/[clientId]/disable.post.ts
  46. 14
      src/server/api/client/[clientId]/enable.post.ts
  47. 13
      src/server/api/client/[clientId]/generateOneTimeLink.post.ts
  48. 14
      src/server/api/client/[clientId]/index.delete.ts
  49. 20
      src/server/api/client/[clientId]/index.get.ts
  50. 22
      src/server/api/client/[clientId]/index.post.ts
  51. 11
      src/server/api/client/[clientId]/qrcode.svg.get.ts
  52. 2
      src/server/api/client/index.get.ts
  53. 16
      src/server/api/client/index.post.ts
  54. 2
      src/server/api/session.get.ts
  55. 20
      src/server/api/session.post.ts
  56. 17
      src/server/api/setup/4.post.ts
  57. 16
      src/server/api/setup/5.post.ts
  58. 18
      src/server/api/setup/migrate.post.ts
  59. 11
      src/server/api/wireguard/backup.get.ts
  60. 11
      src/server/api/wireguard/restore.put.ts
  61. 100
      src/server/database/migrations/0000_short_skin.sql
  62. 18
      src/server/database/migrations/0001_classy_the_stranger.sql
  63. 686
      src/server/database/migrations/meta/0000_snapshot.json
  64. 686
      src/server/database/migrations/meta/0001_snapshot.json
  65. 20
      src/server/database/migrations/meta/_journal.json
  66. 37
      src/server/database/repositories/client/schema.ts
  67. 128
      src/server/database/repositories/client/service.ts
  68. 72
      src/server/database/repositories/client/types.ts
  69. 16
      src/server/database/repositories/general/schema.ts
  70. 68
      src/server/database/repositories/general/service.ts
  71. 15
      src/server/database/repositories/general/types.ts
  72. 24
      src/server/database/repositories/hooks/schema.ts
  73. 34
      src/server/database/repositories/hooks/service.ts
  74. 18
      src/server/database/repositories/hooks/types.ts
  75. 39
      src/server/database/repositories/interface/schema.ts
  76. 90
      src/server/database/repositories/interface/service.ts
  77. 49
      src/server/database/repositories/interface/types.ts
  78. 21
      src/server/database/repositories/metrics/schema.ts
  79. 31
      src/server/database/repositories/metrics/service.ts
  80. 4
      src/server/database/repositories/metrics/types.ts
  81. 27
      src/server/database/repositories/oneTimeLink/schema.ts
  82. 52
      src/server/database/repositories/oneTimeLink/service.ts
  83. 17
      src/server/database/repositories/oneTimeLink/types.ts
  84. 19
      src/server/database/repositories/user/schema.ts
  85. 63
      src/server/database/repositories/user/service.ts
  86. 43
      src/server/database/repositories/user/types.ts
  87. 29
      src/server/database/repositories/userConfig/schema.ts
  88. 50
      src/server/database/repositories/userConfig/service.ts
  89. 31
      src/server/database/repositories/userConfig/types.ts
  90. 12
      src/server/database/schema.ts
  91. 63
      src/server/database/sqlite.ts
  92. 35
      src/server/middleware/auth.ts
  93. 94
      src/server/middleware/session.ts
  94. 6
      src/server/middleware/setup.ts
  95. 6
      src/server/routes/cnf/[oneTimeLink].ts
  96. 6
      src/server/routes/metrics/index.get.ts
  97. 6
      src/server/routes/metrics/json.get.ts
  98. 9
      src/server/utils/Database.ts
  99. 366
      src/server/utils/WireGuard.ts
  100. 7
      src/server/utils/cmd.ts

2
.github/workflows/deploy-development.yml

@ -13,8 +13,6 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: master
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

6
.github/workflows/deploy-pr.yml

@ -4,6 +4,10 @@ on:
workflow_dispatch:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Build & Deploy
@ -14,8 +18,6 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: master
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

11
.vscode/settings.json

@ -12,13 +12,12 @@
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "./src/node_modules/typescript/lib",
"i18n-ally.enabledFrameworks": [
"vue"
],
"i18n-ally.localesPaths": [
"src/i18n/locales"
],
"i18n-ally.enabledFrameworks": ["vue"],
"i18n-ally.localesPaths": ["src/i18n/locales"],
"i18n-ally.sortKeys": false,
"i18n-ally.keepFulfilled": false,
"i18n-ally.keystyle": "nested",

13
Dockerfile

@ -2,6 +2,8 @@
FROM docker.io/library/node:18-alpine AS build
WORKDIR /app
# update corepack
RUN npm install --global corepack@latest
# Install pnpm
RUN corepack enable pnpm
@ -15,6 +17,10 @@ RUN pnpm install
# Build UI
RUN pnpm build
# Remove unnecessary node modules
RUN find ./node_modules/.pnpm -mindepth 1 -maxdepth 1 -type d ! -name '@libsql+linux*' -exec rm -r {} +
RUN find ./node_modules/@libsql -mindepth 1 -maxdepth 1 -type l ! -name 'linux*' -exec rm -r {} +
# Copy build result to a new image.
# This saves a lot of disk space.
FROM docker.io/library/node:lts-alpine
@ -24,6 +30,11 @@ HEALTHCHECK CMD /usr/bin/timeout 5s /bin/sh -c "/usr/bin/wg show | /bin/grep -q
# Copy build
COPY --from=build /app/.output /app
# Copy migrations
COPY --from=build /app/server/database/migrations /app/server/database/migrations
# libsql
COPY --from=build /app/node_modules/.pnpm/ /app/node_modules/.pnpm/
COPY --from=build /app/node_modules/@libsql /app/node_modules/@libsql
# Install Linux packages
RUN apk add --no-cache \
@ -40,7 +51,7 @@ RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables
RUN update-alternatives --install /usr/sbin/ip6tables ip6tables /usr/sbin/ip6tables-legacy 10 --slave /usr/sbin/ip6tables-restore ip6tables-restore /usr/sbin/ip6tables-legacy-restore --slave /usr/sbin/ip6tables-save ip6tables-save /usr/sbin/ip6tables-legacy-save
# Set Environment
ENV DEBUG=Server,WireGuard,LowDB
ENV DEBUG=Server,WireGuard,Database,CMD
ENV PORT=51821
ENV HOST=0.0.0.0

20
Dockerfile.dev

@ -1,15 +1,11 @@
# As a workaround we have to build on nodejs 18
# nodejs 20 hangs on build with armv6/armv7
FROM docker.io/library/node:20-alpine
FROM docker.io/library/node:lts-alpine
WORKDIR /app
# update corepack
RUN npm install --global corepack@latest
# Install pnpm
RUN corepack enable pnpm
# Copy Web UI
COPY src ./
RUN pnpm install
HEALTHCHECK CMD /usr/bin/timeout 5s /bin/sh -c "/usr/bin/wg show | /bin/grep -q interface || exit 1" --interval=1m --timeout=5s --retries=3
# Install Linux packages
@ -27,6 +23,14 @@ RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables
RUN update-alternatives --install /usr/sbin/ip6tables ip6tables /usr/sbin/ip6tables-legacy 10 --slave /usr/sbin/ip6tables-restore ip6tables-restore /usr/sbin/ip6tables-legacy-restore --slave /usr/sbin/ip6tables-save ip6tables-save /usr/sbin/ip6tables-legacy-save
# Set Environment
ENV DEBUG=Server,WireGuard,LowDB
ENV DEBUG=Server,WireGuard,Database,CMD
ENV PORT=51821
ENV HOST=0.0.0.0
# Install Dependencies
COPY src/package.json src/pnpm-lock.yaml ./
RUN pnpm install
# Copy Project
COPY src ./

4
package.json

@ -2,8 +2,8 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "docker compose -f docker-compose.dev.yml up",
"dev": "docker compose -f docker-compose.dev.yml up --build",
"build": "docker build -t wg-easy ."
},
"packageManager": "pnpm@9.15.3"
"packageManager": "pnpm@10.2.0"
}

2
src/.gitignore

@ -22,3 +22,5 @@ logs
.env
.env.*
!.env.example
wg0.db

1
src/.npmrc

@ -0,0 +1 @@
public-hoist-pattern[]=@libsql/linux*

1
src/.prettierignore

@ -1 +1,2 @@
pnpm-lock.yaml
server/database/migrations/meta

4
src/app/app.vue

@ -5,7 +5,7 @@
<ToastViewport
class="fixed bottom-0 right-0 z-[2147483647] m-0 flex w-[390px] max-w-[100vw] list-none flex-col gap-[10px] p-[var(--viewport-padding)] outline-none [--viewport-padding:_25px]"
>
<BaseToast ref="toast" />
<BaseToast ref="toastRef" />
</ToastViewport>
</NuxtLayout>
</ToastProvider>
@ -13,7 +13,7 @@
<script setup lang="ts">
const toast = useToast();
const toastRef = useTemplateRef('toast');
const toastRef = useTemplateRef('toastRef');
toast.setToast(toastRef);
useHead({

2
src/app/components/ClientCard/Address.vue

@ -1,6 +1,6 @@
<template>
<span class="inline-block">
{{ client.address4 }}, {{ client.address6 }}
{{ client.ipv4Address }}, {{ client.ipv6Address }}
</span>
</template>

6
src/app/components/ClientCard/OneTimeLinkBtn.vue

@ -27,8 +27,10 @@ defineProps<{ client: LocalClient }>();
const clientsStore = useClientsStore();
function showOneTimeLink(client: LocalClient) {
api
.showOneTimeLink({ clientId: client.id })
// TODO: improve
$fetch(`/api/client/${client.id}/generateOneTimeLink`, {
method: 'post',
})
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}

14
src/app/components/admin/CidrDialog.vue

@ -17,8 +17,8 @@
class="mb-5 mt-2 text-sm leading-normal text-gray-500 dark:text-neutral-300"
>
<FormGroup>
<FormTextField id="address4" v-model="address4" label="IPv4" />
<FormTextField id="address6" v-model="address6" label="IPv6" />
<FormTextField id="ipv4Cidr" v-model="ipv4Cidr" label="IPv4" />
<FormTextField id="ipv6Cidr" v-model="ipv6Cidr" label="IPv6" />
</FormGroup>
</DialogDescription>
<div class="mt-6 flex justify-end gap-2">
@ -26,7 +26,7 @@
<BaseButton>{{ $t('cancel') }}</BaseButton>
</DialogClose>
<DialogClose as-child>
<BaseButton @click="$emit('change', address4, address6)"
<BaseButton @click="$emit('change', ipv4Cidr, ipv6Cidr)"
>Change</BaseButton
>
</DialogClose>
@ -40,10 +40,10 @@
defineEmits(['change']);
const props = defineProps<{
triggerClass?: string;
address4: string;
address6: string;
ipv4Cidr: string;
ipv6Cidr: string;
}>();
const address4 = ref(props.address4);
const address6 = ref(props.address6);
const ipv4Cidr = ref(props.ipv4Cidr);
const ipv6Cidr = ref(props.ipv6Cidr);
</script>

2
src/app/components/ui/UserMenu.vue

@ -37,7 +37,7 @@
Account
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem v-if="authStore.userData?.role === 'ADMIN'">
<DropdownMenuItem v-if="authStore.userData?.role === roles.ADMIN">
<NuxtLink
to="/admin"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"

28
src/app/middleware/auth.global.ts

@ -0,0 +1,28 @@
export default defineNuxtRouteMiddleware(async (to) => {
// api & setup handled server side
if (to.path.startsWith('/api/') || to.path.startsWith('/setup')) {
return;
}
const authStore = useAuthStore();
const userData = await authStore.getSession();
// skip login if already logged in
if (to.path === '/login') {
if (userData?.username) {
return navigateTo('/', { redirectCode: 302 });
}
return;
}
// Require auth for every page other than Login
if (!userData?.username) {
return navigateTo('/login', { redirectCode: 302 });
}
// Check for admin access
if (to.path.startsWith('/admin')) {
if (userData.role !== roles.ADMIN) {
return abortNavigation('Not allowed to access Admin Panel');
}
}
});

49
src/app/pages/admin/config.vue

@ -8,7 +8,10 @@
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIps" name="allowedIps" />
<FormArrayField
v-model="data.defaultAllowedIps"
name="defaultAllowedIps"
/>
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
@ -16,10 +19,14 @@
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="keepalive"
v-model="data.persistentKeepalive"
id="defaultMtu"
v-model="data.defaultMtu"
label="MTU"
/>
<FormNumberField
id="defaultPersistentKeepalive"
v-model="data.defaultPersistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
@ -27,14 +34,6 @@
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<AdminCidrDialog
trigger-class="col-span-2"
:address6="data.address6Range"
:address4="data.address4Range"
@change="changeCidr"
>
<FormActionField label="Change CIDR" class="w-full" />
</AdminCidrDialog>
</FormGroup>
</FormElement>
</main>
@ -79,30 +78,4 @@ async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
async function changeCidr(address4: string, address6: string) {
try {
const res = await $fetch(`/api/admin/userconfig/cidr`, {
method: 'post',
body: { address4, address6 },
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Changed CIDR',
});
if (!res.success) {
throw new Error('Failed to change CIDR');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
</script>

8
src/app/pages/admin/hooks.vue

@ -2,10 +2,10 @@
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormTextField id="PreUp" v-model="data.PreUp" label="PreUp" />
<FormTextField id="PostUp" v-model="data.PostUp" label="PostUp" />
<FormTextField id="PreDown" v-model="data.PreDown" label="PreDown" />
<FormTextField id="PostDown" v-model="data.PostDown" label="PostDown" />
<FormTextField id="PreUp" v-model="data.preUp" label="PreUp" />
<FormTextField id="PostUp" v-model="data.postUp" label="PostUp" />
<FormTextField id="PreDown" v-model="data.preDown" label="PreDown" />
<FormTextField id="PostDown" v-model="data.postDown" label="PostDown" />
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>

34
src/app/pages/admin/interface.vue

@ -11,6 +11,14 @@
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<AdminCidrDialog
trigger-class="col-span-2"
:ipv4-cidr="data.ipv4Cidr"
:ipv6-cidr="data.ipv6Cidr"
@change="changeCidr"
>
<FormActionField label="Change CIDR" class="w-full" />
</AdminCidrDialog>
</FormGroup>
</FormElement>
</main>
@ -55,4 +63,30 @@ async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
async function changeCidr(ipv4Cidr: string, ipv6Cidr: string) {
try {
const res = await $fetch(`/api/admin/interface/cidr`, {
method: 'post',
body: { ipv4Cidr, ipv6Cidr },
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Changed CIDR',
});
if (!res.success) {
throw new Error('Failed to change CIDR');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
</script>

12
src/app/pages/clients/[id].vue

@ -25,13 +25,13 @@
<FormGroup>
<FormHeading>Address</FormHeading>
<FormTextField
id="address4"
v-model.trim="data.address4"
id="ipv4Address"
v-model.trim="data.ipv4Address"
label="IPv4"
/>
<FormTextField
id="address6"
v-model.trim="data.address6"
id="ipv6Address"
v-model.trim="data.ipv6Address"
label="IPv6"
/>
</FormGroup>
@ -42,8 +42,8 @@
<FormGroup>
<FormHeading>Server Allowed IPs</FormHeading>
<FormArrayField
v-model="data.serverAllowedIPs"
name="serverAllowedIPs"
v-model="data.serverAllowedIps"
name="serverAllowedIps"
/>
</FormGroup>
<FormGroup></FormGroup>

13
src/app/stores/auth.ts

@ -3,6 +3,17 @@ export const useAuthStore = defineStore('Auth', () => {
method: 'get',
});
async function getSession() {
try {
const { data } = await useFetch('/api/session', {
method: 'get',
});
return data.value;
} catch {
return null;
}
}
/**
* @throws if unsuccessful
*/
@ -24,5 +35,5 @@ export const useAuthStore = defineStore('Auth', () => {
return response.success;
}
return { userData, login, logout, update };
return { userData, login, logout, update, getSession };
});

8
src/app/stores/modal.ts

@ -9,11 +9,13 @@ export const useModalStore = defineStore('Modal', () => {
function createClient() {
const name = clientCreateName.value;
const expireDate = clientExpireDate.value || null;
const expiresAt = clientExpireDate.value || null;
if (!name) return;
api
.createClient({ name, expireDate })
$fetch('/api/client', {
method: 'post',
body: { name, expiresAt },
})
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}

19
src/app/utils/api.ts

@ -5,25 +5,6 @@ class API {
});
}
async createClient({
name,
expireDate,
}: {
name: string;
expireDate: string | null;
}) {
return $fetch('/api/client', {
method: 'post',
body: { name, expireDate },
});
}
async showOneTimeLink({ clientId }: { clientId: string }) {
return $fetch(`/api/client/${clientId}/generateOneTimeLink`, {
method: 'post',
});
}
async restoreConfiguration(file: string) {
return $fetch('/api/wireguard/restore', {
method: 'put',

10
src/drizzle.config.ts

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './server/database/migrations',
schema: './server/database/schema.ts',
dialect: 'sqlite',
dbCredentials: {
url: 'file:./wg0.db',
},
});

2
src/eslint.config.mjs

@ -2,3 +2,5 @@ import { createConfigForNuxt } from '@nuxt/eslint-config/flat';
import eslintConfigPrettier from 'eslint-config-prettier';
export default createConfigForNuxt().append(eslintConfigPrettier);
// TODO: add typescript-eslint, import/order, ban raw defineEventHandler

75
src/i18n/locales/en.json

@ -39,26 +39,19 @@
"migration": "Restore the backup"
},
"zod": {
"stringMalformed": "String is malformed",
"id": "Client ID must be a valid UUID",
"address": "IP Address must be a valid string",
"addressMin": "IP Address must be a be at least 1 Character",
"client": {
"id": "Client ID must be a valid number",
"name": "Name must be a valid string",
"nameMin": "Name must be at least 1 Character",
"expireDate": "expiredDate must be a valid string",
"expireDateMin": "expiredDate must be at least 1 Character",
"address4": "IPv4 Address must be a valid string",
"address4Min": "IPv4 Address must be a be at least 1 Character",
"address6": "IPv6 Address must be a valid string",
"address6Min": "IPv6 Address must be a be at least 1 Character",
"allowedIps": "Allowed IPs must be a valid array of strings",
"allowedIpsMin": "Allowed IPs must have at least 1 item",
"serverAllowedIps": "Allowed IPs must be a valid array of strings",
"name": "Name must be a valid string",
"nameMin": "Name must be at least 1 Character",
"mtu": "MTU must be a valid number",
"mtuMin": "MTU must be at least 1280",
"mtuMax": "MTU must be at most 9000",
"persistentKeepalive": "Persistent Keepalive must be a valid number",
"persistentKeepaliveMin": "Persistent Keepalive must be at least 0",
"persistentKeepaliveMax": "Persistent Keepalive must be at most 65535",
"file": "File must be a valid string",
"serverAllowedIps": "Allowed IPs must be a valid array of strings"
},
"user": {
"username": "Username must be a valid string",
"usernameMin": "Username must be at least 8 Characters",
"password": "Password must be a valid string",
@ -67,30 +60,44 @@
"passwordLowercase": "Password must have at least 1 lowercase letter",
"passwordNumber": "Password must have at least 1 number",
"passwordSpecial": "Password must have at least 1 special character",
"accept": "Please accept the condition",
"remember": "Remember must be a valid boolean",
"expireDate": "expiredDate must be a valid string",
"expireDateMin": "expiredDate must be at least 1 Character",
"accept": "Please accept the condition"
},
"userConfig": {
"host": "Host must be a valid string",
"hostMin": "Host must contain at least 1 character"
},
"general": {
"sessionTimeout": "Session Timeout must be a valid number"
},
"interface": {
"cidr": "CIDR must be a valid string",
"cidrMin": "CIDR must be at least 1 Character",
"device": "Device must be a valid string",
"deviceMin": "Device must be at least 1 Character"
},
"otl": {
"otl": "oneTimeLink must be a valid string",
"otlMin": "oneTimeLink must be at least 1 Character",
"features": "key must be a valid string",
"ftBool": "enabled must be a valid boolean",
"ftObj": "value must be a valid object",
"ftObj2": "features must be a valid record",
"stat": "statistics must be a valid object",
"statBool": "enabled must be a valid boolean",
"statNumber": "chartType must be a valid number",
"otlMin": "oneTimeLink must be at least 1 Character"
},
"stringMalformed": "String is malformed",
"body": "Body must be a valid object",
"host": "Host must be a valid string",
"hostMin": "Host must contain at least 1 character",
"hook": "Hook must be a valid string",
"mtu": "MTU must be a valid number",
"mtuMin": "MTU must be at least 1280",
"mtuMax": "MTU must be at most 9000",
"port": "Port must be a valid number",
"portMin": "Port must be at least 1",
"portMax": "Port must be at most 65535",
"sessionTimeout": "Session Timeout must be a valid number",
"device": "Device must be a valid string",
"deviceMin": "Device must be at least 1 Character",
"hook": "Hook must be a valid string",
"dns": "DNS must be a valid array of strings"
"persistentKeepalive": "Persistent Keepalive must be a valid number",
"persistentKeepaliveMin": "Persistent Keepalive must be at least 0",
"persistentKeepaliveMax": "Persistent Keepalive must be at most 65535",
"address": "IP Address must be a valid string",
"addressMin": "IP Address must be a be at least 1 Character",
"dns": "DNS must be a valid array of strings",
"dnsMin": "DNS must have at least 1 item",
"allowedIps": "Allowed IPs must be a valid array of strings",
"allowedIpsMin": "Allowed IPs must have at least 1 item"
},
"name": "Name",
"username": "Username",

9
src/nuxt.config.ts

@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url';
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
future: {
@ -44,5 +46,12 @@ export default defineNuxtConfig({
target: 'es2020',
},
},
alias: {
'#db': fileURLToPath(new URL('./server/database/', import.meta.url)),
},
},
alias: {
// for typecheck reasons (https://github.com/nuxt/cli/issues/323)
'#db': fileURLToPath(new URL('./server/database/', import.meta.url)),
},
});

30
src/package.json

@ -14,30 +14,33 @@
"format": "prettier . --write",
"format:check": "prettier . --check",
"typecheck": "nuxt typecheck",
"check:all": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm build"
"check:all": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm build",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@eschricht/nuxt-color-mode": "^1.1.5",
"@nuxtjs/i18n": "^9.1.1",
"@nuxtjs/tailwindcss": "^6.12.2",
"@libsql/client": "^0.14.0",
"@nuxtjs/i18n": "^9.1.5",
"@nuxtjs/tailwindcss": "^6.13.1",
"@pinia/nuxt": "^0.9.0",
"@tailwindcss/forms": "^0.5.10",
"apexcharts": "^4.3.0",
"apexcharts": "^4.4.0",
"argon2": "^0.41.1",
"basic-auth": "^2.0.1",
"cidr-tools": "^11.0.2",
"crc-32": "^1.2.2",
"debug": "^4.4.0",
"drizzle-orm": "^0.39.1",
"ip-bigint": "^8.2.0",
"is-cidr": "^5.1.0",
"is-ip": "^5.0.1",
"js-sha256": "^0.11.0",
"lowdb": "^7.0.1",
"nuxt": "^3.15.1",
"pinia": "^2.3.0",
"nuxt": "^3.15.4",
"pinia": "^2.3.1",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.12",
"semver": "^7.6.3",
"radix-vue": "^1.9.13",
"semver": "^7.7.1",
"tailwindcss": "^3.4.17",
"timeago.js": "^4.0.2",
"vue": "latest",
@ -45,16 +48,17 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.7.5",
"@nuxt/eslint-config": "^1.0.0",
"@types/debug": "^4.1.12",
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.5.8",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"drizzle-kit": "^0.30.4",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"prettier-plugin-tailwindcss": "^0.6.11",
"typescript": "^5.7.3",
"vue-tsc": "^2.2.0"
},
"packageManager": "pnpm@9.15.3"
"packageManager": "pnpm@10.2.0"
}

3112
src/pnpm-lock.yaml

File diff suppressed because it is too large

8
src/server/api/admin/general.get.ts

@ -1,4 +1,6 @@
export default defineEventHandler(async () => {
const system = await Database.system.get();
return system.general;
export default definePermissionEventHandler(actions.ADMIN, async () => {
const sessionConfig = await Database.general.getSessionConfig();
return {
sessionTimeout: sessionConfig.sessionTimeout,
};
});

13
src/server/api/admin/general.post.ts

@ -1,8 +1,13 @@
export default defineEventHandler(async (event) => {
import { GeneralUpdateSchema } from '#db/repositories/general/types';
export default definePermissionEventHandler(
actions.ADMIN,
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(generalUpdateType, event)
validateZod(GeneralUpdateSchema, event)
);
await Database.system.updateGeneral(data);
await Database.general.update(data);
return { success: true };
});
}
);

9
src/server/api/admin/hooks.get.ts

@ -1,4 +1,7 @@
export default defineEventHandler(async () => {
const system = await Database.system.get();
return system.hooks;
export default definePermissionEventHandler(actions.ADMIN, async () => {
const hooks = await Database.hooks.get('wg0');
if (!hooks) {
throw new Error('Hooks not found');
}
return hooks;
});

13
src/server/api/admin/hooks.post.ts

@ -1,9 +1,14 @@
export default defineEventHandler(async (event) => {
import { HooksUpdateSchema } from '#db/repositories/hooks/types';
export default definePermissionEventHandler(
actions.ADMIN,
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(hooksUpdateType, event)
validateZod(HooksUpdateSchema, event)
);
await Database.system.updateHooks(data);
await Database.hooks.update('wg0', data);
await WireGuard.saveConfig();
return { success: true };
});
}
);

8
src/server/api/admin/hostport.post.ts

@ -1,8 +0,0 @@
export default defineEventHandler(async (event) => {
const { host, port } = await readValidatedBody(
event,
validateZod(hostPortType, event)
);
await Database.system.updateClientsHostPort(host, port);
return { success: true };
});

4
src/server/api/admin/interface.get.ts

@ -1,4 +0,0 @@
export default defineEventHandler(async () => {
const system = await Database.system.get();
return system.interface;
});

9
src/server/api/admin/interface.post.ts

@ -1,9 +0,0 @@
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(
event,
validateZod(interfaceUpdateType, event)
);
await Database.system.updateInterface(data);
await WireGuard.saveConfig();
return { success: true };
});

15
src/server/api/admin/interface/cidr.post.ts

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

12
src/server/api/admin/interface/index.get.ts

@ -0,0 +1,12 @@
export default definePermissionEventHandler(actions.ADMIN, async () => {
const wgInterface = await Database.interfaces.get('wg0');
if (!wgInterface) {
throw new Error('Interface not found');
}
return {
...wgInterface,
privateKey: undefined,
};
});

14
src/server/api/admin/interface/index.post.ts

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

7
src/server/api/admin/userconfig.get.ts

@ -0,0 +1,7 @@
export default definePermissionEventHandler(actions.ADMIN, async () => {
const userConfig = await Database.userConfigs.get('wg0');
if (!userConfig) {
throw new Error('User config not found');
}
return userConfig;
});

14
src/server/api/admin/userconfig.post.ts

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

9
src/server/api/admin/userconfig/cidr.post.ts

@ -1,9 +0,0 @@
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(
event,
validateZod(cidrUpdateType, event)
);
await WireGuard.updateAddressRange(data);
return { success: true };
});

4
src/server/api/admin/userconfig/index.get.ts

@ -1,4 +0,0 @@
export default defineEventHandler(async () => {
const system = await Database.system.get();
return system.userConfig;
});

9
src/server/api/admin/userconfig/index.post.ts

@ -1,9 +0,0 @@
export default defineEventHandler(async (event) => {
const data = await readValidatedBody(
event,
validateZod(userConfigUpdateType, event)
);
await Database.system.updateUserConfig(data);
await WireGuard.saveConfig();
return { success: true };
});

19
src/server/api/client/[clientId]/configuration.get.ts

@ -1,9 +1,19 @@
export default defineEventHandler(async (event) => {
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema)
);
const client = await WireGuard.getClient({ clientId });
const client = await Database.clients.get(clientId);
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, '-')
@ -17,4 +27,5 @@ export default defineEventHandler(async (event) => {
);
setHeader(event, 'Content-Type', 'text/plain');
return config;
});
}
);

14
src/server/api/client/[clientId]/disable.post.ts

@ -1,8 +1,14 @@
export default defineEventHandler(async (event) => {
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema)
);
await WireGuard.disableClient({ clientId });
await Database.clients.toggle(clientId, false);
await WireGuard.saveConfig();
return { success: true };
});
}
);

14
src/server/api/client/[clientId]/enable.post.ts

@ -1,8 +1,14 @@
export default defineEventHandler(async (event) => {
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema)
);
await WireGuard.enableClient({ clientId });
await Database.clients.toggle(clientId, false);
await WireGuard.saveConfig();
return { success: true };
});
}
);

13
src/server/api/client/[clientId]/generateOneTimeLink.post.ts

@ -1,8 +1,13 @@
export default defineEventHandler(async (event) => {
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema)
);
await WireGuard.generateOneTimeLink({ clientId });
await Database.oneTimeLinks.generate(clientId);
return { success: true };
});
}
);

14
src/server/api/client/[clientId]/index.delete.ts

@ -1,8 +1,14 @@
export default defineEventHandler(async (event) => {
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema)
);
await WireGuard.deleteClient({ clientId });
await Database.clients.delete(clientId);
await WireGuard.saveConfig();
return { success: true };
});
}
);

20
src/server/api/client/[clientId]/index.get.ts

@ -1,7 +1,19 @@
export default defineEventHandler(async (event) => {
import { ClientGetSchema } from '~~/server/database/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema, event)
);
return WireGuard.getClient({ clientId });
});
const result = await Database.clients.get(clientId);
if (!result) {
throw createError({
statusCode: 404,
statusMessage: 'Client not found',
});
}
return result;
}
);

22
src/server/api/client/[clientId]/index.post.ts

@ -1,18 +1,24 @@
export default defineEventHandler(async (event) => {
import {
ClientGetSchema,
ClientUpdateSchema,
} from '#db/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema)
);
const data = await readValidatedBody(
event,
validateZod(clientUpdateType, event)
validateZod(ClientUpdateSchema, event)
);
await WireGuard.updateClient({
clientId,
client: data,
});
await Database.clients.update(clientId, data);
await WireGuard.saveConfig();
return { success: true };
});
}
);

11
src/server/api/client/[clientId]/qrcode.svg.get.ts

@ -1,9 +1,14 @@
export default defineEventHandler(async (event) => {
import { ClientGetSchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
actions.CLIENT,
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
validateZod(ClientGetSchema)
);
const svg = await WireGuard.getClientQRCodeSVG({ clientId });
setHeader(event, 'Content-Type', 'image/svg+xml');
return svg;
});
}
);

2
src/server/api/client/index.get.ts

@ -1,3 +1,3 @@
export default defineEventHandler(() => {
export default definePermissionEventHandler(actions.CLIENT, () => {
return WireGuard.getClients();
});

16
src/server/api/client/index.post.ts

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

2
src/server/api/session.get.ts

@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Not logged in',
});
}
const user = await Database.user.findById(session.data.userId);
const user = await Database.users.get(session.data.userId);
if (!user) {
throw createError({
statusCode: 404,

20
src/server/api/session.post.ts

@ -1,11 +1,12 @@
import { UserLoginSchema } from '#db/repositories/user/types';
export default defineEventHandler(async (event) => {
const { username, password, remember } = await readValidatedBody(
event,
validateZod(credentialsType, event)
validateZod(UserLoginSchema, event)
);
const users = await Database.user.findAll();
const user = users.find((user) => user.username == username);
const user = await Database.users.getByUsername(username);
if (!user)
throw createError({
statusCode: 400,
@ -21,18 +22,7 @@ export default defineEventHandler(async (event) => {
});
}
const system = await Database.system.get();
const conf = { ...system.sessionConfig };
if (remember) {
conf.cookie = {
...(system.sessionConfig.cookie ?? {}),
maxAge: system.general.sessionTimeout,
};
}
const session = await useSession<WGSession>(event, conf);
const session = await useWGSession(event, remember);
const data = await session.update({
userId: user.id,

17
src/server/api/setup/4.post.ts

@ -1,17 +1,12 @@
export default defineEventHandler(async (event) => {
const setupDone = await Database.setup.done();
if (setupDone) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state',
});
}
import { UserSetupType } from '#db/repositories/user/types';
export default defineSetupEventHandler(async ({ event }) => {
const { username, password } = await readValidatedBody(
event,
validateZod(passwordSetupType, event)
validateZod(UserSetupType, event)
);
await Database.user.create(username, password);
await Database.setup.set(5);
await Database.users.create(username, password);
await Database.general.setSetupStep(5);
return { success: true };
});

16
src/server/api/setup/5.post.ts

@ -1,17 +1,11 @@
export default defineEventHandler(async (event) => {
const setupDone = await Database.setup.done();
if (setupDone) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state',
});
}
import { UserConfigSetupType } from '#db/repositories/userConfig/types';
export default defineSetupEventHandler(async ({ event }) => {
const { host, port } = await readValidatedBody(
event,
validateZod(hostPortType, event)
validateZod(UserConfigSetupType, event)
);
await Database.system.updateClientsHostPort(host, port);
await Database.setup.set('success');
await Database.userConfigs.updateHostPort('wg0', host, port);
await Database.general.setSetupStep(0);
return { success: true };
});

18
src/server/api/setup/migrate.post.ts

@ -1,16 +1,10 @@
import { parseCidr } from 'cidr-tools';
/*import { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import { z } from 'zod';
import type { Database } from '~~/services/database/repositories/database';
import { z } from 'zod';*/
export default defineEventHandler(async (event) => {
const setupDone = await Database.setup.done();
if (setupDone) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state',
});
}
export default defineSetupEventHandler(async (/*{ event }*/) => {
// TODO: Implement
/*
const { file } = await readValidatedBody(event, validateZod(fileType, event));
const schema = z.object({
@ -79,7 +73,7 @@ export default defineEventHandler(async (event) => {
address6: address6,
mtu: 1420,
});
}
}*/
return { success: true };
});

11
src/server/api/wireguard/backup.get.ts

@ -1,6 +1,9 @@
export default defineEventHandler(async (event) => {
const config = await WireGuard.backupConfiguration();
export default definePermissionEventHandler(
actions.ADMIN,
async (/*{ event }*/) => {
/*const config = await WireGuard.backupConfiguration();
setHeader(event, 'Content-Disposition', 'attachment; filename="wg0.json"');
setHeader(event, 'Content-Type', 'text/json');
return config;
});
return config;*/
}
);

11
src/server/api/wireguard/restore.put.ts

@ -1,5 +1,8 @@
export default defineEventHandler(async (event) => {
const { file } = await readValidatedBody(event, validateZod(fileType));
export default definePermissionEventHandler(
actions.ADMIN,
async (/*{ event }*/) => {
/*const { file } = await readValidatedBody(event, validateZod(fileType));
await WireGuard.restoreConfiguration(file);
return { success: true };
});
return { success: true };*/
}
);

100
src/server/database/migrations/0000_short_skin.sql

@ -0,0 +1,100 @@
CREATE TABLE `clients_table` (
`id` integer PRIMARY KEY AUTOINCREMENT 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
);
--> 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,
`setupStep` integer NOT NULL,
`session_password` text NOT NULL,
`session_timeout` integer NOT NULL,
`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 `prometheus_table` (
`id` text PRIMARY KEY NOT NULL,
`password` 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 `one_time_links_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`one_time_link` text NOT NULL,
`expires_at` text NOT NULL,
`clientId` integer NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`clientId`) 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
);

18
src/server/database/migrations/0001_classy_the_stranger.sql

@ -0,0 +1,18 @@
PRAGMA journal_mode=WAL;--> statement-breakpoint
INSERT INTO `general_table` (`setupStep`, `session_password`, `session_timeout`)
VALUES (1, hex(randomblob(256)), 3600);
--> 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)

686
src/server/database/migrations/meta/0000_snapshot.json

@ -0,0 +1,686 @@
{
"version": "6",
"dialect": "sqlite",
"id": "25907c5f-be21-4ae6-88c4-1a72b2f335e7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"clients_table": {
"name": "clients_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"general_table": {
"name": "general_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false,
"default": 1
},
"setupStep": {
"name": "setupStep",
"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
},
"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": {}
},
"prometheus_table": {
"name": "prometheus_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"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": {
"prometheus_table_id_interfaces_table_name_fk": {
"name": "prometheus_table_id_interfaces_table_name_fk",
"tableFrom": "prometheus_table",
"tableTo": "interfaces_table",
"columnsFrom": [
"id"
],
"columnsTo": [
"name"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"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
},
"clientId": {
"name": "clientId",
"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_clientId_clients_table_id_fk": {
"name": "one_time_links_table_clientId_clients_table_id_fk",
"tableFrom": "one_time_links_table",
"tableTo": "clients_table",
"columnsFrom": [
"clientId"
],
"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": {}
}
}

686
src/server/database/migrations/meta/0001_snapshot.json

@ -0,0 +1,686 @@
{
"id": "60af732f-adc0-405d-96cc-2f818585f593",
"prevId": "25907c5f-be21-4ae6-88c4-1a72b2f335e7",
"version": "6",
"dialect": "sqlite",
"tables": {
"clients_table": {
"name": "clients_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"general_table": {
"name": "general_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false,
"default": 1
},
"setupStep": {
"name": "setupStep",
"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
},
"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": {}
},
"prometheus_table": {
"name": "prometheus_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"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": {
"prometheus_table_id_interfaces_table_name_fk": {
"name": "prometheus_table_id_interfaces_table_name_fk",
"tableFrom": "prometheus_table",
"columnsFrom": [
"id"
],
"tableTo": "interfaces_table",
"columnsTo": [
"name"
],
"onUpdate": "cascade",
"onDelete": "cascade"
}
},
"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
},
"clientId": {
"name": "clientId",
"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_clientId_clients_table_id_fk": {
"name": "one_time_links_table_clientId_clients_table_id_fk",
"tableFrom": "one_time_links_table",
"columnsFrom": [
"clientId"
],
"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": {}
}
}

20
src/server/database/migrations/meta/_journal.json

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

37
src/server/database/repositories/client/schema.ts

@ -0,0 +1,37 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { oneTimeLink } from '../../schema';
export const client = sqliteTable('clients_table', {
id: int().primaryKey({ autoIncrement: true }),
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],
}),
}));

128
src/server/database/repositories/client/service.ts

@ -0,0 +1,128 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { client } from './schema';
import type { ClientCreateType, UpdateClientType } from './types';
import type { ID } from '#db/schema';
import { wgInterface, userConfig } from '#db/schema';
import { parseCidr } from 'cidr-tools';
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(),
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 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,
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();
}
}

72
src/server/database/repositories/client/types.ts

@ -0,0 +1,72 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { client } from './schema';
export type ID = string;
export type ClientType = InferSelectModel<typeof client>;
export type CreateClientType = Omit<
ClientType,
'createdAt' | 'updatedAt' | 'id'
>;
export type UpdateClientType = Omit<
CreateClientType,
'privateKey' | 'publicKey' | 'preSharedKey'
>;
const name = z
.string({ message: 'zod.client.name' })
.min(1, 'zod.client.nameMin')
.pipe(safeStringRefine);
const expiresAt = z
.string({ message: 'zod.client.expireDate' })
.min(1, 'zod.client.expireDateMin')
.pipe(safeStringRefine)
.nullable();
const address4 = z
.string({ message: 'zod.client.address4' })
.min(1, { message: 'zod.client.address4Min' })
.pipe(safeStringRefine);
const address6 = z
.string({ message: 'zod.client.address6' })
.min(1, { message: 'zod.client.address6Min' })
.pipe(safeStringRefine);
const serverAllowedIps = z.array(AddressSchema, {
message: 'zod.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: 'zod.client.id', coerce: true });
export const ClientGetSchema = z.object({
clientId: clientId,
});

16
src/server/database/repositories/general/schema.ts

@ -0,0 +1,16 @@
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().notNull(),
sessionPassword: text('session_password').notNull(),
sessionTimeout: int('session_timeout').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});

68
src/server/database/repositories/general/service.ts

@ -0,0 +1,68 @@
import type { DBType } from '#db/sqlite';
import { sql } from 'drizzle-orm';
import { general } from './schema';
import type { GeneralUpdateType } from './types';
function createPreparedStatement(db: DBType) {
return {
find: db.query.general.findFirst().prepare(),
updateSetupStep: db
.update(general)
.set({
setupStep: sql.placeholder('setupStep') as never as number,
})
.prepare(),
update: db
.update(general)
.set({
sessionTimeout: sql.placeholder('sessionTimeout') as never as number,
})
.prepare(),
};
}
export class GeneralService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
/**
* @throws
*/
private async get() {
const result = await this.#statements.find.execute();
if (!result) {
throw new Error('General Config not found');
}
return result;
}
/**
* @throws
*/
async getSetupStep() {
const result = await this.get();
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.get();
return {
sessionPassword: result.sessionPassword,
sessionTimeout: result.sessionTimeout,
};
}
update(data: GeneralUpdateType) {
return this.#statements.update.execute(data);
}
}

15
src/server/database/repositories/general/types.ts

@ -0,0 +1,15 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { general } from './schema';
import z from 'zod';
export type GeneralType = InferSelectModel<typeof general>;
const sessionTimeout = z.number({ message: 'zod.general.sessionTimeout' });
export const GeneralUpdateSchema = z.object({
sessionTimeout: sessionTimeout,
});
export type GeneralUpdateType = z.infer<typeof GeneralUpdateSchema>;
export type SetupStepType = { step: number; done: boolean };

24
src/server/database/repositories/hooks/schema.ts

@ -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)`),
});

34
src/server/database/repositories/hooks/service.ts

@ -0,0 +1,34 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { hooks } from './schema';
import type { HooksUpdateType } from './types';
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);
}
get(infName: string) {
return this.#statements.get.execute({ interface: infName });
}
update(infName: string, data: HooksUpdateType) {
return this.#db
.update(hooks)
.set(data)
.where(eq(hooks.id, infName))
.execute();
}
}

18
src/server/database/repositories/hooks/types.ts

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

39
src/server/database/repositories/interface/schema.ts

@ -0,0 +1,39 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { userConfig, hooks, prometheus } 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(),
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],
}),
prometheus: one(prometheus, {
fields: [wgInterface.name],
references: [prometheus.id],
}),
userConfig: one(userConfig, {
fields: [wgInterface.name],
references: [userConfig.id],
}),
}));

90
src/server/database/repositories/interface/service.ts

@ -0,0 +1,90 @@
import type { DBType } from '#db/sqlite';
import isCidr from 'is-cidr';
import { eq, sql } from 'drizzle-orm';
import { wgInterface } from './schema';
import type { InterfaceCidrUpdateType, InterfaceUpdateType } from './types';
import { client as clientSchema } from '#db/schema';
import { parseCidr } from 'cidr-tools';
function createPreparedStatement(db: DBType) {
return {
get: db.query.wgInterface
.findFirst({ where: eq(wgInterface.name, sql.placeholder('interface')) })
.prepare(),
getAll: db.query.wgInterface.findMany().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);
}
get(infName: string) {
return this.#statements.get.execute({ interface: infName });
}
getAll() {
return this.#statements.getAll.execute();
}
updateKeyPair(infName: string, privateKey: string, publicKey: string) {
return this.#statements.updateKeyPair.execute({
interface: infName,
privateKey,
publicKey,
});
}
update(infName: string, data: InterfaceUpdateType) {
return this.#db
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, infName))
.execute();
}
updateCidr(infName: string, 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, infName))
.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();
}
});
}
}

49
src/server/database/repositories/interface/types.ts

@ -0,0 +1,49 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { wgInterface } from './schema';
import z from 'zod';
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: 'zod.interface.device' })
.min(1, 'zod.interface.deviceMin')
.pipe(safeStringRefine);
const cidr = z
.string({ message: 'zod.interface.cidr' })
.min(1, { message: 'zod.interface.cidrMin' })
.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,
})
);

21
src/server/database/repositories/metrics/schema.ts

@ -0,0 +1,21 @@
import { sql } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../../schema';
export const prometheus = sqliteTable('prometheus_table', {
id: text()
.primaryKey()
.references(() => wgInterface.name, {
onDelete: 'cascade',
onUpdate: 'cascade',
}),
password: text().notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});

31
src/server/database/repositories/metrics/service.ts

@ -0,0 +1,31 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { prometheus } from './schema';
function createPreparedStatement(db: DBType) {
return {
get: db.query.prometheus
.findFirst({ where: eq(prometheus.id, sql.placeholder('interface')) })
.prepare(),
};
}
export class PrometheusService {
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#statements = createPreparedStatement(db);
}
get(infName: string) {
return this.#statements.get.execute({ interface: infName });
}
}
export class MetricsService {
prometheus: PrometheusService;
constructor(db: DBType) {
this.prometheus = new PrometheusService(db);
}
}

4
src/server/database/repositories/metrics/types.ts

@ -0,0 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { prometheus } from './schema';
export type PrometheusType = InferSelectModel<typeof prometheus>;

27
src/server/database/repositories/oneTimeLink/schema.ts

@ -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()
.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],
}),
}));

52
src/server/database/repositories/oneTimeLink/service.ts

@ -0,0 +1,52 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { oneTimeLink } from './schema';
import type { ID } from '../../schema';
import CRC32 from 'crc-32';
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 });
}
}

17
src/server/database/repositories/oneTimeLink/types.ts

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

19
src/server/database/repositories/user/schema.ts

@ -0,0 +1,19 @@
import { sql } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
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)`),
});

63
src/server/database/repositories/user/service.ts

@ -0,0 +1,63 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { user } from './schema';
import type { ID } from '../../schema';
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(),
};
}
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 this.getByUsername(username);
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,
});
});
}
}

43
src/server/database/repositories/user/types.ts

@ -0,0 +1,43 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { user } from './schema';
import z from 'zod';
export type UserType = InferSelectModel<typeof user>;
const username = z
.string({ message: 'zod.user.username' })
.min(8, 'zod.user.usernameMin')
.pipe(safeStringRefine);
const password = z
.string({ message: 'zod.user.password' })
.min(12, 'zod.user.passwordMin')
.regex(/[A-Z]/, 'zod.user.passwordUppercase')
.regex(/[a-z]/, 'zod.user.passwordLowercase')
.regex(/\d/, 'zod.user.passwordNumber')
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'zod.user.passwordSpecial')
.pipe(safeStringRefine);
const remember = z.boolean({ message: 'zod.user.remember' });
export const UserLoginSchema = z.object(
{
username: username,
password: password,
remember: remember,
},
{ message: objectMessage }
);
const accept = z.boolean().refine((val) => val === true, {
message: 'zod.user.accept',
});
export const UserSetupType = z.object(
{
username: username,
password: password,
accept: accept,
},
{ message: objectMessage }
);

29
src/server/database/repositories/userConfig/schema.ts

@ -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)`),
});

50
src/server/database/repositories/userConfig/service.ts

@ -0,0 +1,50 @@
import type { DBType } from '#db/sqlite';
import { eq, sql } from 'drizzle-orm';
import { userConfig } from './schema';
import type { UserConfigUpdateType } from './types';
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);
}
get(infName: string) {
return this.#statements.get.execute({ interface: infName });
}
updateHostPort(infName: string, host: string, port: number) {
return this.#statements.updateHostPort.execute({
interface: infName,
host,
port,
});
}
update(infName: string, data: UserConfigUpdateType) {
return this.#db
.update(userConfig)
.set(data)
.where(eq(userConfig.id, infName))
.execute();
}
}

31
src/server/database/repositories/userConfig/types.ts

@ -0,0 +1,31 @@
import type { InferSelectModel } from 'drizzle-orm';
import type { userConfig } from './schema';
import z from 'zod';
export type UserConfigType = InferSelectModel<typeof userConfig>;
const host = z
.string({ message: 'zod.userConfig.host' })
.min(1, 'zod.userConfig.hostMin')
.pipe(safeStringRefine);
export const UserConfigSetupType = 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,
})
);

12
src/server/database/schema.ts

@ -0,0 +1,12 @@
// 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/metrics/schema';
export * from './repositories/oneTimeLink/schema';
export * from './repositories/user/schema';
export * from './repositories/userConfig/schema';
// TODO: move to types
export type ID = number;

63
src/server/database/sqlite.ts

@ -0,0 +1,63 @@
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';
import { MetricsService } from './repositories/metrics/service';
const DB_DEBUG = debug('Database');
const client = createClient({ url: 'file:/etc/wireguard/wg0.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;
metrics: MetricsService;
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);
this.metrics = new MetricsService(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);
}
}
}

35
src/server/middleware/auth.ts

@ -1,35 +0,0 @@
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const session = await useWGSession(event);
// Api handled by session, Setup handled with setup middleware
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/setup')) {
return;
}
if (url.pathname === '/login') {
if (session.data.userId) {
return sendRedirect(event, '/', 302);
}
return;
}
// Require auth for every page other than Login
// TODO: investigate /__nuxt_error (error page when unauthenticated)
if (!session.data.userId) {
return sendRedirect(event, '/login', 302);
}
if (url.pathname.startsWith('/admin')) {
const user = await Database.user.findById(session.data.userId);
if (!user) {
return sendRedirect(event, '/login', 302);
}
if (user.role !== 'ADMIN') {
throw createError({
statusCode: 403,
statusMessage: 'Not allowed to access Admin Panel',
});
}
}
});

94
src/server/middleware/session.ts

@ -1,94 +0,0 @@
import type { User } from '~~/services/database/repositories/user';
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
// If one method of a route is public, every method is public!
// Handle api routes
if (
!url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/api/setup/') ||
url.pathname === '/api/session' ||
url.pathname === '/api/release'
) {
return;
}
const system = await Database.system.get();
const session = await getSession<WGSession>(event, system.sessionConfig);
const authorization = getHeader(event, 'Authorization');
let user: User | undefined = undefined;
if (session.data.userId) {
// Handle if authenticating using Session
user = await Database.user.findById(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: 401,
statusMessage: 'Session failed',
});
}
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: 401,
statusMessage: 'Session failed',
});
}
const users = await Database.user.findAll();
const foundUser = users.find((v) => v.username === 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: 'Incorrect Password',
});
}
user = foundUser;
}
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Not logged in',
});
}
if (!user.enabled) {
throw createError({
statusCode: 403,
statusMessage: 'Account is disabled',
});
}
if (url.pathname.startsWith('/api/admin')) {
if (user.role !== 'ADMIN') {
throw createError({
statusCode: 403,
statusMessage: 'Missing Permissions',
});
}
}
});

6
src/server/middleware/setup.ts

@ -7,14 +7,14 @@ export default defineEventHandler(async (event) => {
return;
}
const setupDone = await Database.setup.done();
if (!setupDone) {
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;
const step = await Database.setup.get();
if (step.toString() === currentSetup) {
return;
}

6
src/server/routes/cnf/[oneTimeLink].ts

@ -1,7 +1,9 @@
import { OneTimeLinkGetSchema } from '#db/repositories/oneTimeLink/types';
export default defineEventHandler(async (event) => {
const { oneTimeLink } = await getValidatedRouterParams(
event,
validateZod(oneTimeLinkType)
validateZod(OneTimeLinkGetSchema)
);
const clients = await WireGuard.getClients();
const client = clients.find(
@ -15,7 +17,7 @@ export default defineEventHandler(async (event) => {
}
const clientId = client.id;
const config = await WireGuard.getClientConfiguration({ clientId });
await WireGuard.eraseOneTimeLink({ clientId });
await Database.oneTimeLinks.erase(clientId);
setHeader(
event,
'Content-Disposition',

6
src/server/routes/metrics/index.get.ts

@ -1,8 +1,8 @@
export default defineEventHandler(async (event) => {
// TODO: check password
const system = await Database.system.get();
if (!system.metrics.prometheus.enabled) {
const prometheus = await Database.metrics.prometheus.get('wg0');
if (!prometheus) {
throw createError({
statusCode: 400,
message: 'Prometheus metrics are not enabled',
@ -10,5 +10,5 @@ export default defineEventHandler(async (event) => {
}
setHeader(event, 'Content-Type', 'text/plain');
return WireGuard.getMetrics();
return getPrometheusResponse();
});

6
src/server/routes/metrics/json.get.ts

@ -1,13 +1,13 @@
export default defineEventHandler(async () => {
// TODO: check password
const system = await Database.system.get();
if (!system.metrics.prometheus.enabled) {
const prometheus = await Database.metrics.prometheus.get('wg0');
if (!prometheus) {
throw createError({
statusCode: 400,
message: 'Prometheus metrics are not enabled',
});
}
return WireGuard.getMetricsJSON();
return getMetricsJSON();
});

9
src/server/utils/Database.ts

@ -2,8 +2,7 @@
* Changing the Database Provider
* This design allows for easy swapping of different database implementations.
*/
import LowDb from '~~/services/database/lowdb';
import { connect, type DBServiceType } from '#db/sqlite';
const nullObject = new Proxy(
{},
@ -15,10 +14,10 @@ const nullObject = new Proxy(
);
// eslint-disable-next-line import/no-mutable-exports
let provider = nullObject as never as LowDb;
let provider = nullObject as never as DBServiceType;
LowDb.connect().then((v) => {
provider = v;
connect().then((db) => {
provider = db;
WireGuard.Startup();
});

366
src/server/utils/WireGuard.ts

@ -1,69 +1,58 @@
import fs from 'node:fs/promises';
import debug from 'debug';
import QRCode from 'qrcode';
import CRC32 from 'crc-32';
import isCidr from 'is-cidr';
import type { ID } from '#db/schema';
import type {
CreateClient,
UpdateClient,
} from '~~/services/database/repositories/client';
const DEBUG = debug('WireGuard');
const WG_DEBUG = debug('WireGuard');
class WireGuard {
/**
* Save and sync config
*/
async saveConfig() {
await this.#saveWireguardConfig();
await this.#syncWireguardConfig();
await this.#saveWireguardConfig('wg0');
await this.#syncWireguardConfig('wg0');
}
/**
* Generates and saves WireGuard config from database as wg0
*/
async #saveWireguardConfig() {
const system = await Database.system.get();
const clients = await Database.client.findAll();
async #saveWireguardConfig(infName: string) {
const wgInterface = await Database.interfaces.get(infName);
const clients = await Database.clients.getAll();
const hooks = await Database.hooks.get(infName);
if (!wgInterface || !hooks) {
throw new Error('Interface or Hooks not found');
}
const result = [];
result.push(wg.generateServerInterface(system));
result.push(wg.generateServerInterface(wgInterface, hooks));
for (const client of Object.values(clients)) {
for (const client of clients) {
if (!client.enabled) {
continue;
}
result.push(wg.generateServerPeer(client));
}
DEBUG('Saving Config...');
await fs.writeFile('/etc/wireguard/wg0.conf', result.join('\n\n'), {
WG_DEBUG('Saving Config...');
await fs.writeFile(`/etc/wireguard/${infName}.conf`, result.join('\n\n'), {
mode: 0o600,
});
DEBUG('Config saved successfully.');
WG_DEBUG('Config saved successfully.');
}
async #syncWireguardConfig() {
DEBUG('Syncing Config...');
await wg.sync();
DEBUG('Config synced successfully.');
async #syncWireguardConfig(infName: string) {
WG_DEBUG('Syncing Config...');
await wg.sync(infName);
WG_DEBUG('Config synced successfully.');
}
async getClients() {
const dbClients = await Database.client.findAll();
const clients = Object.entries(dbClients).map(([clientId, client]) => ({
id: clientId,
name: client.name,
enabled: client.enabled,
address4: client.address4,
address6: client.address6,
publicKey: client.publicKey,
createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt),
expiresAt: client.expiresAt,
allowedIps: client.allowedIps,
oneTimeLink: client.oneTimeLink,
persistentKeepalive: null as string | null,
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,
@ -71,16 +60,9 @@ class WireGuard {
}));
// Loop WireGuard status
const dump = await wg.dump();
const dump = await wg.dump('wg0');
dump.forEach(
({
publicKey,
latestHandshakeAt,
endpoint,
transferRx,
transferTx,
persistentKeepalive,
}) => {
({ publicKey, latestHandshakeAt, endpoint, transferRx, transferTx }) => {
const client = clients.find((client) => client.publicKey === publicKey);
if (!client) {
return;
@ -90,33 +72,30 @@ class WireGuard {
client.endpoint = endpoint;
client.transferRx = transferRx;
client.transferTx = transferTx;
client.persistentKeepalive = persistentKeepalive;
}
);
return clients;
}
async getClient({ clientId }: { clientId: string }) {
const client = await Database.client.findById(clientId);
if (!client) {
throw createError({
statusCode: 404,
statusMessage: `Client Not Found: ${clientId}`,
});
}
async getClientConfiguration({ clientId }: { clientId: ID }) {
const wgInterface = await Database.interfaces.get('wg0');
const userConfig = await Database.userConfigs.get('wg0');
return client;
if (!wgInterface || !userConfig) {
throw new Error('Interface or UserConfig not found');
}
async getClientConfiguration({ clientId }: { clientId: string }) {
const system = await Database.system.get();
const client = await this.getClient({ clientId });
const client = await Database.clients.get(clientId);
if (!client) {
throw new Error('Client not found');
}
return wg.generateClientConfig(system, client);
return wg.generateClientConfig(wgInterface, userConfig, client);
}
async getClientQRCodeSVG({ clientId }: { clientId: string }) {
async getClientQRCodeSVG({ clientId }: { clientId: ID }) {
const config = await this.getClientConfiguration({ clientId });
return QRCode.toString(config, {
type: 'svg',
@ -124,134 +103,6 @@ class WireGuard {
});
}
async createClient({
name,
expireDate,
}: {
name: string;
expireDate: string | null;
}) {
const system = await Database.system.get();
const clients = await Database.client.findAll();
const privateKey = await wg.generatePrivateKey();
const publicKey = await wg.getPublicKey(privateKey);
const preSharedKey = await wg.generatePresharedKey();
const address4 = nextIPv4(system, clients);
const address6 = nextIPv6(system, clients);
const client: CreateClient = {
name,
address4,
address6,
privateKey,
publicKey,
preSharedKey,
oneTimeLink: null,
expiresAt: null,
enabled: true,
allowedIps: [...system.userConfig.allowedIps],
serverAllowedIPs: [],
persistentKeepalive: system.userConfig.persistentKeepalive,
mtu: system.userConfig.mtu,
};
if (expireDate) {
const date = new Date(expireDate);
date.setHours(23);
date.setMinutes(59);
date.setSeconds(59);
client.expiresAt = date.toISOString();
}
await Database.client.create(client);
await this.saveConfig();
return client;
}
async deleteClient({ clientId }: { clientId: string }) {
await Database.client.delete(clientId);
await this.saveConfig();
}
async enableClient({ clientId }: { clientId: string }) {
await Database.client.toggle(clientId, true);
await this.saveConfig();
}
async generateOneTimeLink({ clientId }: { clientId: string }) {
const key = `${clientId}-${Math.floor(Math.random() * 1000)}`;
const oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
await Database.client.createOneTimeLink(clientId, {
oneTimeLink,
expiresAt,
});
await this.saveConfig();
}
async eraseOneTimeLink({ clientId }: { clientId: string }) {
await Database.client.deleteOneTimeLink(clientId);
await this.saveConfig();
}
async disableClient({ clientId }: { clientId: string }) {
await Database.client.toggle(clientId, false);
await this.saveConfig();
}
async updateClient({
clientId,
client,
}: {
clientId: string;
client: UpdateClient;
}) {
// TODO: validate ipv4, v6, expire date etc
await Database.client.update(clientId, client);
await this.saveConfig();
}
async updateAddressRange({
address4,
address6,
}: {
address4: string;
address6: string;
}) {
// TODO: be able to revert if error
if (!isCidr(address4) || !isCidr(address6)) {
throw new Error('Invalid CIDR');
}
await Database.system.updateAddressRange(address4, address6);
const systems = await Database.system.get();
const clients = await Database.client.findAll();
for (const _client of Object.values(clients)) {
const clients = await Database.client.findAll();
const client = structuredClone(_client) as DeepWriteable<typeof _client>;
client.address4 = nextIPv4(systems, clients);
client.address6 = nextIPv6(systems, clients);
await Database.client.update(client.id, {
...client,
});
}
await this.saveConfig();
}
// TODO: reimplement database restore
async restoreConfiguration(_config: string) {
/* DEBUG('Starting configuration restore process.');
@ -272,35 +123,58 @@ class WireGuard {
}
async Startup() {
DEBUG('Starting Wireguard...');
await this.#saveWireguardConfig();
await wg.down().catch(() => {});
await wg.up().catch((err) => {
WG_DEBUG('Starting WireGuard...');
const wgInterfaces = await Database.interfaces.getAll();
for (const wgInterface of wgInterfaces) {
if (wgInterface.enabled !== true) {
continue;
}
// 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(
wgInterface.name,
privateKey,
publicKey
);
WG_DEBUG('New Wireguard Keys generated successfully.');
}
WG_DEBUG(`Starting Wireguard Interface ${wgInterface.name}...`);
await this.#saveWireguardConfig(wgInterface.name);
await wg.down(wgInterface.name).catch(() => {});
await wg.up(wgInterface.name).catch((err) => {
if (
err &&
err.message &&
err.message.includes('Cannot find device "wg0"')
err.message.includes(`Cannot find device "${wgInterface.name}"`)
) {
throw new Error(
'WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!'
`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();
DEBUG('Wireguard started successfully.');
await this.#syncWireguardConfig(wgInterface.name);
WG_DEBUG(`Wireguard Interface ${wgInterface.name} started successfully.`);
}
DEBUG('Starting Cron Job.');
WG_DEBUG('Starting Cron Job...');
await this.startCronJob();
DEBUG('Cron Job started successfully.');
WG_DEBUG('Cron Job started successfully.');
}
// TODO: handle as worker_thread
// would need a better database aswell
async startCronJob() {
await this.cronJob().catch((err) => {
DEBUG('Running Cron Job failed.');
WG_DEBUG('Running Cron Job failed.');
console.error(err);
});
setTimeout(() => {
@ -310,109 +184,39 @@ class WireGuard {
// Shutdown wireguard
async Shutdown() {
await wg.down().catch(() => {});
const wgInterfaces = await Database.interfaces.getAll();
for (const wgInterface of wgInterfaces) {
await wg.down(wgInterface.name).catch(() => {});
}
}
async cronJob() {
const clients = await Database.client.findAll();
const clients = await Database.clients.getAll();
// Expires Feature
for (const client of Object.values(clients)) {
for (const client of clients) {
if (client.enabled !== true) continue;
if (
client.expiresAt !== null &&
new Date() > new Date(client.expiresAt)
) {
DEBUG(`Client ${client.id} expired.`);
await Database.client.toggle(client.id, false);
WG_DEBUG(`Client ${client.id} expired.`);
await Database.clients.toggle(client.id, false);
}
}
// One Time Link Feature
for (const client of Object.values(clients)) {
for (const client of clients) {
if (
client.oneTimeLink !== null &&
new Date() > new Date(client.oneTimeLink.expiresAt)
) {
DEBUG(`Client ${client.id} One Time Link expired.`);
await Database.client.deleteOneTimeLink(client.id);
WG_DEBUG(`Client ${client.id} One Time Link expired.`);
await Database.oneTimeLinks.delete(client.id);
}
}
await this.saveConfig();
}
async getMetrics() {
const clients = await this.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
let wireguardSentBytes = '';
let wireguardReceivedBytes = '';
let wireguardLatestHandshakeSeconds = '';
for (const client of Object.values(clients)) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (client.endpoint !== null) {
wireguardConnectedPeersCount++;
}
wireguardSentBytes += `wireguard_sent_bytes{interface="wg0",enabled="${client.enabled}",address4="${client.address4}",address6="${client.address6}",name="${client.name}"} ${Number(client.transferTx)}\n`;
wireguardReceivedBytes += `wireguard_received_bytes{interface="wg0",enabled="${client.enabled}",address4="${client.address4}",address6="${client.address6}",name="${client.name}"} ${Number(client.transferRx)}\n`;
wireguardLatestHandshakeSeconds += `wireguard_latest_handshake_seconds{interface="wg0",enabled="${client.enabled}",address4="${client.address4}",address6="${client.address6}",name="${client.name}"} ${client.latestHandshakeAt ? (new Date().getTime() - new Date(client.latestHandshakeAt).getTime()) / 1000 : 0}\n`;
}
let returnText = '# HELP wg-easy and wireguard metrics\n';
returnText += '\n# HELP wireguard_configured_peers\n';
returnText += '# TYPE wireguard_configured_peers gauge\n';
returnText += `wireguard_configured_peers{interface="wg0"} ${wireguardPeerCount}\n`;
returnText += '\n# HELP wireguard_enabled_peers\n';
returnText += '# TYPE wireguard_enabled_peers gauge\n';
returnText += `wireguard_enabled_peers{interface="wg0"} ${wireguardEnabledPeersCount}\n`;
returnText += '\n# HELP wireguard_connected_peers\n';
returnText += '# TYPE wireguard_connected_peers gauge\n';
returnText += `wireguard_connected_peers{interface="wg0"} ${wireguardConnectedPeersCount}\n`;
returnText += '\n# HELP wireguard_sent_bytes Bytes sent to the peer\n';
returnText += '# TYPE wireguard_sent_bytes counter\n';
returnText += `${wireguardSentBytes}`;
returnText +=
'\n# HELP wireguard_received_bytes Bytes received from the peer\n';
returnText += '# TYPE wireguard_received_bytes counter\n';
returnText += `${wireguardReceivedBytes}`;
returnText +=
'\n# HELP wireguard_latest_handshake_seconds UNIX timestamp seconds of the last handshake\n';
returnText += '# TYPE wireguard_latest_handshake_seconds gauge\n';
returnText += `${wireguardLatestHandshakeSeconds}`;
return returnText;
}
async getMetricsJSON() {
const clients = await this.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
for (const client of Object.values(clients)) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (client.endpoint !== null) {
wireguardConnectedPeersCount++;
}
}
return {
wireguard_configured_peers: wireguardPeerCount,
wireguard_enabled_peers: wireguardEnabledPeersCount,
wireguard_connected_peers: wireguardConnectedPeersCount,
};
}
}
export default new WireGuard();

7
src/server/utils/cmd.ts

@ -1,13 +1,16 @@
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') {
console.log(`$ ${log}`);
CMD_DEBUG(`$ ${log}`);
} else if (log === true) {
console.log(`$ ${cmd}`);
CMD_DEBUG(`$ ${cmd}`);
}
if (process.platform !== 'linux') {

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

Loading…
Cancel
Save