Browse Source

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

* deprecate other languages

new ui has too many new strings

* fix wrong license in readme

* properly fetch release

* order safe data structure for migrations

* empty server allowed ips by default

* show userconfig in admin panel

* remove routes, fix config

* add ability to update clients

* handle form submit using js

avoid weird behavior with FormData

* global toast, be able to update client

* update packages

* fix date field

* delete client using radix dialog

* remove lang from backend, let users decide

* be able to change interface and general

* be able to update user config

* consistent allowedips

* fix array field

* improve avatar, code cleanup

* basic metrics support

* remove dateTime helper

* be able to change hooks

* start cidr update

* be able to update cidr
pull/1619/head
Bernd Storath 3 months ago
committed by GitHub
parent
commit
b2394d1e73
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      .vscode/settings.json
  2. 2
      README.md
  3. 2
      package.json
  4. 10
      src/app/app.vue
  5. 4
      src/app/components/ClientCard/Avatar.vue
  6. 6
      src/app/components/ClientCard/Config.vue
  7. 2
      src/app/components/ClientCard/LastSeen.vue
  8. 2
      src/app/components/ClientCard/Name.vue
  9. 2
      src/app/components/Clients/CreateDialog.vue
  10. 126
      src/app/components/Clients/DeleteDialog.vue
  11. 49
      src/app/components/admin/CidrDialog.vue
  12. 3
      src/app/components/base/Avatar.vue
  13. 1
      src/app/components/base/Switch.vue
  14. 3
      src/app/components/base/Toast.vue
  15. 9
      src/app/components/form/ActionField.vue
  16. 28
      src/app/components/form/ArrayField.vue
  17. 26
      src/app/components/form/DateField.vue
  18. 5
      src/app/components/form/Element.vue
  19. 1
      src/app/components/form/NumberField.vue
  20. 1
      src/app/components/form/TextField.vue
  21. 1
      src/app/components/header/Update.vue
  22. 13
      src/app/components/ui/ChooseLang.vue
  23. 13
      src/app/layouts/default.vue
  24. 4
      src/app/layouts/setup.vue
  25. 3
      src/app/pages/admin.vue
  26. 108
      src/app/pages/admin/config.vue
  27. 24
      src/app/pages/admin/defaults.vue
  28. 58
      src/app/pages/admin/hooks.vue
  29. 62
      src/app/pages/admin/index.vue
  30. 71
      src/app/pages/admin/interface.vue
  31. 156
      src/app/pages/clients/[id].vue
  32. 4
      src/app/pages/index.vue
  33. 7
      src/app/pages/login.vue
  34. 28
      src/app/pages/setup/1.vue
  35. 8
      src/app/pages/setup/4.vue
  36. 8
      src/app/pages/setup/5.vue
  37. 7
      src/app/pages/setup/migrate.vue
  38. 24
      src/app/stores/auth.ts
  39. 28
      src/app/stores/global.ts
  40. 13
      src/app/stores/modal.ts
  41. 33
      src/app/stores/setup.ts
  42. 26
      src/app/stores/toast.ts
  43. 46
      src/app/utils/api.ts
  44. 1
      src/app/utils/localStorage.ts
  45. 11
      src/app/utils/math.ts
  46. 49
      src/i18n/i18n.config.ts
  47. 5
      src/i18n/localeDetector.ts
  48. 56
      src/i18n/locales/be.json
  49. 27
      src/i18n/locales/ca.json
  50. 32
      src/i18n/locales/de.json
  51. 24
      src/i18n/locales/en.json
  52. 37
      src/i18n/locales/es.json
  53. 122
      src/i18n/locales/fr.json
  54. 28
      src/i18n/locales/hi.json
  55. 27
      src/i18n/locales/is.json
  56. 31
      src/i18n/locales/it.json
  57. 37
      src/i18n/locales/ko.json
  58. 27
      src/i18n/locales/nl.json
  59. 27
      src/i18n/locales/no.json
  60. 27
      src/i18n/locales/pl.json
  61. 27
      src/i18n/locales/pt.json
  62. 56
      src/i18n/locales/ru.json
  63. 27
      src/i18n/locales/th.json
  64. 38
      src/i18n/locales/tr.json
  65. 38
      src/i18n/locales/ua.json
  66. 38
      src/i18n/locales/vi.json
  67. 40
      src/i18n/locales/zh-chs.json
  68. 40
      src/i18n/locales/zh-cht.json
  69. 14
      src/nuxt.config.ts
  70. 21
      src/package.json
  71. 2175
      src/pnpm-lock.yaml
  72. 4
      src/server/api/admin/general.get.ts
  73. 8
      src/server/api/admin/general.post.ts
  74. 4
      src/server/api/admin/hooks.get.ts
  75. 9
      src/server/api/admin/hooks.post.ts
  76. 4
      src/server/api/admin/interface.get.ts
  77. 9
      src/server/api/admin/interface.post.ts
  78. 5
      src/server/api/admin/lang.post.ts
  79. 9
      src/server/api/admin/userconfig/cidr.post.ts
  80. 4
      src/server/api/admin/userconfig/index.get.ts
  81. 9
      src/server/api/admin/userconfig/index.post.ts
  82. 12
      src/server/api/client/[clientId]/address4.put.ts
  83. 11
      src/server/api/client/[clientId]/index.post.ts
  84. 9
      src/server/api/client/[clientId]/name.put.ts
  85. 5
      src/server/api/lang.get.ts
  86. 2
      src/server/api/session.post.ts
  87. 14
      src/server/api/setup/1.post.ts
  88. 5
      src/server/api/setup/migrate.post.ts
  89. 1
      src/server/middleware/session.ts
  90. 0
      src/server/routes/cnf/[oneTimeLink].ts
  91. 14
      src/server/routes/metrics/index.get.ts
  92. 13
      src/server/routes/metrics/json.get.ts
  93. 77
      src/server/utils/WireGuard.ts
  94. 2
      src/server/utils/release.ts
  95. 27
      src/server/utils/template.ts
  96. 225
      src/server/utils/types.ts
  97. 14
      src/server/utils/wgHelper.ts
  98. 126
      src/services/database/lowdb.ts
  99. 51
      src/services/database/migrations/1.ts
  100. 19
      src/services/database/migrations/index.ts

3
.vscode/settings.json

@ -21,5 +21,6 @@
],
"i18n-ally.sortKeys": false,
"i18n-ally.keepFulfilled": false,
"i18n-ally.keystyle": "nested"
"i18n-ally.keystyle": "nested",
"editor.gotoLocation.multipleDefinitions": "goto"
}

2
README.md

@ -153,7 +153,7 @@ For less common or specific edge-case scenarios, please refer to the detailed in
## License
This project is licensed under the GPL-3.0-only License - see the LICENSE file for details
This project is licensed under the AGPL-3.0-only License - see the LICENSE file for details
This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Jason A. Donenfeld, ZX2C4 or Edge Security

2
package.json

@ -5,5 +5,5 @@
"dev": "docker compose -f docker-compose.dev.yml up",
"build": "docker build -t wg-easy ."
},
"packageManager": "[email protected].0"
"packageManager": "[email protected].3"
}

10
src/app/app.vue

@ -4,14 +4,18 @@
<NuxtPage />
<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" />
</ToastViewport>
</NuxtLayout>
</ToastProvider>
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
globalStore.setLanguage();
const toast = useToast();
const toastRef = useTemplateRef('toast');
toast.setToast(toastRef);
useHead({
bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800',

4
src/app/components/ClientCard/Avatar.vue

@ -22,9 +22,7 @@
</template>
<script setup lang="ts">
const props = defineProps<{
defineProps<{
client: LocalClient;
}>();
console.log(props.client.avatar);
</script>

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

@ -1,12 +1,12 @@
<template>
<NuxtLink
:to="'/api/client/' + client.id + '/configuration'"
<a
:href="'/api/client/' + client.id + '/configuration'"
download
class="inline-block rounded bg-gray-100 p-2 align-middle transition hover:bg-red-800 hover:text-white dark:bg-neutral-600 dark:text-neutral-300 dark:hover:bg-red-800 dark:hover:text-white"
:title="$t('downloadConfig')"
>
<IconsDownload class="w-5" />
</NuxtLink>
</a>
</template>
<script setup lang="ts">

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

@ -2,7 +2,7 @@
<span
v-if="client.latestHandshakeAt"
class="whitespace-nowrap text-gray-400 dark:text-neutral-500"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))"
:title="$t('lastSeen') + $d(new Date(client.latestHandshakeAt))"
>
· {{ timeago(new Date(client.latestHandshakeAt)) }}
</span>

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

@ -1,7 +1,7 @@
<template>
<div
class="text-sm text-gray-700 md:text-base dark:text-neutral-200"
:title="$t('createdOn') + dateTime(new Date(client.createdAt))"
:title="$t('createdOn') + $d(new Date(client.createdAt))"
>
<span class="border-b-2 border-t-2 border-transparent">
{{ client.name }}

2
src/app/components/Clients/CreateDialog.vue

@ -127,4 +127,6 @@
<script setup lang="ts">
const modalStore = useModalStore();
// TODO: use radix
</script>

126
src/app/components/Clients/DeleteDialog.vue

@ -1,99 +1,41 @@
<template>
<div
v-if="modalStore.clientDelete"
class="fixed inset-0 z-10 overflow-y-auto"
>
<div
class="flex min-h-screen items-center justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0"
>
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div
class="absolute inset-0 bg-gray-500 opacity-75 dark:bg-black dark:opacity-50"
/>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span
class="hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>&#8203;</span
>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
To: "opacity-100 tranneutral-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 tranneutral-y-0 sm:scale-100"
To: "opacity-0 tranneutral-y-4 sm:tranneutral-y-0 sm:scale-95"
-->
<div
class="inline-block w-full transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:max-w-lg sm:align-middle dark:bg-neutral-700"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
<DialogRoot :modal="true">
<DialogTrigger :class="triggerClass"><slot /></DialogTrigger>
<DialogPortal>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-gray-500 opacity-75 dark:bg-black dark:opacity-50"
/>
<DialogContent
class="data-[state=open]:animate-contentShow fixed left-1/2 top-1/2 z-[100] max-h-[85vh] w-[90vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md p-6 shadow-2xl focus:outline-none dark:bg-neutral-700"
>
<div class="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4 dark:bg-neutral-700">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<IconsWarning class="h-6 w-6 text-red-600" />
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3
id="modal-headline"
class="text-lg font-medium leading-6 text-gray-900 dark:text-neutral-200"
>
{{ $t('deleteClient') }}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-neutral-300">
{{ $t('deleteDialog1') }}
<strong>{{ modalStore.clientDelete.name }}</strong
>? {{ $t('deleteDialog2') }}
</p>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-neutral-600"
<DialogTitle
class="m-0 text-lg font-semibold text-gray-900 dark:text-neutral-200"
>
{{ $t('deleteClient') }}
</DialogTitle>
<DialogDescription
class="mb-5 mt-2 text-sm leading-normal text-gray-500 dark:text-neutral-300"
>
<button
type="button"
class="inline-flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm dark:bg-red-600 dark:text-white dark:hover:bg-red-700"
@click="
modalStore.deleteClient(modalStore.clientDelete);
modalStore.clientDelete = null;
"
>
{{ $t('deleteClient') }}
</button>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none sm:ml-3 sm:mt-0 sm:w-auto sm:text-sm dark:border-neutral-500 dark:bg-neutral-500 dark:text-neutral-50 dark:hover:border-neutral-600 dark:hover:bg-neutral-600"
@click="modalStore.clientDelete = null"
>
{{ $t('cancel') }}
</button>
{{ $t('deleteDialog1') }}
<strong>{{ 'test' }}</strong
>? {{ $t('deleteDialog2') }}
</DialogDescription>
<div class="mt-6 flex justify-end gap-2">
<DialogClose as-child>
<BaseButton>{{ $t('cancel') }}</BaseButton>
</DialogClose>
<DialogClose as-child>
<BaseButton @click="$emit('delete')">{{
$t('deleteClient')
}}</BaseButton>
</DialogClose>
</div>
</div>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<script setup lang="ts">
const modalStore = useModalStore();
<script lang="ts" setup>
defineEmits(['delete']);
defineProps<{ triggerClass?: string }>();
</script>

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

@ -0,0 +1,49 @@
<template>
<DialogRoot :modal="true">
<DialogTrigger :class="triggerClass"><slot /></DialogTrigger>
<DialogPortal>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-gray-500 opacity-75 dark:bg-black dark:opacity-50"
/>
<DialogContent
class="data-[state=open]:animate-contentShow fixed left-1/2 top-1/2 z-[100] max-h-[85vh] w-[90vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md p-6 shadow-2xl focus:outline-none dark:bg-neutral-700"
>
<DialogTitle
class="m-0 text-lg font-semibold text-gray-900 dark:text-neutral-200"
>
Change CIDR
</DialogTitle>
<DialogDescription
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" />
</FormGroup>
</DialogDescription>
<div class="mt-6 flex justify-end gap-2">
<DialogClose as-child>
<BaseButton>{{ $t('cancel') }}</BaseButton>
</DialogClose>
<DialogClose as-child>
<BaseButton @click="$emit('change', address4, address6)"
>Change</BaseButton
>
</DialogClose>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<script lang="ts" setup>
defineEmits(['change']);
const props = defineProps<{
triggerClass?: string;
address4: string;
address6: string;
}>();
const address4 = ref(props.address4);
const address6 = ref(props.address6);
</script>

3
src/app/components/base/Avatar.vue

@ -3,9 +3,8 @@
class="mr-2 inline-flex select-none items-center justify-center overflow-hidden rounded-full align-middle"
>
<AvatarImage
v-if="img"
class="h-full w-full rounded-[inherit] object-cover"
:src="img"
:src="img ?? ''"
/>
<AvatarFallback
class="leading-1 flex h-full w-full items-center justify-center bg-white text-sm font-medium"

1
src/app/components/base/Switch.vue

@ -2,6 +2,7 @@
<SwitchRoot
:id="id"
v-model:checked="data"
:name="id"
class="relative flex h-6 w-10 cursor-default rounded-full bg-gray-200 shadow-sm focus-within:outline focus-within:outline-red-700 data-[state=checked]:bg-red-800 dark:bg-neutral-400"
>
<SwitchThumb

3
src/app/components/base/Toast.vue

@ -11,11 +11,12 @@ defineExpose({
publish,
});
// TODO: support multiple types (info, success, error, warning)
const count = reactive<{ title: string; message: string }[]>([]);
function publish(e: { title: string; message: string }) {
count.push({ title: e.title, message: e.message });
console.log(count.length);
}
</script>

9
src/app/components/form/ActionField.vue

@ -1,11 +1,16 @@
<template>
<input
:value="label"
type="button"
:type="type ?? 'button'"
class="col-span-2 rounded-lg border-2 border-gray-100 py-2 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
</template>
<script lang="ts" setup>
defineProps<{ label: string }>();
import type { InputTypeHTMLAttribute } from 'vue';
defineProps<{
label: string;
type?: InputTypeHTMLAttribute;
}>();
</script>

28
src/app/components/form/ArrayField.vue

@ -1,28 +1,32 @@
<template>
<div class="flex flex-col">
<div v-for="(item, i) in data" :key="item">
<div v-if="data?.length === 0">
{{ emptyText || 'No items' }}
</div>
<div v-else class="flex flex-col">
<div v-for="(item, i) in data" :key="i">
<input
:value="item"
:name="name"
type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
@input="update(i)"
@input="update($event, i)"
/>
<input type="button" value="-" @click="del(i)" />
</div>
<input type="button" value="Add" @click="add" />
</div>
<input type="button" value="Add" @click="add" />
</template>
<script lang="ts" setup>
const data = defineModel<string[] | null>();
const data = defineModel<string[]>();
defineProps<{ emptyText?: string[]; name: string }>();
function update(i: number) {
return (v: string) => {
if (!data.value) {
return;
}
data.value[i] = v;
};
function update(e: Event, i: number) {
const v = (e.target as HTMLInputElement).value;
if (!data.value) {
return;
}
data.value[i] = v;
}
function add() {

26
src/app/components/form/DateField.vue

@ -0,0 +1,26 @@
<template>
<Label :for="id" class="font-semibold md:align-middle md:leading-10">
{{ label }}
</Label>
<input
:id="id"
v-model="data"
:name="id"
type="date"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
</template>
<script lang="ts" setup>
defineProps<{ id: string; label: string }>();
const [data] = defineModel<string | null>({
set(value) {
const temp = value?.trim() ?? null;
if (temp === '') {
return null;
}
return temp;
},
});
</script>

5
src/app/components/form/Element.vue

@ -0,0 +1,5 @@
<template>
<form>
<slot />
</form>
</template>

1
src/app/components/form/NumberField.vue

@ -5,6 +5,7 @@
<input
:id="id"
v-model.number="data"
:name="id"
type="number"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>

1
src/app/components/form/TextField.vue

@ -5,6 +5,7 @@
<input
:id="id"
v-model="data"
:name="id"
type="text"
class="rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>

1
src/app/components/header/Update.vue

@ -23,5 +23,4 @@
<script lang="ts" setup>
const globalStore = useGlobalStore();
globalStore.fetchRelease();
</script>

13
src/app/components/ui/ChooseLang.vue

@ -2,7 +2,7 @@
<SelectRoot v-model="langProxy" :default-value="locale">
<SelectTrigger
class="inline-flex h-[35px] min-w-[160px] items-center justify-between gap-[5px] rounded px-[15px] text-[13px] leading-none dark:bg-neutral-500 dark:text-white"
aria-label="Customise language"
aria-label="Customize language"
>
<SelectValue :placeholder="$t('setup.chooseLang')" />
<IconsArrowDown class="size-4" />
@ -31,16 +31,15 @@
</template>
<script setup lang="ts">
import { LOCALES } from '#shared/locales';
// TODO: improve
const { locale } = useI18n();
const emit = defineEmits(['update:lang']);
const { locales, locale, setLocale } = useI18n();
const langProxy = ref(locale);
watch(langProxy, (newVal) => {
emit('update:lang', newVal);
watchEffect(() => {
setLocale(langProxy.value);
});
const langs = LOCALES.sort((a, b) => a.code.localeCompare(b.code));
const langs = locales.value.sort((a, b) => a.code.localeCompare(b.code));
</script>

13
src/app/layouts/default.vue

@ -4,12 +4,12 @@
<div
class="mb-5"
:class="
hasOwnLogo
? 'flex justify-end'
: 'flex flex-auto flex-col-reverse items-center gap-3 xxs:flex-row'
loggedIn
? 'flex flex-auto flex-col-reverse items-center gap-3 xxs:flex-row'
: 'flex justify-end'
"
>
<HeaderLogo v-if="!hasOwnLogo" />
<HeaderLogo v-if="loggedIn" />
<div class="flex grow-0 items-center gap-3 self-end xxs:self-center">
<HeaderThemeSwitch />
<HeaderChartToggle />
@ -55,13 +55,10 @@
<script setup lang="ts">
const globalStore = useGlobalStore();
globalStore.fetchRelease();
const route = useRoute();
const hasOwnLogo = computed(
() => route.path === '/login' || route.path === '/setup'
);
const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/setup'
);

4
src/app/layouts/setup.vue

@ -17,13 +17,9 @@
</div>
</PanelBody>
</Panel>
<BaseToast ref="toast" />
</main>
</template>
<script lang="ts" setup>
const setupStore = useSetupStore();
const savedRef = useTemplateRef('toast');
setupStore.setErrorRef(savedRef);
</script>

3
src/app/pages/admin.vue

@ -42,8 +42,9 @@ const route = useRoute();
const menuItems = [
{ id: '', name: 'General' },
{ id: 'defaults', name: 'Defaults' },
{ id: 'config', name: 'Config' },
{ id: 'interface', name: 'Interface' },
{ id: 'hooks', name: 'Hooks' },
{ id: 'metrics', name: 'Metrics' },
];

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

@ -0,0 +1,108 @@
<template>
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>Connection</FormHeading>
<FormTextField id="host" v-model="data.host" label="Host" />
<FormNumberField id="port" v-model="data.port" label="Port" />
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIps" name="allowedIps" />
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
<FormArrayField v-model="data.defaultDns" name="defaultDns" />
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="keepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
<FormGroup>
<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>
</template>
<script lang="ts" setup>
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/userconfig`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/userconfig`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
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>

24
src/app/pages/admin/defaults.vue

@ -1,24 +0,0 @@
<template>
<div>
<FormGroup>
<FormHeading>Connection</FormHeading>
<FormTextField id="host" label="Host" />
<FormTextField id="port" label="Port" />
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField />
</FormGroup>
<FormGroup>
<FormHeading>DNS</FormHeading>
<FormArrayField />
</FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" label="MTU" />
<FormNumberField id="keepalive" label="Persistent Keepalive" />
</FormGroup>
</div>
</template>
<script setup lang="ts"></script>

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

@ -0,0 +1,58 @@
<template>
<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" />
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
</FormGroup>
</FormElement>
</main>
</template>
<script setup lang="ts">
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/hooks`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/hooks`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

62
src/app/pages/admin/index.vue

@ -1,9 +1,59 @@
<template>
<div>
<FormGroup>
<FormNumberField id="session" label="Session Timeout" />
</FormGroup>
</div>
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormNumberField
id="session"
v-model="data.sessionTimeout"
label="Session Timeout"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
</FormGroup>
</FormElement>
</main>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/general`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/general`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

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

@ -1,19 +1,58 @@
<template>
<div>
<FormGroup>
<FormHeading>Interface Settings</FormHeading>
<FormNumberField id="mtu" label="MTU" />
<FormNumberField id="port" label="Port" />
<FormTextField id="device" label="Device" />
</FormGroup>
<FormGroup>
<FormHeading>Scripts</FormHeading>
<FormTextField id="mtu" label="PreUp" />
<FormTextField id="port" label="PostUp" />
<FormTextField id="device" label="PreDown" />
<FormTextField id="device" label="PostDown" />
</FormGroup>
</div>
<main v-if="data">
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>Interface Settings</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField id="port" v-model="data.port" label="Port" />
<FormTextField id="device" v-model="data.device" label="Device" />
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
</FormGroup>
</FormElement>
</main>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
const toast = useToast();
const { data: _data, refresh } = await useFetch(`/api/admin/interface`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/admin/interface`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
await refreshNuxtData();
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
</script>

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

@ -5,45 +5,69 @@
<PanelHeadTitle :text="data.name" />
</PanelHead>
<PanelBody>
<FormGroup>
<FormHeading>
{{ $t('me.sectionGeneral') }}
</FormHeading>
<FormTextField id="name" v-model.trim="data.name" label="Name" />
<FormSwitchField
id="enabled"
v-model="data.enabled"
label="Enabled"
/>
</FormGroup>
<FormGroup>
<FormHeading>Address</FormHeading>
<FormTextField id="ipv4" v-model.trim="data.address4" label="IPv4" />
<FormTextField id="ipv6" v-model.trim="data.address6" label="IPv6" />
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIPs" />
</FormGroup>
<FormGroup>
<FormHeading>Server Allowed IPs</FormHeading>
<FormArrayField v-model="data.serverAllowedIPs" />
</FormGroup>
<FormGroup></FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="keepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField label="Delete!" />
<FormActionField label="Revert!" @click="revert" />
</FormGroup>
<FormElement @submit.prevent="submit">
<FormGroup>
<FormHeading>
{{ $t('me.sectionGeneral') }}
</FormHeading>
<FormTextField id="name" v-model.trim="data.name" label="Name" />
<FormSwitchField
id="enabled"
v-model="data.enabled"
label="Enabled"
/>
<FormDateField
id="expiresAt"
v-model.trim="data.expiresAt"
label="Expire Date"
/>
</FormGroup>
<FormGroup>
<FormHeading>Address</FormHeading>
<FormTextField
id="address4"
v-model.trim="data.address4"
label="IPv4"
/>
<FormTextField
id="address6"
v-model.trim="data.address6"
label="IPv6"
/>
</FormGroup>
<FormGroup>
<FormHeading>Allowed IPs</FormHeading>
<FormArrayField v-model="data.allowedIps" name="allowedIps" />
</FormGroup>
<FormGroup>
<FormHeading>Server Allowed IPs</FormHeading>
<FormArrayField
v-model="data.serverAllowedIPs"
name="serverAllowedIPs"
/>
</FormGroup>
<FormGroup></FormGroup>
<FormGroup>
<FormHeading>Advanced</FormHeading>
<FormNumberField id="mtu" v-model="data.mtu" label="MTU" />
<FormNumberField
id="persistentKeepalive"
v-model="data.persistentKeepalive"
label="Persistent Keepalive"
/>
</FormGroup>
<FormGroup>
<FormHeading>Actions</FormHeading>
<FormActionField type="submit" label="Save" />
<FormActionField label="Revert" @click="revert" />
<ClientsDeleteDialog
trigger-class="col-span-2"
@delete="deleteClient"
>
<FormActionField label="Delete" class="w-full" />
</ClientsDeleteDialog>
</FormGroup>
</FormElement>
</PanelBody>
</Panel>
</main>
@ -52,15 +76,69 @@
<script lang="ts" setup>
const authStore = useAuthStore();
authStore.update();
const router = useRouter();
const route = useRoute();
const toast = useToast();
const id = route.params.id as string;
const { data: _data, refresh } = await useFetch(`/api/client/${id}`, {
method: 'get',
});
const data = toRef(_data.value);
async function submit() {
try {
const res = await $fetch(`/api/client/${id}`, {
method: 'post',
body: data.value,
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Saved',
});
if (!res.success) {
throw new Error('Failed to save');
}
router.push('/');
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
async function revert() {
await refresh();
data.value = toRef(_data.value).value;
}
async function deleteClient() {
try {
const res = await $fetch(`/api/client/${id}`, {
method: 'delete',
});
toast.showToast({
type: 'success',
title: 'Success',
message: 'Deleted',
});
if (!res.success) {
throw new Error('Failed to delete');
}
router.push('/');
} catch (e) {
if (e instanceof Error) {
toast.showToast({
type: 'error',
title: 'Error',
message: e.message,
});
}
}
}
</script>

4
src/app/pages/index.vue

@ -27,7 +27,6 @@
<ClientsQRCodeDialog />
<ClientsCreateDialog />
<ClientsDeleteDialog />
</main>
</template>
@ -37,6 +36,9 @@ authStore.update();
const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
// TODO?: use hover card to show more detailed info without leaving the page
// or do something like a accordion
const intervalId = ref<NodeJS.Timeout | null>(null);
clientsStore.refresh();

7
src/app/pages/login.vue

@ -74,8 +74,6 @@
:value="$t('signIn')"
/>
</form>
<BaseToast ref="toast" />
</main>
</template>
@ -89,7 +87,7 @@ const remember = ref(false);
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const authStore = useAuthStore();
const toast = useTemplateRef('toast');
const toast = useToast();
async function login(e: Event) {
e.preventDefault();
@ -108,7 +106,8 @@ async function login(e: Event) {
}
} catch (error) {
if (error instanceof FetchError) {
toast.value?.publish({
toast.showToast({
type: 'error',
title: t('error.login'),
message: error.data.message,
});

28
src/app/pages/setup/1.vue

@ -4,39 +4,23 @@
{{ $t('setup.messageSetupLanguage') }}
</p>
<div class="mb-8 flex justify-center">
<UiChooseLang @update:lang="handleEventUpdateLang" />
<UiChooseLang />
</div>
<div><BaseButton @click="updateLang">Continue</BaseButton></div>
<div><BaseButton @click="nextStep">Continue</BaseButton></div>
</div>
</template>
<script setup lang="ts">
import { FetchError } from 'ofetch';
definePageMeta({
layout: 'setup',
});
const { t, locale, setLocale } = useI18n();
function handleEventUpdateLang(value: string) {
setLocale(value);
}
const setupStore = useSetupStore();
setupStore.setStep(1);
const router = useRouter();
async function updateLang() {
try {
await setupStore.step1(locale.value);
router.push('/setup/2');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
title: t('setup.requirements'),
message: error.data.message,
});
}
}
async function nextStep() {
router.push('/setup/2');
}
</script>

8
src/app/pages/setup/4.vue

@ -55,10 +55,13 @@ const username = ref<null | string>(null);
const password = ref<null | string>(null);
const accept = ref<boolean>(true);
const toast = useToast();
async function newAccount() {
try {
if (!username.value || !password.value) {
setupStore.handleError({
toast.showToast({
type: 'error',
title: t('setup.requirements'),
message: t('setup.emptyFields'),
});
@ -69,7 +72,8 @@ async function newAccount() {
await router.push('/setup/5');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
toast.showToast({
type: 'error',
title: t('setup.requirements'),
message: error.data.message,
});

8
src/app/pages/setup/5.vue

@ -43,9 +43,12 @@ const router = useRouter();
const host = ref<null | string>(null);
const port = ref<number>(51820);
const toast = useToast();
async function updateHostPort() {
if (!host.value || !port.value) {
setupStore.handleError({
toast.showToast({
type: 'error',
title: t('setup.requirements'),
message: t('setup.emptyFields'),
});
@ -57,7 +60,8 @@ async function updateHostPort() {
await router.push('/setup/success');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
toast.showToast({
type: 'error',
title: t('setup.requirements'),
message: error.data.message,
});

7
src/app/pages/setup/migrate.vue

@ -37,10 +37,12 @@ function onChangeFile(evt: Event) {
}
const router = useRouter();
const toast = useToast();
async function sendFile() {
if (!backupFile.value) {
setupStore.handleError({
toast.showToast({
type: 'error',
title: t('setup.requirements'),
message: t('setup.emptyFields'),
});
@ -54,7 +56,8 @@ async function sendFile() {
await router.push('/setup/success');
} catch (error) {
if (error instanceof FetchError) {
setupStore.handleError({
toast.showToast({
type: 'error',
title: t('setup.requirements'),
message: error.data.message,
});

24
src/app/stores/auth.ts

@ -1,16 +1,16 @@
export const useAuthStore = defineStore('Auth', () => {
const userData = ref<null | {
name: string;
username: string;
role: string;
email: string | null;
}>();
const { data: userData, refresh: update } = useFetch('/api/session', {
method: 'get',
});
/**
* @throws if unsuccessful
*/
async function login(username: string, password: string, remember: boolean) {
await api.createSession({ username, password, remember });
await $fetch('/api/session', {
method: 'post',
body: { username, password, remember },
});
return true as const;
}
@ -18,15 +18,11 @@ export const useAuthStore = defineStore('Auth', () => {
* @throws if unsuccessful
*/
async function logout() {
const response = await api.deleteSession();
const response = await $fetch('/api/session', {
method: 'delete',
});
return response.success;
}
async function update() {
// store role etc
const { data: response } = await api.getSession();
userData.value = response.value;
}
return { userData, login, logout, update };
});

28
src/app/stores/global.ts

@ -3,21 +3,6 @@ import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('Global', () => {
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
const { availableLocales, locale } = useI18n();
async function setLanguage() {
const { data: lang } = await useFetch('/api/lang', {
method: 'get',
});
if (
lang.value !== getItem('lang') &&
availableLocales.includes(lang.value!)
) {
setItem('lang', lang.value!);
locale.value = lang.value!;
}
}
const currentRelease = ref<null | string>(null);
const latestRelease = ref<null | { version: string; changelog: string }>(
null
@ -46,20 +31,8 @@ export const useGlobalStore = defineStore('Global', () => {
const uiChartType = ref(getItem('uiChartType') ?? 'area');
/**
* @throws if unsuccessful
*/
async function updateLang(lang: string) {
const response = await $fetch('/api/admin/lang', {
method: 'post',
body: { lang },
});
return response.success;
}
return {
sortClient,
setLanguage,
currentRelease,
latestRelease,
updateAvailable,
@ -67,6 +40,5 @@ export const useGlobalStore = defineStore('Global', () => {
uiShowCharts,
toggleCharts,
uiChartType,
updateLang,
};
});

13
src/app/stores/modal.ts

@ -2,7 +2,6 @@ import { defineStore } from 'pinia';
export const useModalStore = defineStore('Modal', () => {
const clientsStore = useClientsStore();
const clientDelete = ref<null | WGClient>(null);
const clientCreate = ref<null | boolean>(null);
const clientCreateName = ref<string>('');
const clientExpireDate = ref<string>('');
@ -19,23 +18,11 @@ export const useModalStore = defineStore('Modal', () => {
.finally(() => clientsStore.refresh().catch(console.error));
}
function deleteClient(client: WGClient | null) {
if (client === null) {
return;
}
api
.deleteClient({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => clientsStore.refresh().catch(console.error));
}
return {
clientDelete,
clientCreate,
clientCreateName,
clientExpireDate,
qrcode,
createClient,
deleteClient,
};
});

33
src/app/stores/setup.ts

@ -1,17 +1,6 @@
import { defineStore } from 'pinia';
export const useSetupStore = defineStore('Setup', () => {
/**
* @throws if unsuccessful
*/
async function step1(lang: string) {
const response = await $fetch('/api/setup/1', {
method: 'post',
body: { lang },
});
return response.success;
}
/**
* @throws if unsuccessful
*/
@ -45,25 +34,6 @@ export const useSetupStore = defineStore('Setup', () => {
return response.success;
}
type SetupError = {
title: string;
message: string;
};
type ErrorRef = {
value: { publish: (e: SetupError) => void } | null;
};
const errorRef = ref<null | ErrorRef>(null);
function setErrorRef(a: ErrorRef | null) {
errorRef.value = a;
}
function handleError(e: SetupError) {
errorRef.value?.value?.publish(e);
}
const step = ref(1);
const totalSteps = ref(6);
function setStep(i: number) {
@ -71,12 +41,9 @@ export const useSetupStore = defineStore('Setup', () => {
}
return {
step1,
step4,
step5,
runMigration,
setErrorRef,
handleError,
step,
totalSteps,
setStep,

26
src/app/stores/toast.ts

@ -0,0 +1,26 @@
export const useToast = defineStore('Toast', () => {
type ToastInterface = {
publish: (e: { title: string; message: string }) => void;
};
type ToastRef = Ref<null | ToastInterface>;
const toast = ref<Ref<ToastRef> | null>(null);
function setToast(toastInstance: ToastRef) {
toast.value = toastInstance;
}
function showToast({
title,
message,
}: {
type: 'success' | 'error';
title: string;
message: string;
}) {
toast.value?.value?.publish({ title, message });
}
return { setToast, showToast };
});

46
src/app/utils/api.ts

@ -1,31 +1,4 @@
class API {
async getSession() {
return useFetch('/api/session', {
method: 'get',
});
}
async createSession({
username,
password,
remember,
}: {
username: string;
password: string | null;
remember: boolean;
}) {
return $fetch('/api/session', {
method: 'post',
body: { username, password, remember },
});
}
async deleteSession() {
return $fetch('/api/session', {
method: 'delete',
});
}
async getClients() {
return useFetch('/api/client', {
method: 'get',
@ -45,31 +18,12 @@ class API {
});
}
async deleteClient({ clientId }: { clientId: string }) {
return $fetch(`/api/client/${clientId}`, {
method: 'delete',
});
}
async showOneTimeLink({ clientId }: { clientId: string }) {
return $fetch(`/api/client/${clientId}/generateOneTimeLink`, {
method: 'post',
});
}
async updateClientExpireDate({
clientId,
expireDate,
}: {
clientId: string;
expireDate: string | null;
}) {
return $fetch(`/api/client/${clientId}/expireDate`, {
method: 'put',
body: { expireDate },
});
}
async restoreConfiguration(file: string) {
return $fetch('/api/wireguard/restore', {
method: 'put',

1
src/app/utils/localStorage.ts

@ -1,6 +1,5 @@
export type LocalStorage = {
uiShowCharts: '1' | '0';
lang: string;
uiChartType: 'area' | 'bar' | 'line';
};

11
src/app/utils/math.ts

@ -20,17 +20,6 @@ export function bytes(
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function dateTime(value: Date) {
// TODO: results in mismatch because of different locales
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(value);
}
/**
* Sorts an array of objects by a specified property in ascending or descending order.
*

49
src/i18n/i18n.config.ts

@ -1,56 +1,9 @@
import en from './locales/en.json';
import ua from './locales/ua.json';
import ru from './locales/ru.json';
import tr from './locales/tr.json';
import no from './locales/no.json';
import pl from './locales/pl.json';
import fr from './locales/fr.json';
import de from './locales/de.json';
import ca from './locales/ca.json';
import es from './locales/es.json';
import ko from './locales/ko.json';
import vi from './locales/vi.json';
import nl from './locales/nl.json';
import is from './locales/is.json';
import pt from './locales/pt.json';
import zhChs from './locales/zh-chs.json';
import zhCht from './locales/zh-cht.json';
import it from './locales/it.json';
import th from './locales/th.json';
import hi from './locales/hi.json';
export default defineI18nConfig(() => ({
fallbackLocale: 'en',
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: {
en,
ua,
ru,
// Müslüm Barış Korkmazer @babico
tr,
// github.com/digvalley
no,
// github.com/archont94
pl,
// github.com/clem3109
fr,
de,
// github.com/guillembonet
ca,
// github.com/amarqz
es,
ko,
// https://github.com/hoangneeee
vi,
nl,
is,
pt,
zhChs,
zhCht,
it,
th,
// github.com/rahilarious
hi,
},
}));

5
src/i18n/localeDetector.ts

@ -4,7 +4,10 @@ export default defineI18nLocaleDetector((event, config) => {
return query.toString();
}
const cookie = tryCookieLocale(event, { lang: '', name: 'i18n_locale' });
const cookie = tryCookieLocale(event, {
lang: '',
name: 'i18n_redirected',
});
if (cookie) {
return cookie.toString();
}

56
src/i18n/locales/be.json

@ -1,56 +0,0 @@
{
"setup": {
"welcome": "Сардэчна запрашаем на старонку пачатковай налады wg-easy!",
"msg": "Калі ласка, увядзіце лагін для адміністратара і прыдумайце бяспечны пароль. Гэтыя дадзеныя будуць выкарыстоўвацца для ўваходу ў панэль адміністравання.",
"newPassword": "Новы пароль",
"accept": "Я згодны з умовай",
"submitBtn": "Стварыць уліковы запіс адміністратара",
"usernameCondition": "Імя карыстальніка павінна быць не менш за 8 сімвалаў.",
"passwordCondition": "Пароль павінен быць не менш за 12 сімвалаў, уключваючы 1 вялікую літару, 1 малую літару, 1 лічбу і 1 спецыяльны сімвал.",
"usernamePlaceholder": "Адміністратар",
"passwordPlaceholder": "Надзейны пароль"
},
"name": "Імя",
"password": "Пароль",
"signIn": "Увайсці",
"logout": "Выйсці",
"updateAvailable": "Даступна абнаўленне!",
"update": "Абнавіць",
"clients": "Кліенты",
"new": "Стварыць",
"deleteClient": "Выдаліць кліента",
"deleteDialog1": "Вы ўпэўнены, што хочаце выдаліць",
"deleteDialog2": "Гэта дзеянне немагчыма адмяніць.",
"cancel": "Закрыць",
"create": "Стварыць",
"createdOn": "Створана ў ",
"lastSeen": "Апошняе падключэнне ў ",
"totalDownload": "Усяго спампавана: ",
"totalUpload": "Усяго загружана: ",
"newClient": "Стварыць кліента",
"disableClient": "Выключыць кліента",
"enableClient": "Уключыць кліента",
"noClients": "Пакуль няма кліентаў.",
"noPrivKey": "Немагчыма стварыць канфігурацыю: у кліента няма вядомага прыватнага ключа.",
"showQR": "Паказаць QR-код",
"downloadConfig": "Спампаваць канфігурацыю",
"madeBy": "Аўтар",
"donate": "Падзякаваць",
"toggleCharts": "Паказаць/схаваць графікі",
"theme": {
"dark": "Цёмная тэма",
"light": "Светлая тэма",
"system": "Як у сістэме"
},
"restore": "Аднавіць",
"backup": "Рэзервовая копія",
"titleRestoreConfig": "Аднавіць канфігурацыю",
"titleBackupConfig": "Стварыць рэзервовую копію канфігурацыі",
"rememberMe": "Запомніць мяне",
"titleRememberMe": "Заставацца ў сістэме пасля закрыцця браўзера",
"sort": "Сартыроўка",
"ExpireDate": "Дата заканчэння тэрміну",
"Permanent": "Бестэрмінова",
"OneTimeLink": "Стварыць кароткую аднаразовую спасылку",
"errorInit": "Памылка ініцыялізацыі."
}

27
src/i18n/locales/ca.json

@ -1,27 +0,0 @@
{
"name": "Nom",
"password": "Contrasenya",
"signIn": "Iniciar sessió",
"logout": "Tanca sessió",
"updateAvailable": "Hi ha una actualització disponible!",
"update": "Actualitza",
"clients": "Clients",
"new": "Nou",
"deleteClient": "Esborra client",
"deleteDialog1": "Estàs segur que vols esborrar aquest client?",
"deleteDialog2": "Aquesta acció no es pot desfer.",
"cancel": "Cancel·la",
"create": "Crea",
"createdOn": "Creat el ",
"lastSeen": "Última connexió el ",
"totalDownload": "Baixada total: ",
"totalUpload": "Pujada total: ",
"newClient": "Nou client",
"disableClient": "Desactiva client",
"enableClient": "Activa client",
"noClients": "Encara no hi ha cap client.",
"showQR": "Mostra codi QR",
"downloadConfig": "Descarrega configuració",
"madeBy": "Fet per",
"donate": "Donatiu"
}

32
src/i18n/locales/de.json

@ -1,32 +0,0 @@
{
"name": "Name",
"password": "Passwort",
"signIn": "Anmelden",
"logout": "Abmelden",
"updateAvailable": "Eine Aktualisierung steht zur Verfügung!",
"update": "Aktualisieren",
"clients": "Clients",
"new": "Neu",
"deleteClient": "Client löschen",
"deleteDialog1": "Möchtest du wirklich löschen?",
"deleteDialog2": "Diese Aktion kann nicht rückgängig gemacht werden.",
"cancel": "Abbrechen",
"create": "Erstellen",
"createdOn": "Erstellt am ",
"lastSeen": "Zuletzt Online ",
"totalDownload": "Gesamt Download: ",
"totalUpload": "Gesamt Upload: ",
"newClient": "Neuer Client",
"disableClient": "Client deaktivieren",
"enableClient": "Client aktivieren",
"noClients": "Es wurden noch keine Clients konfiguriert.",
"noPrivKey": "Es ist kein Private Key für diesen Client bekannt. Eine Konfiguration kann nicht erstellt werden.",
"showQR": "Zeige den QR Code",
"downloadConfig": "Konfiguration herunterladen",
"madeBy": "Erstellt von",
"donate": "Spenden",
"restore": "Wiederherstellen",
"backup": "Sichern",
"titleRestoreConfig": "Stelle deine Konfiguration wieder her",
"titleBackupConfig": "Sichere deine Konfiguration"
}

24
src/i18n/locales/en.json

@ -19,7 +19,7 @@
"warning": "First of all, make sure you have a backup of your data if you want to migrate your users to your new wg-easy.",
"next": "Click on the arrow button to proceed to the next step."
},
"messageSetupLanguage": "Please choose a default language.",
"messageSetupLanguage": "Please choose a language for the setup.",
"messageSetupCreateAdminUser": "Please first enter an admin username and a strong secure password. This information will be used to log in to your administration panel.",
"messageSetupHostPort": "Please enter the host and port information. This will be used for the client configuration when setting up WireGuard on their devices.",
"messageSetupMigration": "Please provide the backup file if you want to migrate your data from your previous wg-easy version to your new setup.",
@ -39,10 +39,25 @@
"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",
"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",
"username": "Username must be a valid string",
"usernameMin": "Username must be at least 8 Characters",
@ -70,7 +85,12 @@
"hostMin": "Host must contain at least 1 character",
"port": "Port must be a valid number",
"portMin": "Port must be at least 1",
"portMax": "Port must be at most 65535"
"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"
},
"name": "Name",
"username": "Username",

37
src/i18n/locales/es.json

@ -1,37 +0,0 @@
{
"name": "Nombre",
"password": "Contraseña",
"signIn": "Iniciar sesión",
"logout": "Cerrar sesión",
"updateAvailable": "¡Hay una actualización disponible!",
"update": "Actualizar",
"clients": "Clientes",
"new": "Nuevo",
"deleteClient": "Eliminar cliente",
"deleteDialog1": "¿Estás seguro de que quieres borrar este cliente?",
"deleteDialog2": "Esta acción no podrá ser revertida.",
"cancel": "Cancelar",
"create": "Crear",
"createdOn": "Creado el ",
"lastSeen": "Última conexión el ",
"totalDownload": "Total descargado: ",
"totalUpload": "Total subido: ",
"newClient": "Nuevo cliente",
"disableClient": "Desactivar cliente",
"enableClient": "Activar cliente",
"noClients": "Aún no hay ningún cliente.",
"showQR": "Mostrar código QR",
"downloadConfig": "Descargar configuración",
"madeBy": "Hecho por",
"donate": "Donar",
"toggleCharts": "Mostrar/Ocultar gráficos",
"theme": {
"dark": "Modo oscuro",
"light": "Modo claro",
"system": "Modo automático"
},
"restore": "Restaurar",
"backup": "Realizar copia de seguridad",
"titleRestoreConfig": "Restaurar su configuración",
"titleBackupConfig": "Realizar copia de seguridad de su configuración"
}

122
src/i18n/locales/fr.json

@ -1,122 +0,0 @@
{
"pages": {
"me": "Compte",
"clients": "Clients"
},
"me": {
"sectionGeneral": "Général",
"sectionPassword": "Mot de passe"
},
"email": "E-Mail",
"save": "Enregistrer",
"updatePassword": "Changer le mot de passe",
"currentPassword": "Mot de passe actuel",
"confirmPassword": "Confirmer le mot de passe",
"setup": {
"welcome": "Bienvenue à votre première installation de wg-easy !",
"messageWelcome": {
"whatIs": "Vous avez trouvé le moyen le plus simple d'installer et de gérer WireGuard sur n'importe quel hôte Linux !",
"warning": "Tout d'abord, assurez-vous d'avoir une sauvegarde de vos données si vous voulez migrer vos utilisateurs vers votre nouveau wg-easy.",
"next": "Cliquez sur la flèche pour passer à l'étape suivante."
},
"messageSetupLanguage": "Sélectionner votre langue.",
"messageSetupCreateAdminUser": "Veuillez renseigner votre nom d'utilisateur et votre mot de passe. Ces informations seront utilisées pour vous connecter à votre page d'administration.",
"messageSetupHostPort": "Veuillez entrer les informations de l'hôte et du port. Cela sera utilisé pour la configuration du client lors de la configuration de WireGuard sur leurs appareils.",
"messageSetupMigration": " Veuillez fournir le fichier de sauvegarde si vous souhaitez migrer vos données de la version précédente de wg-easy vers votre nouvelle installation.",
"messageSetupValidation": "Bienvenue sur wg-easy ! La meilleur application pour administrer son serveur VPN WireGuard.",
"emptyFields": "Des champs sont requis",
"chooseLang": "Choisir une langue...",
"newPassword": "Nouveau mot de passe",
"accept": "J'accepte les conditions d'utilisation",
"submitBtn": "Créer un compte administrateur",
"usernamePlaceholder": "Administrateur",
"passwordPlaceholder": "Mot de passe sapau",
"requirements": "Conditions de configuration",
"host": "Hôte",
"hostPlaceholder": "wg-easy.example.com",
"port": "Port",
"portPlaceholder": "443",
"migration": "Restaurer la sauvegarde"
},
"zod": {
"id": "L'ID du client doit être un UUID valide",
"address4": "L'adresse IPv4 doit être une chaîne valide",
"name": "Le nom doit être une chaîne valide",
"nameMin": "Le nom doit contenir au moins 1 caractère",
"file": "Le fichier doit être une chaîne valide",
"username": "Le nom d'utilisateur doit être une chaîne valide",
"usernameMin": "Le nom d'utilisateur doit contenir au moins 8 caractères",
"password": "Le mot de passe doit être une chaîne valide",
"passwordMin": "Le mot de passe doit contenir au moins 12 caractères",
"passwordUppercase": "Le mot de passe doit contenir au moins 1 lettre majuscule",
"passwordLowercase": "Le mot de passe doit contenir au moins 1 lettre minuscule",
"passwordNumber": "Le mot de passe doit contenir au moins 1 chiffre",
"passwordSpecial": "Le mot de passe doit contenir au moins 1 caractère spécial",
"accept": "Veuillez accpter les conditions d'utilisation",
"remember": "La case « se souvenir de moi » doit être un booléen valide",
"expireDate": "La date d'expiration doit être une chaîne valide",
"expireDateMin": "La date d'expiration doit contenir au moins 1 caractère",
"otl": "Le lien à usage unique doit être une chaîne valide",
"otlMin": "Le lien à usage unique doit contenir au moins 1 caractère",
"features": "La clé doit être une chaîne valide",
"ftBool": "Le paramètre « activé » doit être un booléen valide",
"ftObj": "La valeur doit être un objet valide",
"ftObj2": "Les fonctionnalités doivent être un enregistrement valide",
"stat": "Les statistiques doivent être un objet valide",
"statBool": "Le paramètre « activé » doit être un booléen valide",
"statNumber": "Le type de graphique doit être un nombre valide",
"body": "Le corps doit être un objet valide",
"host": "L'hôte doit être une chaîne valide",
"hostMin": "L'hôte doit contenir au moins 1 caractère",
"port": "Le port doit être un nombre valide",
"portMin": "Le port doit être au moins 1",
"portMax": "Le port doit être au maximum 65535"
},
"name": "Nom",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"signIn": "Se Connecter",
"logout": "Se déconnecter",
"updateAvailable": "Une mise à jour est disponible !",
"update": "Mise à jour",
"new": "Nouveau",
"deleteClient": "Supprimer ce client",
"deleteDialog1": "Êtes-vous sûr de vouloir supprimer",
"deleteDialog2": "Cette action ne peut pas être annulée.",
"cancel": "Annuler",
"create": "Créer",
"createdOn": "Créé le ",
"lastSeen": "Dernière connexion le ",
"totalDownload": "Téléchargement total : ",
"totalUpload": "Téléversement total : ",
"newClient": "Nouveau client",
"disableClient": "Désactiver ce client",
"enableClient": "Activer ce client",
"noClients": "Aucun client pour le moment.",
"noPrivKey": "Ce client n'a pas de clé privée connue. Impossible de créer la configuration.",
"showQR": "Afficher le code QR",
"downloadConfig": "Télécharger la configuration",
"madeBy": "Développé par",
"donate": "Faire un don",
"toggleCharts": "Afficher/masquer les graphiques",
"theme": {
"dark": "Thème sombre",
"light": "Thème clair",
"system": "Thème du système"
},
"restore": "Restaurer",
"backup": "Sauvegarder",
"titleRestoreConfig": "Restaurer votre configuration",
"titleBackupConfig": "Sauvegarder votre configuration",
"rememberMe": "Se souvenir de moi",
"titleRememberMe": "Restez connecté après la fermeture du navigateur",
"sort": "Trier",
"ExpireDate": "Date d'expiration",
"Permanent": "Permanent",
"OneTimeLink": "Générer un lien court à usage unique",
"errorInit": "Échec de l'initialisation.",
"error": {
"clear": "Effacer",
"login": "Erreur de connexion"
}
}

28
src/i18n/locales/hi.json

@ -1,28 +0,0 @@
{
"name": "नाम",
"password": "पासवर्ड",
"signIn": "लॉगिन",
"logout": "लॉगआउट",
"updateAvailable": "अपडेट उपलब्ध है!",
"update": "अपडेट",
"clients": "उपयोगकर्ताये",
"new": "नया",
"deleteClient": "उपयोगकर्ता हटाएँ",
"deleteDialog1": "क्या आपको पक्का हटाना है",
"deleteDialog2": "यह निर्णय पलट नहीं सकता।",
"cancel": "कुछ ना करें",
"create": "बनाएं",
"createdOn": "सर्जन तारीख ",
"lastSeen": "पिछली बार देखे गए थे ",
"totalDownload": "कुल डाउनलोड: ",
"totalUpload": "कुल अपलोड: ",
"newClient": "नया उपयोगकर्ता",
"disableClient": "उपयोगकर्ता स्थगित कीजिये",
"enableClient": "उपयोगकर्ता शुरू कीजिये",
"noClients": "अभी तक कोई भी उपयोगकर्ता नहीं है।",
"noPrivKey": "ये उपयोगकर्ता की कोई भी गुप्त चाबी नहीं हे। बना नहीं सकते।",
"showQR": "क्यू आर कोड देखिये",
"downloadConfig": "डाउनलोड कॉन्फीग्यूरेशन",
"madeBy": "सर्जक",
"donate": "दान करें"
}

27
src/i18n/locales/is.json

@ -1,27 +0,0 @@
{
"name": "Nafn",
"password": "Lykilorð",
"signIn": "Skrá inn",
"logout": "Útskráning",
"updateAvailable": "Það er uppfærsla í boði!",
"update": "Uppfæra",
"clients": "Viðskiptavinir",
"new": "Nýtt",
"deleteClient": "Eyða viðskiptavin",
"deleteDialog1": "Ertu viss um að þú viljir eyða",
"deleteDialog2": "Þessi aðgerð getur ekki verið afturkallað.",
"cancel": "Hætta við",
"create": "Búa til",
"createdOn": "Búið til á ",
"lastSeen": "Síðast séð á ",
"totalDownload": "Samtals Niðurhlaða: ",
"totalUpload": "Samtals Upphlaða: ",
"newClient": "Nýr Viðskiptavinur",
"disableClient": "Gera viðskiptavin óvirkan",
"enableClient": "Gera viðskiptavin virkan",
"noClients": "Engir viðskiptavinir ennþá.",
"showQR": "Sýna QR-kóða",
"downloadConfig": "Niðurhal Stillingar",
"madeBy": "Gert af",
"donate": "Gefa"
}

31
src/i18n/locales/it.json

@ -1,31 +0,0 @@
{
"name": "Nome",
"password": "Password",
"signIn": "Accedi",
"logout": "Esci",
"updateAvailable": "È disponibile un aggiornamento!",
"update": "Aggiorna",
"clients": "Client",
"new": "Nuovo",
"deleteClient": "Elimina Client",
"deleteDialog1": "Sei sicuro di voler eliminare",
"deleteDialog2": "Questa azione non può essere annullata.",
"cancel": "Annulla",
"create": "Crea",
"createdOn": "Creato il ",
"lastSeen": "Visto l'ultima volta il ",
"totalDownload": "Totale Download: ",
"totalUpload": "Totale Upload: ",
"newClient": "Nuovo Client",
"disableClient": "Disabilita Client",
"enableClient": "Abilita Client",
"noClients": "Non ci sono ancora client.",
"showQR": "Mostra codice QR",
"downloadConfig": "Scarica configurazione",
"madeBy": "Realizzato da",
"donate": "Donazione",
"restore": "Ripristina",
"backup": "Backup",
"titleRestoreConfig": "Ripristina la tua configurazione",
"titleBackupConfig": "Esegui il backup della tua configurazione"
}

37
src/i18n/locales/ko.json

@ -1,37 +0,0 @@
{
"name": "이름",
"password": "암호",
"signIn": "로그인",
"logout": "로그아웃",
"updateAvailable": "업데이트가 있습니다!",
"update": "업데이트",
"clients": "클라이언트",
"new": "추가",
"deleteClient": "클라이언트 삭제",
"deleteDialog1": "삭제 하시겠습니까?",
"deleteDialog2": "이 작업은 취소할 수 없습니다.",
"cancel": "취소",
"create": "생성",
"createdOn": "생성일: ",
"lastSeen": "마지막 사용 날짜: ",
"totalDownload": "총 다운로드: ",
"totalUpload": "총 업로드: ",
"newClient": "새로운 클라이언트",
"disableClient": "클라이언트 비활성화",
"enableClient": "클라이언트 활성화",
"noClients": "아직 클라이언트가 없습니다.",
"showQR": "QR 코드 표시",
"downloadConfig": "구성 다운로드",
"madeBy": "만든 사람",
"donate": "기부",
"toggleCharts": "차트 표시/숨기기",
"theme": {
"dark": "어두운 테마",
"light": "밝은 테마",
"system": "자동 테마"
},
"restore": "복원",
"backup": "백업",
"titleRestoreConfig": "구성 파일 복원",
"titleBackupConfig": "구성 파일 백업"
}

27
src/i18n/locales/nl.json

@ -1,27 +0,0 @@
{
"name": "Naam",
"password": "Wachtwoord",
"signIn": "Inloggen",
"logout": "Uitloggen",
"updateAvailable": "Nieuw update beschikbaar!",
"update": "update",
"clients": "clients",
"new": "Nieuw",
"deleteClient": "client verwijderen",
"deleteDialog1": "Weet je zeker dat je wilt verwijderen",
"deleteDialog2": "Deze actie kan niet ongedaan worden gemaakt.",
"cancel": "Annuleren",
"create": "Creëren",
"createdOn": "Gemaakt op ",
"lastSeen": "Laatst gezien op ",
"totalDownload": "Totaal Gedownload: ",
"totalUpload": "Totaal Geupload: ",
"newClient": "Nieuwe client",
"disableClient": "client uitschakelen",
"enableClient": "client inschakelen",
"noClients": "Er zijn nog geen clients.",
"showQR": "QR-code weergeven",
"downloadConfig": "Configuratie downloaden",
"madeBy": "Gemaakt door",
"donate": "Doneren"
}

27
src/i18n/locales/no.json

@ -1,27 +0,0 @@
{
"name": "Navn",
"password": "Passord",
"signIn": "Logg Inn",
"logout": "Logg Ut",
"updateAvailable": "En ny oppdatering er tilgjengelig!",
"update": "Oppdater",
"clients": "Klienter",
"new": "Ny",
"deleteClient": "Slett Klient",
"deleteDialog1": "Er du sikker på at du vil slette?",
"deleteDialog2": "Denne handlingen kan ikke angres",
"cancel": "Avbryt",
"create": "Opprett",
"createdOn": "Opprettet ",
"lastSeen": "Sist sett ",
"totalDownload": "Total Nedlasting: ",
"totalUpload": "Total Opplasting: ",
"newClient": "Ny Klient",
"disableClient": "Deaktiver Klient",
"enableClient": "Aktiver Klient",
"noClients": "Ingen klienter opprettet enda.",
"showQR": "Vis QR Kode",
"downloadConfig": "Last Ned Konfigurasjon",
"madeBy": "Laget av",
"donate": "Doner"
}

27
src/i18n/locales/pl.json

@ -1,27 +0,0 @@
{
"name": "Nazwa",
"password": "Hasło",
"signIn": "Zaloguj się",
"logout": "Wyloguj się",
"updateAvailable": "Dostępna aktualizacja!",
"update": "Aktualizuj",
"clients": "Klienci",
"new": "Stwórz klienta",
"deleteClient": "Usuń klienta",
"deleteDialog1": "Jesteś pewny że chcesz usunąć",
"deleteDialog2": "Tej akcji nie da się cofnąć.",
"cancel": "Anuluj",
"create": "Stwórz",
"createdOn": "Utworzono ",
"lastSeen": "Ostatnio widziany ",
"totalDownload": "Całkowite pobieranie: ",
"totalUpload": "Całkowite wysyłanie: ",
"newClient": "Nowy klient",
"disableClient": "Wyłączenie klienta",
"enableClient": "Włączenie klienta",
"noClients": "Nie ma jeszcze klientów.",
"showQR": "Pokaż kod QR",
"downloadConfig": "Pobierz konfigurację",
"madeBy": "Stworzone przez",
"donate": "Wsparcie autora"
}

27
src/i18n/locales/pt.json

@ -1,27 +0,0 @@
{
"name": "Nome",
"password": "Palavra Chave",
"signIn": "Entrar",
"logout": "Sair",
"updateAvailable": "Existe uma atualização disponível!",
"update": "Atualizar",
"clients": "Clientes",
"new": "Novo",
"deleteClient": "Apagar Clientes",
"deleteDialog1": "Tem certeza que pretende apagar",
"deleteDialog2": "Esta ação não pode ser revertida.",
"cancel": "Cancelar",
"create": "Criar",
"createdOn": "Criado em ",
"lastSeen": "Último acesso em ",
"totalDownload": "Total Download: ",
"totalUpload": "Total Upload: ",
"newClient": "Novo Cliente",
"disableClient": "Desativar Cliente",
"enableClient": "Ativar Cliente",
"noClients": "Não existem ainda clientes.",
"showQR": "Apresentar o código QR",
"downloadConfig": "Descarregar Configuração",
"madeBy": "Feito por",
"donate": "Doar"
}

56
src/i18n/locales/ru.json

@ -1,56 +0,0 @@
{
"setup": {
"welcome": "Добро пожаловать на страницу начальной настройки wg-easy !",
"msg": "Пожалуйста, введите логин для администратора и придумайте безопасный пароль. Эти данные будут использоваться для входа в панель администрирования.",
"newPassword": "Новый пароль",
"accept": "Я согласен с условием",
"submitBtn": "Создать учетную запись администратора",
"usernameCondition": "Имя пользователя должно быть не менее 8 символов.",
"passwordCondition": "Пароль должен быть не менее 12 символов, включая 1 заглавную букву, 1 прописную букву, 1 цифру и 1 специальный символ.",
"usernamePlaceholder": "Администратор",
"passwordPlaceholder": "Надежный пароль"
},
"name": "Имя",
"password": "Пароль",
"signIn": "Войти",
"logout": "Выйти",
"updateAvailable": "Доступно обновление!",
"update": "Обновить",
"clients": "Клиенты",
"new": "Создать",
"deleteClient": "Удалить клиента",
"deleteDialog1": "Вы уверены, что хотите удалить",
"deleteDialog2": "Это действие невозможно отменить.",
"cancel": "Закрыть",
"create": "Создать",
"createdOn": "Создано в ",
"lastSeen": "Последнее подключение в ",
"totalDownload": "Всего скачано: ",
"totalUpload": "Всего загружено: ",
"newClient": "Создать клиента",
"disableClient": "Выключить клиента",
"enableClient": "Включить клиента",
"noClients": "Пока нет клиентов.",
"noPrivKey": "Невозможно создать конфигурацию: у клиента нет известного приватного ключа.",
"showQR": "Показать QR-код",
"downloadConfig": "Скачать конфигурацию",
"madeBy": "Автор",
"donate": "Поблагодарить",
"toggleCharts": "Показать/скрыть графики",
"theme": {
"dark": "Тёмная тема",
"light": "Светлая тема",
"system": "Как в системе"
},
"restore": "Восстановить",
"backup": "Резервная копия",
"titleRestoreConfig": "Восстановить конфигурацию",
"titleBackupConfig": "Создать резервную копию конфигурации",
"rememberMe": "Запомнить меня",
"titleRememberMe": "Оставаться в системе после закрытия браузера",
"sort": "Сортировка",
"ExpireDate": "Дата истечения срока",
"Permanent": "Бессрочно",
"OneTimeLink": "Создать короткую одноразовую ссылку",
"errorInit": "Ошибка инициализации."
}

27
src/i18n/locales/th.json

@ -1,27 +0,0 @@
{
"name": "ชื่อ",
"password": "รหัสผ่าน",
"signIn": "ลงชื่อเข้าใช้",
"logout": "ออกจากระบบ",
"updateAvailable": "มีอัปเดตพร้อมใช้งาน!",
"update": "อัปเดต",
"clients": "Clients",
"new": "ใหม่",
"deleteClient": "ลบ Client",
"deleteDialog1": "คุณแน่ใจหรือไม่ว่าต้องการลบ",
"deleteDialog2": "การกระทำนี้;ไม่สามารถยกเลิกได้",
"cancel": "ยกเลิก",
"create": "สร้าง",
"createdOn": "สร้างเมื่อ ",
"lastSeen": "เห็นครั้งสุดท้ายเมื่อ ",
"totalDownload": "ดาวน์โหลดทั้งหมด: ",
"totalUpload": "อัพโหลดทั้งหมด: ",
"newClient": "Client ใหม่",
"disableClient": "ปิดการใช้งาน Client",
"enableClient": "เปิดการใช้งาน Client",
"noClients": "ยังไม่มี Clients เลย",
"showQR": "แสดงรหัส QR",
"downloadConfig": "ดาวน์โหลดการตั้งค่า",
"madeBy": "สร้างโดย",
"donate": "บริจาค"
}

38
src/i18n/locales/tr.json

@ -1,38 +0,0 @@
{
"name": "İsim",
"password": "Şifre",
"signIn": "Giriş Yap",
"logout": "Çıkış Yap",
"updateAvailable": "Mevcut bir güncelleme var!",
"update": "Güncelle",
"clients": "Kullanıcılar",
"new": "Yeni",
"deleteClient": "Kullanıcı Sil",
"deleteDialog1": "Silmek istediğine emin misin",
"deleteDialog2": "Bu işlem geri alınamaz.",
"cancel": "İptal",
"create": "Oluştur",
"createdOn": "Şu saatte oluşturuldu: ",
"lastSeen": "Son görülme tarihi: ",
"totalDownload": "Toplam İndirme: ",
"totalUpload": "Toplam Yükleme: ",
"newClient": "Yeni Kullanıcı",
"disableClient": "Kullanıcıyı Devre Dışı Bırak",
"enableClient": "Kullanıcıyı Etkinleştir",
"noClients": "Henüz kullanıcı yok.",
"noPrivKey": "Bu istemcinin bilinen bir özel anahtarı yok. Yapılandırma oluşturulamıyor.",
"showQR": "QR Kodunu Göster",
"downloadConfig": "Yapılandırmayı İndir",
"madeBy": "Yapan Kişi: ",
"donate": "Bağış Yap",
"toggleCharts": "Grafiği göster/gizle",
"theme": {
"dark": "Karanlık tema",
"light": "Açık tema",
"system": "Otomatik tema"
},
"restore": "Geri yükle",
"backup": "Yedekle",
"titleRestoreConfig": "Yapılandırmanızı geri yükleyin",
"titleBackupConfig": "Yapılandırmanızı yedekleyin"
}

38
src/i18n/locales/ua.json

@ -1,38 +0,0 @@
{
"name": "Ім`я",
"password": "Пароль",
"signIn": "Увійти",
"logout": "Вихід",
"updateAvailable": "Доступне оновлення!",
"update": "Оновити",
"clients": "Клієнти",
"new": "Новий",
"deleteClient": "Видалити клієнта",
"deleteDialog1": "Ви впевнені, що бажаєте видалити",
"deleteDialog2": "Цю дію неможливо скасувати.",
"cancel": "Скасувати",
"create": "Створити",
"createdOn": "Створено ",
"lastSeen": "Останнє підключення в ",
"totalDownload": "Всього завантажено: ",
"totalUpload": "Всього відправлено: ",
"newClient": "Новий клієнт",
"disableClient": "Вимкнути клієнта",
"enableClient": "Увімкнути клієнта",
"noClients": "Ще немає клієнтів.",
"noPrivKey": "У цього клієнта немає відомого приватного ключа. Неможливо створити конфігурацію.",
"showQR": "Показати QR-код",
"downloadConfig": "Завантажити конфігурацію",
"madeBy": "Зроблено",
"donate": "Пожертвувати",
"toggleCharts": "Показати/сховати діаграми",
"theme": {
"dark": "Темна тема",
"light": "Світла тема",
"system": "Автоматична тема"
},
"restore": "Відновити",
"backup": "Резервна копія",
"titleRestoreConfig": "Відновити конфігурацію",
"titleBackupConfig": "Створити резервну копію конфігурації"
}

38
src/i18n/locales/vi.json

@ -1,38 +0,0 @@
{
"name": "Tên",
"password": "Mật khẩu",
"signIn": "Đăng nhập",
"logout": "Đăng xuất",
"updateAvailable": "Có bản cập nhật mới!",
"update": "Cập nhật",
"clients": "Danh sách người dùng",
"new": "Mới",
"deleteClient": "Xóa người dùng",
"deleteDialog1": "Bạn có chắc chắn muốn xóa",
"deleteDialog2": "Thao tác này không thể hoàn tác.",
"cancel": "Huỷ",
"create": "Tạo",
"createdOn": "Được tạo lúc ",
"lastSeen": "Lần xem cuối vào ",
"totalDownload": "Tổng dung lượng tải xuống: ",
"totalUpload": "Tổng dung lượng tải lên: ",
"newClient": "Người dùng mới",
"disableClient": "Vô hiệu hóa người dùng",
"enableClient": "Kích hoạt người dùng",
"noClients": "Hiện chưa có người dùng nào.",
"showQR": "Hiển thị mã QR",
"downloadConfig": "Tải xuống cấu hình",
"madeBy": "Được tạo bởi",
"donate": "Ủng hộ",
"toggleCharts": "Mở/Ẩn Biểu đồ",
"theme": {
"dark": "Dark theme",
"light": "Light theme",
"system": "System theme"
},
"restore": "Khôi phục",
"backup": "Sao lưu",
"titleRestoreConfig": "Khôi phục cấu hình của bạn",
"titleBackupConfig": "Sao lưu cấu hình của bạn",
"sort": "Sắp xếp"
}

40
src/i18n/locales/zh-chs.json

@ -1,40 +0,0 @@
{
"name": "名称",
"password": "密码",
"signIn": "登录",
"logout": "退出",
"updateAvailable": "有新版本可用!",
"update": "更新",
"clients": "客户端",
"new": "新建",
"deleteClient": "删除客户端",
"deleteDialog1": "您确定要删除",
"deleteDialog2": "此操作无法撤销。",
"cancel": "取消",
"create": "创建",
"createdOn": "创建于 ",
"lastSeen": "最后访问于 ",
"totalDownload": "总下载: ",
"totalUpload": "总上传: ",
"newClient": "新建客户端",
"disableClient": "禁用客户端",
"enableClient": "启用客户端",
"noClients": "目前没有客户端。",
"noPrivKey": "此客户端没有已知的私钥。无法创建配置。",
"showQR": "显示二维码",
"downloadConfig": "下载配置",
"madeBy": "由",
"donate": "捐赠",
"toggleCharts": "显示/隐藏图表",
"theme": { "dark": "暗黑主题", "light": "明亮主题", "system": "自动主题" },
"restore": "恢复",
"backup": "备份",
"titleRestoreConfig": "恢复您的配置",
"titleBackupConfig": "备份您的配置",
"rememberMe": "记住我",
"titleRememberMe": "关闭浏览器后保持登录",
"sort": "排序",
"ExpireDate": "到期日期",
"Permanent": "永久",
"OneTimeLink": "生成一次性短链接"
}

40
src/i18n/locales/zh-cht.json

@ -1,40 +0,0 @@
{
"name": "名字",
"password": "密碼",
"signIn": "登入",
"logout": "登出",
"updateAvailable": "有新版本可以使用!",
"update": "更新",
"clients": "使用者",
"new": "建立",
"deleteClient": "刪除使用者",
"deleteDialog1": "您確定要刪除",
"deleteDialog2": "此作業無法復原。",
"cancel": "取消",
"create": "建立",
"createdOn": "建立於 ",
"lastSeen": "最後存取於 ",
"totalDownload": "總下載: ",
"totalUpload": "總上傳: ",
"newClient": "新用戶",
"disableClient": "停用使用者",
"enableClient": "啟用使用者",
"noClients": "目前沒有使用者。",
"noPrivKey": "此使用者沒有已知的私鑰。無法創建配置。",
"showQR": "顯示 QR Code",
"downloadConfig": "下載 Config 檔",
"madeBy": "由",
"donate": "抖內",
"toggleCharts": "顯示/隱藏圖表",
"theme": { "dark": "暗黑主題", "light": "明亮主題", "system": "自動主題" },
"restore": "恢復",
"backup": "備份",
"titleRestoreConfig": "恢復您的配置",
"titleBackupConfig": "備份您的配置",
"rememberMe": "記住我",
"titleRememberMe": "關閉瀏覽器後保持登錄",
"sort": "排序",
"ExpireDate": "到期日期",
"Permanent": "永久",
"OneTimeLink": "生成一次性短鏈接"
}

14
src/nuxt.config.ts

@ -23,10 +23,24 @@ export default defineNuxtConfig({
experimental: {
localeDetector: './localeDetector.ts',
},
locales: [
{
code: 'en',
language: 'en-US',
name: 'English',
},
],
defaultLocale: 'en',
vueI18n: './i18n.config.ts',
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
},
},
nitro: {
esbuild: {
options: {
// to support big int
target: 'es2020',
},
},

21
src/package.json

@ -14,37 +14,38 @@
"format": "prettier . --write",
"format:check": "prettier . --check",
"typecheck": "nuxt typecheck",
"check:runall": "nuxt build && nuxt typecheck && eslint . && prettier . --check"
"check:all": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm build"
},
"dependencies": {
"@eschricht/nuxt-color-mode": "^1.1.5",
"@nuxtjs/i18n": "^9.1.1",
"@nuxtjs/tailwindcss": "^6.12.2",
"@pinia/nuxt": "^0.9.0",
"@tailwindcss/forms": "^0.5.9",
"apexcharts": "^4.2.0",
"@tailwindcss/forms": "^0.5.10",
"apexcharts": "^4.3.0",
"argon2": "^0.41.1",
"basic-auth": "^2.0.1",
"cidr-tools": "^11.0.2",
"crc-32": "^1.2.2",
"debug": "^4.4.0",
"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.14.1592",
"nuxt": "^3.15.1",
"pinia": "^2.3.0",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.11",
"radix-vue": "^1.9.12",
"semver": "^7.6.3",
"tailwindcss": "^3.4.16",
"tailwindcss": "^3.4.17",
"timeago.js": "^4.0.2",
"vue": "latest",
"vue3-apexcharts": "^1.8.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.7.3",
"@nuxt/eslint-config": "^0.7.5",
"@types/debug": "^4.1.12",
"@types/qrcode": "^1.5.5",
"@types/semver": "^7.5.8",
@ -52,8 +53,8 @@
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"typescript": "^5.7.2",
"vue-tsc": "^2.1.10"
"typescript": "^5.7.3",
"vue-tsc": "^2.2.0"
},
"packageManager": "[email protected].0"
"packageManager": "[email protected].3"
}

2175
src/pnpm-lock.yaml

File diff suppressed because it is too large

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

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

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

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

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

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

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

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

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

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

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

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

5
src/server/api/admin/lang.post.ts

@ -1,5 +0,0 @@
export default defineEventHandler(async (event) => {
const { lang } = await readValidatedBody(event, validateZod(langType));
await Database.system.updateLang(lang);
return { success: true };
});

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

@ -0,0 +1,9 @@
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

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

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

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

12
src/server/api/client/[clientId]/address4.put.ts

@ -1,12 +0,0 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
const { address4 } = await readValidatedBody(
event,
validateZod(address4Type)
);
await WireGuard.updateClientAddress({ clientId, address4 });
return { success: true };
});

11
src/server/api/client/[clientId]/expireDate.put.ts → src/server/api/client/[clientId]/index.post.ts

@ -3,13 +3,16 @@ export default defineEventHandler(async (event) => {
event,
validateZod(clientIdType)
);
const { expireDate } = await readValidatedBody(
const data = await readValidatedBody(
event,
validateZod(expireDateType)
validateZod(clientUpdateType, event)
);
await WireGuard.updateClientExpireDate({
await WireGuard.updateClient({
clientId,
expireDate,
client: data,
});
return { success: true };
});

9
src/server/api/client/[clientId]/name.put.ts

@ -1,9 +0,0 @@
export default defineEventHandler(async (event) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(clientIdType)
);
const { name } = await readValidatedBody(event, validateZod(nameType));
await WireGuard.updateClientName({ clientId, name });
return { success: true };
});

5
src/server/api/lang.get.ts

@ -1,5 +0,0 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
const system = await Database.system.get();
return system.general.lang;
});

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

@ -40,5 +40,5 @@ export default defineEventHandler(async (event) => {
SERVER_DEBUG(`New Session: ${data.id}`);
return { success: true, requiresPassword: true };
return { success: true };
});

14
src/server/api/setup/1.post.ts

@ -1,14 +0,0 @@
export default defineEventHandler(async (event) => {
const setupDone = await Database.setup.done();
if (setupDone) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state',
});
}
const { lang } = await readValidatedBody(event, validateZod(langType));
await Database.system.updateLang(lang);
await Database.setup.set(2);
return { success: true };
});

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

@ -61,11 +61,10 @@ export default defineEventHandler(async (event) => {
clients: {} as Database['clients'],
};
for (const [oldId, oldClient] of Object.entries(oldConfig.clients)) {
for (const oldClient of Object.values(oldConfig.clients)) {
const address6 = nextIPv6(db.system, db.clients);
await Database.client.create({
id: oldId,
address4: oldClient.address,
enabled: oldClient.enabled,
name: oldClient.name,
@ -74,7 +73,7 @@ export default defineEventHandler(async (event) => {
publicKey: oldClient.publicKey,
expiresAt: null,
oneTimeLink: null,
allowedIPs: [...db.system.userConfig.allowedIps],
allowedIps: [...db.system.userConfig.allowedIps],
serverAllowedIPs: [],
persistentKeepalive: 0,
address6: address6,

1
src/server/middleware/session.ts

@ -8,7 +8,6 @@ export default defineEventHandler(async (event) => {
!url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/api/setup/') ||
url.pathname === '/api/session' ||
url.pathname === '/api/lang' ||
url.pathname === '/api/release'
) {
return;

0
src/server/api/cnf/[oneTimeLink].ts → src/server/routes/cnf/[oneTimeLink].ts

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

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

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

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

77
src/server/utils/WireGuard.ts

@ -1,11 +1,13 @@
import fs from 'node:fs/promises';
import debug from 'debug';
import crypto from 'node:crypto';
import QRCode from 'qrcode';
import CRC32 from 'crc-32';
import isCidr from 'is-cidr';
import type { NewClient } from '~~/services/database/repositories/client';
import { isIPv4 } from 'is-ip';
import type {
CreateClient,
UpdateClient,
} from '~~/services/database/repositories/client';
const DEBUG = debug('WireGuard');
@ -59,7 +61,7 @@ class WireGuard {
createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt),
expiresAt: client.expiresAt,
allowedIPs: client.allowedIPs,
allowedIps: client.allowedIps,
oneTimeLink: client.oneTimeLink,
persistentKeepalive: null as string | null,
latestHandshakeAt: null as Date | null,
@ -140,11 +142,7 @@ class WireGuard {
const address6 = nextIPv6(system, clients);
// Create Client
const id = crypto.randomUUID();
const client: NewClient = {
id,
const client: CreateClient = {
name,
address4,
address6,
@ -154,8 +152,8 @@ class WireGuard {
oneTimeLink: null,
expiresAt: null,
enabled: true,
allowedIPs: [...system.userConfig.allowedIps],
serverAllowedIPs: null,
allowedIps: [...system.userConfig.allowedIps],
serverAllowedIPs: [],
persistentKeepalive: system.userConfig.persistentKeepalive,
mtu: system.userConfig.mtu,
};
@ -208,55 +206,48 @@ class WireGuard {
await this.saveConfig();
}
async updateClientName({
async updateClient({
clientId,
name,
client,
}: {
clientId: string;
name: string;
client: UpdateClient;
}) {
await Database.client.updateName(clientId, name);
// TODO: validate ipv4, v6, expire date etc
await Database.client.update(clientId, client);
await this.saveConfig();
}
async updateClientAddress({
clientId,
async updateAddressRange({
address4,
address6,
}: {
clientId: string;
address4: string;
address6: string;
}) {
if (!isIPv4(address4)) {
throw createError({
statusCode: 400,
statusMessage: `Invalid Address: ${address4}`,
});
// TODO: be able to revert if error
if (!isCidr(address4) || !isCidr(address6)) {
throw new Error('Invalid CIDR');
}
await Database.client.updateAddress4(clientId, address4);
await Database.system.updateAddressRange(address4, address6);
await this.saveConfig();
}
const systems = await Database.system.get();
const clients = await Database.client.findAll();
async updateClientExpireDate({
clientId,
expireDate,
}: {
clientId: string;
expireDate: string | null;
}) {
let updatedDate: string | null = null;
for (const _client of Object.values(clients)) {
const clients = await Database.client.findAll();
if (expireDate) {
const date = new Date(expireDate);
date.setHours(23);
date.setMinutes(59);
date.setSeconds(59);
updatedDate = date.toISOString();
}
const client = structuredClone(_client) as DeepWriteable<typeof _client>;
await Database.client.updateExpirationDate(clientId, updatedDate);
client.address4 = nextIPv4(systems, clients);
client.address6 = nextIPv6(systems, clients);
await Database.client.update(client.id, {
...client,
});
}
await this.saveConfig();
}

2
src/server/utils/release.ts

@ -30,7 +30,7 @@ async function fetchLatestRelease() {
try {
const response = await $fetch<GithubRelease>(
'https://api.github.com/repos/wg-easy/wg-easy/releases/latest',
{ method: 'get' }
{ method: 'get', timeout: 5000 }
);
if (!response) {
throw new Error('Empty Response');

27
src/server/utils/template.ts

@ -0,0 +1,27 @@
import type { DeepReadonly } from 'vue';
import type { System } from '~~/services/database/repositories/system';
/**
* Replace all {{key}} in the template with the values[key]
*/
export function template(templ: string, values: Record<string, string>) {
return templ.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return values[key] !== undefined ? values[key] : match;
});
}
/**
* Available keys:
* - address4: IPv4 address range
* - address6: IPv6 address range
* - device: Network device
* - port: Port number
*/
export function iptablesTemplate(templ: string, system: DeepReadonly<System>) {
return template(templ, {
address4: system.userConfig.address4Range,
address6: system.userConfig.address6Range,
device: system.interface.device,
port: system.interface.port.toString(),
});
}

225
src/server/utils/types.ts

@ -1,68 +1,16 @@
import type { ZodSchema } from 'zod';
import type { ZodSchema, ZodTypeDef } from 'zod';
import { z, ZodError } from 'zod';
import type { H3Event, EventHandlerRequest } from 'h3';
import { LOCALES } from '#shared/locales';
// TODO: use i18n for messages
const objectMessage = 'zod.body';
const safeStringRefine = z
.string()
.refine(
(v) => v !== '__proto__' && v !== 'constructor' && v !== 'prototype',
{ message: 'String is malformed' }
{ message: 'zod.stringMalformed' }
);
const id = z.string().uuid('zod.id').pipe(safeStringRefine);
const address4 = z.string({ message: 'zod.address4' }).pipe(safeStringRefine);
const name = z
.string({ message: 'zod.name' })
.min(1, 'zod.nameMin')
.pipe(safeStringRefine);
const file = z.string({ message: 'zod.file' }).pipe(safeStringRefine);
const file_ = z.instanceof(File, { message: 'zod.file' });
const username = z
.string({ message: 'zod.username' })
.min(8, 'zod.usernameMin') // i18n key
.pipe(safeStringRefine);
const password = z
.string({ message: 'zod.password' })
.min(12, 'zod.passwordMin') // i18n key
.regex(/[A-Z]/, 'zod.passwordUppercase') // i18n key
.regex(/[a-z]/, 'zod.passwordLowercase') // i18n key
.regex(/\d/, 'zod.passwordNumber') // i18n key
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'zod.passwordSpecial') // i18n key
.pipe(safeStringRefine);
const accept = z.boolean().refine((val) => val === true, {
message: 'zod.accept',
}); // i18n key
const remember = z.boolean({ message: 'zod.remember' }); // i18n key
const expireDate = z
.string({ message: 'zod.expireDate' }) // i18n key
.min(1, 'zod.expireDateMin') // i18n key
.pipe(safeStringRefine)
.nullable();
const oneTimeLink = z
.string({ message: 'zod.otl' }) // i18n key
.min(1, 'zod.otlMin') // i18n key
.pipe(safeStringRefine);
const statistics = z.object(
{
enabled: z.boolean({ message: 'zod.statBool' }), // i18n key
chartType: z.number({ message: 'zod.statNumber' }), // i18n key
},
{ message: 'zod.stat' } // i18n key
);
const host = z
.string({ message: 'zod.host' })
.min(1, 'zod.hostMin')
@ -73,47 +21,24 @@ const port = z
.min(1, 'zod.portMin')
.max(65535, 'zod.portMax');
const objectMessage = 'zod.body'; // i18n key
const langs = LOCALES.map((lang) => lang.code);
const lang = z.enum(['', ...langs]);
export const langType = z.object({
lang: lang,
});
export const hostPortType = z.object({
host: host,
port: port,
});
const id = z.string().uuid('zod.id').pipe(safeStringRefine);
export const clientIdType = z.object(
{
clientId: id,
},
{ message: "This shouldn't happen" }
);
export const address4Type = z.object(
{
address4: address4,
},
{ message: objectMessage }
);
export const nameType = z.object(
{
name: name,
},
{ message: objectMessage }
);
export const expireDateType = z.object(
{
expireDate: expireDate,
},
{ message: objectMessage }
);
const oneTimeLink = z
.string({ message: 'zod.otl' })
.min(1, 'zod.otlMin')
.pipe(safeStringRefine);
export const oneTimeLinkType = z.object(
{
@ -122,6 +47,17 @@ export const oneTimeLinkType = z.object(
{ message: objectMessage }
);
const name = z
.string({ message: 'zod.name' })
.min(1, 'zod.nameMin')
.pipe(safeStringRefine);
const expireDate = z
.string({ message: 'zod.expireDate' })
.min(1, 'zod.expireDateMin')
.pipe(safeStringRefine)
.nullable();
export const createType = z.object(
{
name: name,
@ -130,6 +66,9 @@ export const createType = z.object(
{ message: objectMessage }
);
const file = z.string({ message: 'zod.file' }).pipe(safeStringRefine);
const file_ = z.instanceof(File, { message: 'zod.file' });
export const fileType = z.object(
{
file: file,
@ -143,6 +82,22 @@ export const fileType_ = z.object(
{ message: objectMessage }
);
const username = z
.string({ message: 'zod.username' })
.min(8, 'zod.usernameMin')
.pipe(safeStringRefine);
const password = z
.string({ message: 'zod.password' })
.min(12, 'zod.passwordMin')
.regex(/[A-Z]/, 'zod.passwordUppercase')
.regex(/[a-z]/, 'zod.passwordLowercase')
.regex(/\d/, 'zod.passwordNumber')
.regex(/[!@#$%^&*(),.?":{}|<>]/, 'zod.passwordSpecial')
.pipe(safeStringRefine);
const remember = z.boolean({ message: 'zod.remember' });
export const credentialsType = z.object(
{
username: username,
@ -160,6 +115,10 @@ export const passwordType = z.object(
{ message: objectMessage }
);
const accept = z.boolean().refine((val) => val === true, {
message: 'zod.accept',
});
export const passwordSetupType = z.object(
{
username: username,
@ -169,15 +128,93 @@ export const passwordSetupType = z.object(
{ message: objectMessage }
);
export const statisticsType = z.object(
{
statistics: statistics,
},
{ message: objectMessage }
);
const address = z
.string({ message: 'zod.address' })
.min(1, { message: 'zod.addressMin' })
.pipe(safeStringRefine);
const address4 = z
.string({ message: 'zod.address4' })
.min(1, { message: 'zod.address4Min' })
.pipe(safeStringRefine);
const address6 = z
.string({ message: 'zod.address6' })
.min(1, { message: 'zod.address6Min' })
.pipe(safeStringRefine);
const allowedIps = z
.array(address, { message: 'zod.allowedIps' })
.min(1, { message: 'zod.allowedIpsMin' });
const mtu = z
.number({ message: 'zod.mtu' })
.min(1280, { message: 'zod.mtuMin' })
.max(9000, { message: 'zod.mtuMax' });
const persistentKeepalive = z
.number({ message: 'zod.persistentKeepalive' })
.min(0, 'zod.persistentKeepaliveMin')
.max(65535, 'zod.persistentKeepaliveMax');
export const clientUpdateType = z.object({
name: name,
enabled: z.boolean(),
expiresAt: expireDate,
address4: address4,
address6: address6,
allowedIps: allowedIps,
serverAllowedIPs: z.array(address, { message: 'zod.serverAllowedIPs' }),
mtu: mtu,
persistentKeepalive: persistentKeepalive,
});
export const generalUpdateType = z.object({
sessionTimeout: z.number({ message: 'zod.sessionTimeout' }),
});
const device = z
.string({ message: 'zod.device' })
.min(1, 'zod.deviceMin')
.pipe(safeStringRefine);
export const interfaceUpdateType = z.object({
mtu: mtu,
port: port,
device: device,
});
export const userConfigUpdateType = z.object({
host: host,
port: port,
allowedIps: allowedIps,
defaultDns: z.array(address, { message: 'zod.dns' }),
mtu: mtu,
persistentKeepalive: persistentKeepalive,
});
const hook = z.string({ message: 'zod.hook' }).pipe(safeStringRefine);
export const hooksUpdateType = z.object({
PreUp: hook,
PostUp: hook,
PreDown: hook,
PostDown: hook,
});
export const cidrUpdateType = z.object({
address4: address,
address6: address,
});
// from https://github.com/airjp73/rvf/blob/7e7c35d98015ea5ecff5affaf89f78296e84e8b9/packages/zod-form-data/src/helpers.ts#L117
type FormDataLikeInput = {
[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>;
entries(): IterableIterator<[string, FormDataEntryValue]>;
};
export function validateZod<T>(
schema: ZodSchema<T>,
schema: ZodSchema<T> | ZodSchema<T, ZodTypeDef, FormData | FormDataLikeInput>,
event?: H3Event<EventHandlerRequest>
) {
return async (data: unknown) => {
@ -197,7 +234,7 @@ export function validateZod<T>(
let m = v.message;
if (t) {
m = t(m); // m key else v.message
m = t(m);
}
return m;
@ -208,3 +245,7 @@ export function validateZod<T>(
}
};
}
export type DeepWriteable<T> = {
-readonly [P in keyof T]: DeepWriteable<T[P]>;
};

14
src/server/utils/wgHelper.ts

@ -33,10 +33,10 @@ PrivateKey = ${system.interface.privateKey}
Address = ${system.interface.address4}/${cidr4Block}, ${system.interface.address6}/${cidr6Block}
ListenPort = ${system.interface.port}
MTU = ${system.interface.mtu}
PreUp = ${system.iptables.PreUp}
PostUp = ${system.iptables.PostUp}
PreDown = ${system.iptables.PreDown}
PostDown = ${system.iptables.PostDown}`;
PreUp = ${iptablesTemplate(system.hooks.PreUp, system)}
PostUp = ${iptablesTemplate(system.hooks.PostUp, system)}
PreDown = ${iptablesTemplate(system.hooks.PreDown, system)}
PostDown = ${iptablesTemplate(system.hooks.PostDown, system)}`;
},
generateClientConfig: (
@ -55,7 +55,7 @@ MTU = ${client.mtu}
[Peer]
PublicKey = ${system.interface.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIPs.join(', ')}
AllowedIPs = ${client.allowedIps.join(', ')}
PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
},
@ -112,7 +112,7 @@ Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
publicKey,
preSharedKey,
endpoint,
allowedIPs,
allowedIps,
latestHandshakeAt,
transferRx,
transferTx,
@ -123,7 +123,7 @@ Endpoint = ${system.userConfig.host}:${system.userConfig.port}`;
publicKey,
preSharedKey,
endpoint: endpoint === '(none)' ? null : endpoint,
allowedIPs,
allowedIps,
latestHandshakeAt:
latestHandshakeAt === '0'
? null

126
src/services/database/lowdb.ts

@ -1,26 +1,33 @@
import crypto from 'node:crypto';
import debug from 'debug';
import { JSONFilePreset } from 'lowdb/node';
import type { Low } from 'lowdb';
import type { DeepReadonly } from 'vue';
import { parseCidr } from 'cidr-tools';
import { stringifyIp } from 'ip-bigint';
import {
DatabaseProvider,
DatabaseError,
DEFAULT_DATABASE,
} from './repositories/database';
import { JSONFilePreset } from 'lowdb/node';
import type { Low } from 'lowdb';
import { UserRepository, type User } from './repositories/user';
import type { Database } from './repositories/database';
import { migrationRunner } from './migrations';
import {
ClientRepository,
type Client,
type NewClient,
type UpdateClient,
type CreateClient,
type OneTimeLink,
} from './repositories/client';
import { SystemRepository, type Lang } from './repositories/system';
import {
SystemRepository,
type General,
type UpdateWGConfig,
type UpdateWGInterface,
type WGHooks,
} from './repositories/system';
import { SetupRepository, type Steps } from './repositories/setup';
import type { DeepReadonly } from 'vue';
const DEBUG = debug('LowDB');
@ -73,13 +80,6 @@ class LowDBSystem extends SystemRepository {
return makeReadonly(system);
}
async updateLang(lang: Lang): Promise<void> {
DEBUG('Update Language');
this.#db.update((v) => {
v.system.general.lang = lang;
});
}
async updateClientsHostPort(host: string, port: number): Promise<void> {
DEBUG('Update Clients Host and Port endpoint');
this.#db.update((v) => {
@ -87,6 +87,63 @@ class LowDBSystem extends SystemRepository {
v.system.userConfig.port = port;
});
}
async updateGeneral(general: General) {
DEBUG('Update General');
this.#db.update((v) => {
v.system.general = general;
});
}
async updateInterface(wgInterface: UpdateWGInterface) {
DEBUG('Update Interface');
this.#db.update((v) => {
const oldInterface = v.system.interface;
v.system.interface = {
...oldInterface,
...wgInterface,
};
});
}
async updateUserConfig(userConfig: UpdateWGConfig) {
DEBUG('Update User Config');
this.#db.update((v) => {
const oldUserConfig = v.system.userConfig;
v.system.userConfig = {
...oldUserConfig,
...userConfig,
};
});
}
async updateHooks(hooks: WGHooks) {
DEBUG('Update Hooks');
this.#db.update((v) => {
v.system.hooks = hooks;
});
}
/**
* updates the address range and the interface address
*/
async updateAddressRange(address4Range: string, address6Range: string) {
DEBUG('Update Address Range');
const cidr4 = parseCidr(address4Range);
const cidr6 = parseCidr(address6Range);
this.#db.update((v) => {
v.system.userConfig.address4Range = address4Range;
v.system.userConfig.address6Range = address6Range;
v.system.interface.address4 = stringifyIp({
number: cidr4.start + 1n,
version: 4,
});
v.system.interface.address6 = stringifyIp({
number: cidr6.start + 1n,
version: 6,
});
});
}
}
class LowDBUser extends UserRepository {
@ -173,12 +230,13 @@ class LowDBClient extends ClientRepository {
return makeReadonly(this.#db.data.clients[id]);
}
async create(client: NewClient) {
async create(client: CreateClient) {
DEBUG('Create Client');
const id = crypto.randomUUID();
const now = new Date().toISOString();
const newClient: Client = { ...client, createdAt: now, updatedAt: now };
const newClient = { ...client, createdAt: now, updatedAt: now, id };
await this.#db.update((data) => {
data.clients[client.id] = newClient;
data.clients[id] = newClient;
});
}
@ -200,24 +258,6 @@ class LowDBClient extends ClientRepository {
});
}
async updateName(id: string, name: string) {
DEBUG('Update Client Name');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].name = name;
}
});
}
async updateAddress4(id: string, address4: string) {
DEBUG('Update Client Address4');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].address4 = address4;
}
});
}
async updateExpirationDate(id: string, expirationDate: string | null) {
DEBUG('Update Client Expiration Date');
await this.#db.update((data) => {
@ -249,6 +289,22 @@ class LowDBClient extends ClientRepository {
}
});
}
async update(id: string, client: UpdateClient) {
DEBUG('Create Client');
const now = new Date().toISOString();
await this.#db.update((data) => {
const oldClient = data.clients[id];
if (!oldClient) {
return;
}
data.clients[id] = {
...oldClient,
...client,
updatedAt: now,
};
});
}
}
export default class LowDB extends DatabaseProvider {

51
src/services/database/migrations/1.ts

@ -18,7 +18,6 @@ export async function run1(db: Low<Database>) {
system: {
general: {
sessionTimeout: 3600, // 1 hour
lang: 'en',
},
// Config to configure Server
interface: {
@ -41,12 +40,30 @@ export async function run1(db: Low<Database>) {
host: '',
port: 51820,
},
// Config to configure Firewall
iptables: {
// Config to configure Firewall or general hooks
hooks: {
PreUp: '',
PostUp: '',
PostUp: [
'iptables -t nat -A POSTROUTING -s {{address4}} -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 {{address6}} -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;',
].join(' '),
PreDown: '',
PostDown: '',
PostDown: [
'iptables -t nat -D POSTROUTING -s {{address4}} -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 {{address6}} -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;',
].join(' '),
},
metrics: {
prometheus: {
@ -65,30 +82,6 @@ export async function run1(db: Low<Database>) {
clients: {},
};
database.system.iptables.PostUp =
`iptables -t nat -A POSTROUTING -s ${database.system.userConfig.address4Range} -o ${database.system.interface.device} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
iptables -A FORWARD -i wg0 -j ACCEPT;
iptables -A FORWARD -o wg0 -j ACCEPT;
ip6tables -t nat -A POSTROUTING -s ${database.system.userConfig.address6Range} -o ${database.system.interface.device} -j MASQUERADE;
ip6tables -A INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
ip6tables -A FORWARD -i wg0 -j ACCEPT;
ip6tables -A FORWARD -o wg0 -j ACCEPT;`
.split('\n')
.join(' ');
database.system.iptables.PostDown =
`iptables -t nat -D POSTROUTING -s ${database.system.userConfig.address4Range} -o ${database.system.interface.device} -j MASQUERADE;
iptables -D INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
iptables -D FORWARD -i wg0 -j ACCEPT;
iptables -D FORWARD -o wg0 -j ACCEPT;
ip6tables -t nat -D POSTROUTING -s ${database.system.userConfig.address6Range} -o ${database.system.interface.device} -j MASQUERADE;
ip6tables -D INPUT -p udp -m udp --dport ${database.system.interface.port} -j ACCEPT;
ip6tables -D FORWARD -i wg0 -j ACCEPT;
ip6tables -D FORWARD -o wg0 -j ACCEPT;`
.split('\n')
.join(' ');
db.data = database;
db.write();
}

19
src/services/database/migrations/index.ts

@ -4,10 +4,10 @@ import { run1 } from './1';
export type MIGRATION_FN = (db: Low<Database>) => Promise<void>;
const MIGRATION_LIST = {
const MIGRATION_LIST = [
// Adds Initial Database Structure
'1': run1,
} satisfies Record<string, MIGRATION_FN>;
{ id: '1', fn: run1 },
] satisfies { id: string; fn: MIGRATION_FN }[];
/**
* Runs all migrations
@ -15,18 +15,15 @@ const MIGRATION_LIST = {
*/
export async function migrationRunner(db: Low<Database>) {
const ranMigrations = db.data.migrations;
const runMigrations = Object.keys(
MIGRATION_LIST
) as (keyof typeof MIGRATION_LIST)[];
for (const migrationId of runMigrations) {
if (ranMigrations.includes(migrationId)) {
for (const migration of MIGRATION_LIST) {
if (ranMigrations.includes(migration.id)) {
continue;
}
try {
await MIGRATION_LIST[migrationId](db);
db.data.migrations.push(migrationId);
await migration.fn(db);
db.data.migrations.push(migration.id);
} catch (e) {
throw new Error(`Failed to run Migration ${migrationId}: ${e}`);
throw new Error(`Failed to run Migration ${migration.id}: ${e}`);
}
}
await db.write();

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

Loading…
Cancel
Save