Browse Source

Feat: Rewrite Wireguard to use Database (#1345)

* update wireguard

* update

* update

* remove all config

* move all features into one route

* improve code

* fix some issues
pull/1354/head
Bernd Storath 9 months ago
committed by Bernd Storath
parent
commit
5a1ae6e1c6
  1. 2
      .gitignore
  2. 7
      src/app/app.vue
  3. 20
      src/app/components/Client/Charts.vue
  4. 4
      src/app/components/Client/Client.vue
  5. 8
      src/app/components/Client/ExpireDate.vue
  6. 2
      src/app/components/Client/LastSeen.vue
  7. 4
      src/app/components/Client/OneTimeLink.vue
  8. 2
      src/app/components/Client/OneTimeLinkBtn.vue
  9. 5
      src/app/components/Clients/CreateDialog.vue
  10. 2
      src/app/components/Clients/Sort.vue
  11. 4
      src/app/layouts/Header.vue
  12. 2
      src/app/pages/login.vue
  13. 2
      src/app/stores/auth.ts
  14. 5
      src/app/stores/clients.ts
  15. 68
      src/app/stores/global.ts
  16. 44
      src/app/utils/api.ts
  17. 26
      src/server/api/cnf/[oneTimeLink].ts
  18. 9
      src/server/api/features.get.ts
  19. 6
      src/server/api/release.get.ts
  20. 5
      src/server/api/remember-me.get.ts
  21. 10
      src/server/api/session.post.ts
  22. 11
      src/server/api/ui-chart-type.get.ts
  23. 11
      src/server/api/ui-sort-clients.get.ts
  24. 11
      src/server/api/ui-traffic-stats.get.ts
  25. 11
      src/server/api/wg-enable-expire-time.get.ts
  26. 11
      src/server/api/wg-enable-one-time-links.get.ts
  27. 3
      src/server/api/wireguard/client/[clientId]/generateOneTimeLink.post.ts
  28. 4
      src/server/middleware/session.ts
  29. 3
      src/server/middleware/setup.ts
  30. 1
      src/server/utils/Database.ts
  31. 364
      src/server/utils/WireGuard.ts
  32. 69
      src/server/utils/config.ts
  33. 3
      src/server/utils/logger.ts
  34. 12
      src/server/utils/types.ts
  35. 106
      src/services/database/lowdb.ts
  36. 64
      src/services/database/migrations/1.ts
  37. 43
      src/services/database/repositories/client.ts
  38. 27
      src/services/database/repositories/database.ts
  39. 4
      src/services/database/repositories/system.ts

2
.gitignore

@ -4,5 +4,3 @@
/src/node_modules /src/node_modules
.DS_Store .DS_Store
*.swp *.swp
# lowdb data file
db.json

7
src/app/app.vue

@ -8,13 +8,8 @@
<script setup lang="ts"> <script setup lang="ts">
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
globalStore.fetchTrafficStats(); globalStore.fetchFeatures();
globalStore.fetchChartType();
globalStore.fetchRelease(); globalStore.fetchRelease();
globalStore.fetchOneTimeLinks();
globalStore.fetchSortClients();
globalStore.fetchExpireTime();
globalStore.fetchRememberMe();
useHead({ useHead({
bodyAttrs: { bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800', class: 'bg-gray-50 dark:bg-neutral-800',

20
src/app/components/Client/Charts.vue

@ -1,13 +1,13 @@
<template> <template>
<div <div
v-if="globalStore.uiChartType" v-if="globalStore.features.trafficStats.type"
:class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${globalStore.uiChartType === 1 && 'line-chart'}`" :class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${globalStore.features.trafficStats.type === 1 && 'line-chart'}`"
> >
<UiChart :options="chartOptionsTX" :series="client.transferTxSeries" /> <UiChart :options="chartOptionsTX" :series="client.transferTxSeries" />
</div> </div>
<div <div
v-if="globalStore.uiChartType" v-if="globalStore.features.trafficStats.type"
:class="`absolute z-0 top-0 left-0 right-0 h-6 ${globalStore.uiChartType === 1 && 'line-chart'}`" :class="`absolute z-0 top-0 left-0 right-0 h-6 ${globalStore.features.trafficStats.type === 1 && 'line-chart'}`"
> >
<UiChart <UiChart
:options="chartOptionsRX" :options="chartOptionsRX"
@ -32,8 +32,10 @@ const chartOptionsTX = computed(() => {
...chartOptions, ...chartOptions,
colors: [CHART_COLORS.tx[theme.value]], colors: [CHART_COLORS.tx[theme.value]],
}; };
opts.chart.type = UI_CHART_TYPES[globalStore.uiChartType]?.type || undefined; opts.chart.type =
opts.stroke.width = UI_CHART_TYPES[globalStore.uiChartType]?.strokeWidth ?? 0; UI_CHART_TYPES[globalStore.features.trafficStats.type]?.type || undefined;
opts.stroke.width =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.strokeWidth ?? 0;
return opts; return opts;
}); });
@ -42,8 +44,10 @@ const chartOptionsRX = computed(() => {
...chartOptions, ...chartOptions,
colors: [CHART_COLORS.rx[theme.value]], colors: [CHART_COLORS.rx[theme.value]],
}; };
opts.chart.type = UI_CHART_TYPES[globalStore.uiChartType]?.type || undefined; opts.chart.type =
opts.stroke.width = UI_CHART_TYPES[globalStore.uiChartType]?.strokeWidth ?? 0; UI_CHART_TYPES[globalStore.features.trafficStats.type]?.type || undefined;
opts.stroke.width =
UI_CHART_TYPES[globalStore.features.trafficStats.type]?.strokeWidth ?? 0;
return opts; return opts;
}); });

4
src/app/components/Client/Client.vue

@ -14,7 +14,7 @@
> >
<ClientAddress :client="client" /> <ClientAddress :client="client" />
<ClientInlineTransfer <ClientInlineTransfer
v-if="!globalStore.uiTrafficStats" v-if="!globalStore.features.trafficStats.enabled"
:client="client" :client="client"
/> />
<ClientLastSeen :client="client" /> <ClientLastSeen :client="client" />
@ -25,7 +25,7 @@
<!-- Info --> <!-- Info -->
<div <div
v-if="globalStore.uiTrafficStats" v-if="globalStore.features.trafficStats.enabled"
class="flex gap-2 items-center shrink-0 text-gray-400 dark:text-neutral-400 text-xs mt-px justify-end" class="flex gap-2 items-center shrink-0 text-gray-400 dark:text-neutral-400 text-xs mt-px justify-end"
> >
<ClientTransfer :client="client" /> <ClientTransfer :client="client" />

8
src/app/components/Client/ExpireDate.vue

@ -1,6 +1,6 @@
<template> <template>
<div <div
v-show="globalStore.enableExpireTime" v-show="globalStore.features.clientExpiration.enabled"
class="block md:inline-block pb-1 md:pb-0 text-gray-500 dark:text-neutral-400 text-xs" class="block md:inline-block pb-1 md:pb-0 text-gray-500 dark:text-neutral-400 text-xs"
> >
<span class="group"> <span class="group">
@ -24,7 +24,7 @@
<span <span
v-show="clientEditExpireDateId !== client.id" v-show="clientEditExpireDateId !== client.id"
class="inline-block" class="inline-block"
>{{ expiredDateFormat(client.expireAt) }}</span >{{ expiredDateFormat(client.expiresAt) }}</span
> >
<!-- Edit --> <!-- Edit -->
@ -32,8 +32,8 @@
v-show="clientEditExpireDateId !== client.id" v-show="clientEditExpireDateId !== client.id"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click=" @click="
clientEditExpireDate = client.expireAt clientEditExpireDate = client.expiresAt
? client.expireAt.slice(0, 10) ? client.expiresAt.slice(0, 10)
: 'yyyy-mm-dd'; : 'yyyy-mm-dd';
clientEditExpireDateId = client.id; clientEditExpireDateId = client.id;
nextTick(() => clientExpireDateInput?.select()); nextTick(() => clientExpireDateInput?.select());

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

@ -4,7 +4,7 @@
class="text-gray-400 dark:text-neutral-500 whitespace-nowrap" class="text-gray-400 dark:text-neutral-500 whitespace-nowrap"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))" :title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))"
> >
{{ !globalStore.uiTrafficStats ? ' · ' : '' {{ !globalStore.features.trafficStats.enabled ? ' · ' : ''
}}{{ timeago(new Date(client.latestHandshakeAt)) }} }}{{ timeago(new Date(client.latestHandshakeAt)) }}
</span> </span>
</template> </template>

4
src/app/components/Client/OneTimeLink.vue

@ -1,9 +1,7 @@
<template> <template>
<div <div
v-if=" v-if="
globalStore.enableOneTimeLinks && globalStore.features.oneTimeLinks.enabled && client.oneTimeLink !== null
client.oneTimeLink !== null &&
client.oneTimeLink !== ''
" "
:ref="'client-' + client.id + '-link'" :ref="'client-' + client.id + '-link'"
class="text-gray-400 text-xs" class="text-gray-400 text-xs"

2
src/app/components/Client/OneTimeLinkBtn.vue

@ -1,6 +1,6 @@
<template> <template>
<button <button
v-if="globalStore.enableOneTimeLinks" v-if="globalStore.features.oneTimeLinks.enabled"
:disabled="!client.downloadableConfig" :disabled="!client.downloadableConfig"
class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition" class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:class="{ :class="{

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

@ -71,7 +71,10 @@
/> />
</p> </p>
</div> </div>
<div v-show="globalStore.enableExpireTime" class="mt-2"> <div
v-show="globalStore.features.clientExpiration.enabled"
class="mt-2"
>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
<label <label
class="block text-gray-900 dark:text-neutral-200 text-sm font-bold mb-2" class="block text-gray-900 dark:text-neutral-200 text-sm font-bold mb-2"

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

@ -1,6 +1,6 @@
<template> <template>
<button <button
v-if="globalStore.enableSortClient" v-if="globalStore.features.sortClients.enabled"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 md:rounded inline-flex items-center transition" class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 md:rounded inline-flex items-center transition"
@click="globalStore.sortClient = !globalStore.sortClient" @click="globalStore.sortClient = !globalStore.sortClient"
> >

4
src/app/layouts/Header.vue

@ -36,7 +36,7 @@
</button> </button>
<!-- Show / hide charts --> <!-- Show / hide charts -->
<label <label
v-if="globalStore.uiChartType > 0" v-if="globalStore.features.trafficStats.type > 0"
class="inline-flex items-center justify-center cursor-pointer w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 whitespace-nowrap transition group" class="inline-flex items-center justify-center cursor-pointer w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 whitespace-nowrap transition group"
:title="$t('toggleCharts')" :title="$t('toggleCharts')"
> >
@ -96,8 +96,6 @@ const currentRelease = ref<null | number>(null);
const latestRelease = ref<null | { version: number; changelog: string }>(null); const latestRelease = ref<null | { version: number; changelog: string }>(null);
const theme = useTheme(); const theme = useTheme();
globalStore.fetchChartType();
const uiShowCharts = ref(getItem('uiShowCharts') === '1'); const uiShowCharts = ref(getItem('uiShowCharts') === '1');
function toggleTheme() { function toggleTheme() {

2
src/app/pages/login.vue

@ -37,7 +37,6 @@
/> />
<label <label
v-if="globalStore.rememberMeEnabled"
class="inline-block mb-5 cursor-pointer whitespace-nowrap" class="inline-block mb-5 cursor-pointer whitespace-nowrap"
:title="$t('titleRememberMe')" :title="$t('titleRememberMe')"
> >
@ -89,7 +88,6 @@ const remember = ref(false);
const username = ref<null | string>(null); const username = ref<null | string>(null);
const password = ref<null | string>(null); const password = ref<null | string>(null);
const authStore = useAuthStore(); const authStore = useAuthStore();
const globalStore = useGlobalStore();
async function login(e: Event) { async function login(e: Event) {
e.preventDefault(); e.preventDefault();

2
src/app/stores/auth.ts

@ -5,7 +5,7 @@ export const useAuthStore = defineStore('Auth', () => {
* @throws if unsuccessful * @throws if unsuccessful
*/ */
async function signup(username: string, password: string) { async function signup(username: string, password: string) {
const response = await api.newAccount({ username, password }); const response = await api.createAccount({ username, password });
return response.success; return response.success;
} }

5
src/app/stores/clients.ts

@ -108,7 +108,10 @@ export const useClientsStore = defineStore('Clients', () => {
}; };
}); });
if (globalStore.enableSortClient && transformedClients !== undefined) { if (
globalStore.features.sortClients.enabled &&
transformedClients !== undefined
) {
transformedClients = sortByProperty( transformedClients = sortByProperty(
transformedClients, transformedClients,
'name', 'name',

68
src/app/stores/global.ts

@ -1,17 +1,26 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('Global', () => { export const useGlobalStore = defineStore('Global', () => {
const uiChartType = ref(0);
const uiShowCharts = ref(getItem('uiShowCharts') === '1'); const uiShowCharts = ref(getItem('uiShowCharts') === '1');
const currentRelease = ref<null | number>(null); const currentRelease = ref<null | number>(null);
const latestRelease = ref<null | { version: number; changelog: string }>( const latestRelease = ref<null | { version: number; changelog: string }>(
null null
); );
const uiTrafficStats = ref(false); const features = ref({
const rememberMeEnabled = ref(false); trafficStats: {
const enableExpireTime = ref(false); enabled: false,
const enableOneTimeLinks = ref(false); type: 0,
const enableSortClient = ref(false); },
sortClients: {
enabled: false,
},
clientExpiration: {
enabled: false,
},
oneTimeLinks: {
enabled: false,
},
});
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
const { availableLocales, locale } = useI18n(); const { availableLocales, locale } = useI18n();
@ -40,56 +49,23 @@ export const useGlobalStore = defineStore('Global', () => {
latestRelease.value = release.value!.latestRelease; latestRelease.value = release.value!.latestRelease;
} }
async function fetchChartType() { async function fetchFeatures() {
const { data: chartType } = await api.getChartType(); const { data: apiFeatures } = await api.getFeatures();
uiChartType.value = chartType.value ?? 0; if (apiFeatures.value) {
} features.value = apiFeatures.value;
async function fetchTrafficStats() {
const { data: trafficStats } = await api.getTrafficStats();
uiTrafficStats.value = trafficStats.value ?? false;
}
async function fetchOneTimeLinks() {
const { data: oneTimeLinks } = await api.getEnableOneTimeLinks();
enableOneTimeLinks.value = oneTimeLinks.value ?? false;
} }
async function fetchSortClients() {
const { data: sortClients } = await api.getSortClients();
enableSortClient.value = sortClients.value ?? false;
}
async function fetchExpireTime() {
const { data: expireTime } = await api.getEnableExpireTime();
enableExpireTime.value = expireTime.value ?? false;
}
async function fetchRememberMe() {
const { data: rememberMe } = await api.getRememberMeEnabled();
rememberMeEnabled.value = rememberMe.value ?? false;
} }
const updateCharts = computed(() => { const updateCharts = computed(() => {
return uiChartType.value > 0 && uiShowCharts.value; return features.value.trafficStats.type > 0 && uiShowCharts.value;
}); });
return { return {
uiChartType,
uiShowCharts, uiShowCharts,
uiTrafficStats,
updateCharts, updateCharts,
rememberMeEnabled,
enableSortClient,
sortClient, sortClient,
enableExpireTime, features,
enableOneTimeLinks,
fetchRelease, fetchRelease,
fetchChartType, fetchFeatures,
fetchTrafficStats,
fetchOneTimeLinks,
fetchSortClients,
fetchExpireTime,
fetchRememberMe,
}; };
}); });

44
src/app/utils/api.ts

@ -11,36 +11,6 @@ class API {
}); });
} }
async getRememberMeEnabled() {
return useFetch('/api/remember-me', {
method: 'get',
});
}
async getTrafficStats() {
return useFetch('/api/ui-traffic-stats', {
method: 'get',
});
}
async getChartType() {
return useFetch('/api/ui-chart-type', {
method: 'get',
});
}
async getEnableOneTimeLinks() {
return useFetch('/api/wg-enable-one-time-links', {
method: 'get',
});
}
async getEnableExpireTime() {
return useFetch('/api/wg-enable-expire-time', {
method: 'get',
});
}
async getSession() { async getSession() {
// TODO?: use useFetch // TODO?: use useFetch
return $fetch('/api/session', { return $fetch('/api/session', {
@ -158,13 +128,7 @@ class API {
}); });
} }
async getSortClients() { async createAccount({
return useFetch('/api/ui-sort-clients', {
method: 'get',
});
}
async newAccount({
username, username,
password, password,
}: { }: {
@ -176,6 +140,12 @@ class API {
body: { username, password }, body: { username, password },
}); });
} }
async getFeatures() {
return useFetch('/api/features', {
method: 'get',
});
}
} }
type WGClientReturn = Awaited< type WGClientReturn = Awaited<

26
src/server/api/cnf/[clientOneTimeLink].ts → src/server/api/cnf/[oneTimeLink].ts

@ -1,30 +1,32 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const system = await Database.getSystem(); const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
if (!system.oneTimeLinks.enabled) { if (!system.oneTimeLinks.enabled) {
throw createError({ throw createError({
status: 404, statusCode: 404,
message: 'Invalid state', statusMessage: 'Invalid state',
}); });
} }
// TODO: validate with zod const { oneTimeLink } = await getValidatedRouterParams(
const clientOneTimeLink = getRouterParam(event, 'clientOneTimeLink'); event,
validateZod(oneTimeLinkType)
);
const clients = await WireGuard.getClients(); const clients = await WireGuard.getClients();
const client = clients.find( const client = clients.find(
(client) => client.oneTimeLink === clientOneTimeLink (client) => client.oneTimeLink?.oneTimeLink === oneTimeLink
); );
if (!client) return; if (!client) {
throw createError({
statusCode: 404,
statusMessage: 'Invalid One Time Link',
});
}
const clientId = client.id; const clientId = client.id;
const config = await WireGuard.getClientConfiguration({ clientId }); const config = await WireGuard.getClientConfiguration({ clientId });
await WireGuard.eraseOneTimeLink({ clientId }); await WireGuard.eraseOneTimeLink({ clientId });
setHeader( setHeader(
event, event,
'Content-Disposition', 'Content-Disposition',
`attachment; filename="${clientOneTimeLink}.conf"` `attachment; filename="${client.name}.conf"`
); );
setHeader(event, 'Content-Type', 'text/plain'); setHeader(event, 'Content-Type', 'text/plain');
return config; return config;

9
src/server/api/features.get.ts

@ -0,0 +1,9 @@
export default defineEventHandler(async () => {
const system = await Database.getSystem();
return {
trafficStats: system.trafficStats,
sortClients: system.sortClients,
clientExpiration: system.clientExpiration,
oneTimeLinks: system.oneTimeLinks,
};
});

6
src/server/api/release.get.ts

@ -1,11 +1,5 @@
export default defineEventHandler(async () => { export default defineEventHandler(async () => {
const system = await Database.getSystem(); const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
const latestRelease = await fetchLatestRelease(); const latestRelease = await fetchLatestRelease();
return { return {
currentRelease: system.release, currentRelease: system.release,

5
src/server/api/remember-me.get.ts

@ -1,5 +0,0 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
// TODO: enable by default
return MAX_AGE > 0;
});

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

@ -23,17 +23,13 @@ export default defineEventHandler(async (event) => {
} }
const system = await Database.getSystem(); const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
const conf: SessionConfig = system.sessionConfig; const conf: SessionConfig = system.sessionConfig;
if (MAX_AGE && remember) {
if (remember) {
conf.cookie = { conf.cookie = {
...(system.sessionConfig.cookie ?? {}), ...(system.sessionConfig.cookie ?? {}),
maxAge: MAX_AGE, maxAge: system.cookieMaxAge * 60,
}; };
} }

11
src/server/api/ui-chart-type.get.ts

@ -1,11 +0,0 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
return system.trafficStats.type;
});

11
src/server/api/ui-sort-clients.get.ts

@ -1,11 +0,0 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
return system.sortClients.enabled;
});

11
src/server/api/ui-traffic-stats.get.ts

@ -1,11 +0,0 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
return system.trafficStats.enabled;
});

11
src/server/api/wg-enable-expire-time.get.ts

@ -1,11 +0,0 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
return system.clientExpiration.enabled;
});

11
src/server/api/wg-enable-one-time-links.get.ts

@ -1,11 +0,0 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
return system.oneTimeLinks.enabled;
});

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

@ -1,5 +1,6 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') { const system = await Database.getSystem();
if (!system.oneTimeLinks.enabled) {
throw createError({ throw createError({
status: 404, status: 404,
message: 'Invalid state', message: 'Invalid state',

4
src/server/middleware/session.ts

@ -2,12 +2,12 @@ export default defineEventHandler(async (event) => {
const url = getRequestURL(event); const url = getRequestURL(event);
if ( if (
!url.pathname.startsWith('/api/') || !url.pathname.startsWith('/api/') ||
// TODO: only allowed on onboarding!
url.pathname === '/api/account/new' || url.pathname === '/api/account/new' ||
url.pathname === '/api/session' || url.pathname === '/api/session' ||
url.pathname === '/api/lang' || url.pathname === '/api/lang' ||
url.pathname === '/api/release' || url.pathname === '/api/release' ||
url.pathname === '/api/ui-chart-type' || url.pathname === '/api/features'
url.pathname === '/api/ui-traffic-stats'
) { ) {
return; return;
} }

3
src/server/middleware/setup.ts

@ -4,7 +4,8 @@ export default defineEventHandler(async (event) => {
if ( if (
url.pathname.startsWith('/setup') || url.pathname.startsWith('/setup') ||
url.pathname === '/api/account/new' url.pathname === '/api/account/new' ||
url.pathname === '/api/features'
) { ) {
return; return;
} }

1
src/server/utils/Database.ts

@ -13,5 +13,4 @@ provider.connect().catch((err) => {
}); });
// TODO: check if old config exists and tell user about migration path // TODO: check if old config exists and tell user about migration path
export default provider; export default provider;

364
src/server/utils/WireGuard.ts

@ -1,127 +1,39 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'path'; import path from 'path';
import debug_logger from 'debug'; import debug from 'debug';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import CRC32 from 'crc-32'; import CRC32 from 'crc-32';
const debug = debug_logger('WireGuard'); import type { NewClient } from '~~/services/database/repositories/client';
type Server = { const DEBUG = debug('WireGuard');
privateKey: string;
publicKey: string;
address: string;
};
type Client = {
id: string;
name: string;
address: string;
privateKey: string;
publicKey: string;
preSharedKey: string;
createdAt: string;
updatedAt: string;
expireAt: string | null;
endpoint: string | null;
enabled: boolean;
allowedIPs?: never;
oneTimeLink: string | null;
oneTimeLinkExpiresAt: string | null;
};
type Config = {
server: Server;
clients: Record<string, Client>;
};
class WireGuard { class WireGuard {
#configCache: Config | null = null;
async __buildConfig() {
if (!WG_HOST) {
throw new Error('WG_HOST Environment Variable Not Set!');
}
debug('Loading configuration...');
this.#configCache = null;
try {
const config = await fs.readFile(path.join(WG_PATH, 'wg0.json'), 'utf8');
const parsedConfig = JSON.parse(config);
debug('Configuration loaded.');
return parsedConfig as Config;
} catch {
const privateKey = await exec('wg genkey');
const publicKey = await exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey',
});
const address = WG_DEFAULT_ADDRESS.replace('x', '1');
const config: Config = {
server: {
privateKey,
publicKey,
address,
},
clients: {},
};
debug('Configuration generated.');
return config;
}
}
async getConfig(): Promise<Config> {
if (this.#configCache !== null) {
return this.#configCache;
}
const config = await this.__buildConfig();
await this.__saveConfig(config);
await exec('wg-quick down wg0').catch(() => {});
await exec('wg-quick up wg0').catch((err) => {
if (
err &&
err.message &&
err.message.includes('Cannot find device "wg0"')
) {
throw new Error(
'WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!'
);
}
throw err;
});
// await Util.exec(`iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ' + WG_DEVICE + ' -j MASQUERADE`);
// await Util.exec('iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT');
// await Util.exec('iptables -A FORWARD -i wg0 -j ACCEPT');
// await Util.exec('iptables -A FORWARD -o wg0 -j ACCEPT');
await this.__syncConfig();
this.#configCache = config;
return this.#configCache;
}
async saveConfig() { async saveConfig() {
const config = await this.getConfig(); await this.#saveWireguardConfig();
await this.__saveConfig(config); await this.#syncWireguardConfig();
await this.__syncConfig();
} }
async __saveConfig(config: Config) { async #saveWireguardConfig() {
const system = await Database.getSystem();
const clients = await Database.getClients();
let result = ` let result = `
# Note: Do not edit this file directly. # Note: Do not edit this file directly.
# Your changes will be overwritten! # Your changes will be overwritten!
# Server # Server
[Interface] [Interface]
PrivateKey = ${config.server.privateKey} PrivateKey = ${system.interface.privateKey}
Address = ${config.server.address}/24 Address = ${system.interface.address}/24
ListenPort = ${WG_PORT} ListenPort = ${system.wgPort}
PreUp = ${WG_PRE_UP} PreUp = ${system.iptables.PreUp}
PostUp = ${WG_POST_UP} PostUp = ${system.iptables.PostUp}
PreDown = ${WG_PRE_DOWN} PreDown = ${system.iptables.PreDown}
PostDown = ${WG_POST_DOWN} PostDown = ${system.iptables.PostDown}
`; `;
for (const [clientId, client] of Object.entries(config.clients)) { for (const [clientId, client] of Object.entries(clients)) {
if (!client.enabled) continue; if (!client.enabled) continue;
result += ` result += `
@ -134,30 +46,22 @@ ${
}AllowedIPs = ${client.address}/32`; }AllowedIPs = ${client.address}/32`;
} }
debug('Config saving...'); DEBUG('Config saving...');
await fs.writeFile( await fs.writeFile(path.join('/etc/wireguard', 'wg0.conf'), result, {
path.join(WG_PATH, 'wg0.json'),
JSON.stringify(config, undefined, 2),
{
mode: 0o660,
}
);
await fs.writeFile(path.join(WG_PATH, 'wg0.conf'), result, {
mode: 0o600, mode: 0o600,
}); });
debug('Config saved.'); DEBUG('Config saved.');
} }
async __syncConfig() { async #syncWireguardConfig() {
debug('Config syncing...'); DEBUG('Config syncing...');
await exec('wg syncconf wg0 <(wg-quick strip wg0)'); await exec('wg syncconf wg0 <(wg-quick strip wg0)');
debug('Config synced.'); DEBUG('Config synced.');
} }
async getClients() { async getClients() {
const config = await this.getConfig(); const dbClients = await Database.getClients();
const clients = Object.entries(config.clients).map( const clients = Object.entries(dbClients).map(([clientId, client]) => ({
([clientId, client]) => ({
id: clientId, id: clientId,
name: client.name, name: client.name,
enabled: client.enabled, enabled: client.enabled,
@ -165,18 +69,16 @@ ${
publicKey: client.publicKey, publicKey: client.publicKey,
createdAt: new Date(client.createdAt), createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt), updatedAt: new Date(client.updatedAt),
expireAt: client.expireAt !== null ? new Date(client.expireAt) : null, expiresAt: client.expiresAt,
allowedIPs: client.allowedIPs, allowedIPs: client.allowedIPs,
oneTimeLink: client.oneTimeLink ?? null, oneTimeLink: client.oneTimeLink,
oneTimeLinkExpiresAt: client.oneTimeLinkExpiresAt ?? null,
downloadableConfig: 'privateKey' in client, downloadableConfig: 'privateKey' in client,
persistentKeepalive: null as string | null, persistentKeepalive: null as string | null,
latestHandshakeAt: null as Date | null, latestHandshakeAt: null as Date | null,
endpoint: null as string | null, endpoint: null as string | null,
transferRx: null as number | null, transferRx: null as number | null,
transferTx: null as number | null, transferTx: null as number | null,
}) }));
);
// Loop WireGuard status // Loop WireGuard status
const dump = await exec('wg show wg0 dump', { const dump = await exec('wg show wg0 dump', {
@ -215,8 +117,7 @@ ${
} }
async getClient({ clientId }: { clientId: string }) { async getClient({ clientId }: { clientId: string }) {
const config = await this.getConfig(); const client = await Database.getClient(clientId);
const client = config.clients[clientId];
if (!client) { if (!client) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
@ -228,23 +129,22 @@ ${
} }
async getClientConfiguration({ clientId }: { clientId: string }) { async getClientConfiguration({ clientId }: { clientId: string }) {
const config = await this.getConfig(); const system = await Database.getSystem();
const client = await this.getClient({ clientId }); const client = await this.getClient({ clientId });
return ` return `
[Interface] [Interface]
PrivateKey = ${client.privateKey ? `${client.privateKey}` : 'REPLACE_ME'} PrivateKey = ${client.privateKey ? `${client.privateKey}` : 'REPLACE_ME'}
Address = ${client.address}/24 Address = ${client.address}/24
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\ DNS = ${system.userConfig.defaultDns.join(',')}
${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\ MTU = ${system.userConfig.mtu}
[Peer] [Peer]
PublicKey = ${config.server.publicKey} PublicKey = ${system.interface.publicKey}
${ PresharedKey = ${client.preSharedKey}
client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : '' AllowedIPs = ${client.allowedIPs}
}AllowedIPs = ${WG_ALLOWED_IPS} PersistentKeepalive = ${client.persistentKeepalive}
PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE} Endpoint = ${system.wgHost}:${system.wgConfigPort}`;
Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
} }
async getClientQRCodeSVG({ clientId }: { clientId: string }) { async getClientQRCodeSVG({ clientId }: { clientId: string }) {
@ -266,7 +166,8 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
throw new Error('Missing: Name'); throw new Error('Missing: Name');
} }
const config = await this.getConfig(); const system = await Database.getSystem();
const clients = await Database.getClients();
const privateKey = await exec('wg genkey'); const privateKey = await exec('wg genkey');
const publicKey = await exec(`echo ${privateKey} | wg pubkey`, { const publicKey = await exec(`echo ${privateKey} | wg pubkey`, {
@ -274,15 +175,19 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
}); });
const preSharedKey = await exec('wg genpsk'); const preSharedKey = await exec('wg genpsk');
// TODO: cidr
// Calculate next IP // Calculate next IP
let address; let address;
for (let i = 2; i < 255; i++) { for (let i = 2; i < 255; i++) {
const client = Object.values(config.clients).find((client) => { const client = Object.values(clients).find((client) => {
return client.address === WG_DEFAULT_ADDRESS.replace('x', i.toString()); return (
client.address ===
system.userConfig.addressRange.replace('x', i.toString())
);
}); });
if (!client) { if (!client) {
address = WG_DEFAULT_ADDRESS.replace('x', i.toString()); address = system.userConfig.addressRange.replace('x', i.toString());
break; break;
} }
} }
@ -293,22 +198,20 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
// Create Client // Create Client
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const client: Client = {
const client: NewClient = {
id, id,
name, name,
address, address,
privateKey, privateKey,
publicKey, publicKey,
preSharedKey, preSharedKey,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
endpoint: null, endpoint: null,
oneTimeLink: null, oneTimeLink: null,
oneTimeLinkExpiresAt: null, expiresAt: null,
expireAt: null,
enabled: true, enabled: true,
allowedIPs: system.userConfig.allowedIps,
persistentKeepalive: system.userConfig.persistentKeepalive,
}; };
if (expireDate) { if (expireDate) {
@ -316,10 +219,10 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
date.setHours(23); date.setHours(23);
date.setMinutes(59); date.setMinutes(59);
date.setSeconds(59); date.setSeconds(59);
client.expireAt = date.toISOString(); client.expiresAt = date;
} }
config.clients[id] = client; await Database.createClient(client);
await this.saveConfig(); await this.saveConfig();
@ -327,48 +230,34 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
} }
async deleteClient({ clientId }: { clientId: string }) { async deleteClient({ clientId }: { clientId: string }) {
const config = await this.getConfig(); await Database.deleteClient(clientId);
if (config.clients[clientId]) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete config.clients[clientId];
await this.saveConfig(); await this.saveConfig();
} }
}
async enableClient({ clientId }: { clientId: string }) { async enableClient({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId }); await Database.toggleClient(clientId, true);
client.enabled = true;
client.updatedAt = new Date().toISOString();
await this.saveConfig(); await this.saveConfig();
} }
async generateOneTimeLink({ clientId }: { clientId: string }) { async generateOneTimeLink({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId });
const key = `${clientId}-${Math.floor(Math.random() * 1000)}`; const key = `${clientId}-${Math.floor(Math.random() * 1000)}`;
client.oneTimeLink = Math.abs(CRC32.str(key)).toString(16); const oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
client.oneTimeLinkExpiresAt = new Date( const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
Date.now() + 5 * 60 * 1000 await Database.createOneTimeLink(clientId, {
).toISOString(); oneTimeLink,
client.updatedAt = new Date().toISOString(); expiresAt,
});
await this.saveConfig(); await this.saveConfig();
} }
async eraseOneTimeLink({ clientId }: { clientId: string }) { async eraseOneTimeLink({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId }); await Database.deleteOneTimeLink(clientId);
client.oneTimeLink = null;
client.oneTimeLinkExpiresAt = null;
client.updatedAt = new Date().toISOString();
await this.saveConfig(); await this.saveConfig();
} }
async disableClient({ clientId }: { clientId: string }) { async disableClient({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId }); await Database.toggleClient(clientId, false);
client.enabled = false;
client.updatedAt = new Date().toISOString();
await this.saveConfig(); await this.saveConfig();
} }
@ -380,10 +269,7 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
clientId: string; clientId: string;
name: string; name: string;
}) { }) {
const client = await this.getClient({ clientId }); await Database.updateClientName(clientId, name);
client.name = name;
client.updatedAt = new Date().toISOString();
await this.saveConfig(); await this.saveConfig();
} }
@ -395,8 +281,6 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
clientId: string; clientId: string;
address: string; address: string;
}) { }) {
const client = await this.getClient({ clientId });
if (!isValidIPv4(address)) { if (!isValidIPv4(address)) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
@ -404,8 +288,7 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
}); });
} }
client.address = address; await Database.updateClientAddress(clientId, address);
client.updatedAt = new Date().toISOString();
await this.saveConfig(); await this.saveConfig();
} }
@ -417,42 +300,81 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
clientId: string; clientId: string;
expireDate: string | null; expireDate: string | null;
}) { }) {
const client = await this.getClient({ clientId }); let updatedDate: Date | null = null;
if (expireDate) { if (expireDate) {
const date = new Date(expireDate); const date = new Date(expireDate);
date.setHours(23); date.setHours(23);
date.setMinutes(59); date.setMinutes(59);
date.setSeconds(59); date.setSeconds(59);
client.expireAt = date.toISOString(); updatedDate = date;
} else {
client.expireAt = null;
} }
client.updatedAt = new Date().toISOString();
await this.saveConfig(); await Database.updateClientExpirationDate(clientId, updatedDate);
}
async __reloadConfig() { await this.saveConfig();
await this.__buildConfig();
await this.__syncConfig();
} }
async restoreConfiguration(config: string) { // TODO: reimplement database restore
debug('Starting configuration restore process.'); async restoreConfiguration(_config: string) {
/* DEBUG('Starting configuration restore process.');
// TODO: sanitize config // TODO: sanitize config
const _config = JSON.parse(config); const _config = JSON.parse(config);
await this.__saveConfig(_config); await this.__saveConfig(_config);
await this.__reloadConfig(); await this.__reloadConfig();
debug('Configuration restore process completed.'); DEBUG('Configuration restore process completed.'); */
} }
// TODO: reimplement database restore
async backupConfiguration() { async backupConfiguration() {
debug('Starting configuration backup.'); /* DEBUG('Starting configuration backup.');
const config = await this.getConfig(); const config = await this.getConfig();
const backup = JSON.stringify(config, null, 2); const backup = JSON.stringify(config, null, 2);
debug('Configuration backup completed.'); DEBUG('Configuration backup completed.');
return backup; return backup; */
}
async Startup() {
// TODO: improve this
await new Promise((res) => {
function wait() {
if (Database.connected) {
return res(true);
}
}
setTimeout(wait, 1000);
});
DEBUG('Starting Wireguard');
await this.#saveWireguardConfig();
await exec('wg-quick down wg0').catch(() => {});
await exec('wg-quick up wg0').catch((err) => {
if (
err &&
err.message &&
err.message.includes('Cannot find device "wg0"')
) {
throw new Error(
'WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!'
);
}
throw err;
});
await this.#syncWireguardConfig();
DEBUG('Wireguard started successfully');
DEBUG('Starting Cron Job');
await this.startCronJob();
}
async startCronJob() {
await this.cronJob().catch((err) => {
DEBUG('Running Cron Job failed.');
console.error(err);
});
setTimeout(() => {
this.startCronJob();
}, 60 * 1000);
} }
// Shutdown wireguard // Shutdown wireguard
@ -460,46 +382,30 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
await exec('wg-quick down wg0').catch(() => {}); await exec('wg-quick down wg0').catch(() => {});
} }
async cronJobEveryMinute() { async cronJob() {
const config = await this.getConfig(); const clients = await Database.getClients();
const system = await Database.getSystem(); const system = await Database.getSystem();
if (!system) {
throw new Error('Invalid Database');
}
let needSaveConfig = false;
// Expires Feature // Expires Feature
if (system.clientExpiration.enabled) { if (system.clientExpiration.enabled) {
for (const client of Object.values(config.clients)) { for (const client of Object.values(clients)) {
if (client.enabled !== true) continue; if (client.enabled !== true) continue;
if ( if (client.expiresAt !== null && new Date() > client.expiresAt) {
client.expireAt !== null && DEBUG(`Client ${client.id} expired.`);
new Date() > new Date(client.expireAt) await Database.toggleClient(client.id, false);
) {
debug(`Client ${client.id} expired.`);
needSaveConfig = true;
client.enabled = false;
client.updatedAt = new Date().toISOString();
} }
} }
} }
// One Time Link Feature // One Time Link Feature
if (system.oneTimeLinks.enabled) { if (system.oneTimeLinks.enabled) {
for (const client of Object.values(config.clients)) { for (const client of Object.values(clients)) {
if ( if (
client.oneTimeLink !== null && client.oneTimeLink !== null &&
client.oneTimeLinkExpiresAt !== null && new Date() > client.oneTimeLink.expiresAt
new Date() > new Date(client.oneTimeLinkExpiresAt)
) { ) {
debug(`Client ${client.id} One Time Link expired.`); DEBUG(`Client ${client.id} One Time Link expired.`);
needSaveConfig = true; await Database.deleteOneTimeLink(client.id);
client.oneTimeLink = null;
client.oneTimeLinkExpiresAt = null;
client.updatedAt = new Date().toISOString();
}
} }
} }
if (needSaveConfig) {
await this.saveConfig();
} }
} }
@ -578,15 +484,9 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
} }
const inst = new WireGuard(); const inst = new WireGuard();
inst.Startup().catch((v) => {
// This also has to also start the WireGuard Server console.error(v);
async function cronJobEveryMinute() { process.exit(1);
await inst.cronJobEveryMinute().catch((err) => {
debug('Running Cron Job failed.');
console.error(err);
}); });
setTimeout(cronJobEveryMinute, 60 * 1000);
}
cronJobEveryMinute();
export default inst; export default inst;

69
src/server/utils/config.ts

@ -1,69 +0,0 @@
import type { SessionConfig } from 'h3';
import debug from 'debug';
export const MAX_AGE = process.env.MAX_AGE
? parseInt(process.env.MAX_AGE, 10) * 60
: 0;
export const WG_PATH = process.env.WG_PATH || '/etc/wireguard/';
export const WG_DEVICE = process.env.WG_DEVICE || 'eth0';
export const WG_HOST = process.env.WG_HOST;
export const WG_PORT = process.env.WG_PORT || '51820';
export const WG_CONFIG_PORT =
process.env.WG_CONFIG_PORT || process.env.WG_PORT || '51820';
export const WG_MTU = process.env.WG_MTU || null;
export const WG_PERSISTENT_KEEPALIVE =
process.env.WG_PERSISTENT_KEEPALIVE || '0';
export const WG_DEFAULT_ADDRESS = process.env.WG_DEFAULT_ADDRESS || '10.8.0.x';
export const WG_DEFAULT_DNS =
typeof process.env.WG_DEFAULT_DNS === 'string'
? process.env.WG_DEFAULT_DNS
: '1.1.1.1';
export const WG_ALLOWED_IPS = process.env.WG_ALLOWED_IPS || '0.0.0.0/0, ::/0';
export const WG_PRE_UP = process.env.WG_PRE_UP || '';
export const WG_POST_UP =
process.env.WG_POST_UP ||
`
iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${WG_DEVICE} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport ${WG_PORT} -j ACCEPT;
iptables -A FORWARD -i wg0 -j ACCEPT;
iptables -A FORWARD -o wg0 -j ACCEPT;
`
.split('\n')
.join(' ');
export const WG_PRE_DOWN = process.env.WG_PRE_DOWN || '';
export const WG_POST_DOWN =
process.env.WG_POST_DOWN ||
`
iptables -t nat -D POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${WG_DEVICE} -j MASQUERADE;
iptables -D INPUT -p udp -m udp --dport ${WG_PORT} -j ACCEPT;
iptables -D FORWARD -i wg0 -j ACCEPT;
iptables -D FORWARD -o wg0 -j ACCEPT;
`
.split('\n')
.join(' ');
export const LANG = process.env.LANG || 'en';
export const UI_TRAFFIC_STATS = process.env.UI_TRAFFIC_STATS || 'false';
export const UI_CHART_TYPE = process.env.UI_CHART_TYPE || '0';
export const WG_ENABLE_ONE_TIME_LINKS =
process.env.WG_ENABLE_ONE_TIME_LINKS || 'false';
export const UI_ENABLE_SORT_CLIENTS =
process.env.UI_ENABLE_SORT_CLIENTS || 'false';
export const WG_ENABLE_EXPIRES_TIME =
process.env.WG_ENABLE_EXPIRES_TIME || 'false';
export const ENABLE_PROMETHEUS_METRICS =
process.env.ENABLE_PROMETHEUS_METRICS || 'false';
export const PROMETHEUS_METRICS_PASSWORD =
process.env.PROMETHEUS_METRICS_PASSWORD;
export const REQUIRES_PROMETHEUS_PASSWORD = !!PROMETHEUS_METRICS_PASSWORD;
export const SESSION_CONFIG = {
password: getRandomHex(256),
name: 'wg-easy',
cookie: undefined,
} satisfies SessionConfig;
export const SERVER_DEBUG = debug('Server');

3
src/server/utils/logger.ts

@ -0,0 +1,3 @@
import debug from 'debug';
export const SERVER_DEBUG = debug('Server');

12
src/server/utils/types.ts

@ -42,6 +42,11 @@ const expireDate = z
.pipe(safeStringRefine) .pipe(safeStringRefine)
.nullable(); .nullable();
const oneTimeLink = z
.string({ message: 'oneTimeLink must be a valid string' })
.min(1, 'oneTimeLink must be at least 1 Character')
.pipe(safeStringRefine);
export const clientIdType = z.object( export const clientIdType = z.object(
{ {
clientId: id, clientId: id,
@ -70,6 +75,13 @@ export const expireDateType = z.object(
{ message: 'Body must be a valid object' } { message: 'Body must be a valid object' }
); );
export const oneTimeLinkType = z.object(
{
oneTimeLink: oneTimeLink,
},
{ message: 'Body must be a valid object' }
);
export const createType = z.object( export const createType = z.object(
{ {
name: name, name: name,

106
src/services/database/lowdb.ts

@ -13,16 +13,17 @@ import type { Low } from 'lowdb';
import type { User } from './repositories/user'; import type { User } from './repositories/user';
import type { Database } from './repositories/database'; import type { Database } from './repositories/database';
import { migrationRunner } from './migrations'; import { migrationRunner } from './migrations';
import type { Client, NewClient, OneTimeLink } from './repositories/client';
const DEBUG = debug('LowDB'); const DEBUG = debug('LowDB');
export default class LowDB extends DatabaseProvider { export default class LowDB extends DatabaseProvider {
#db!: Low<Database>; #db!: Low<Database>;
#connected = false;
// is this really needed? // is this really needed?
private async __init() { private async __init() {
// TODO: assume path to db file const dbFilePath = join('/etc/wireguard', 'db.json');
const dbFilePath = join(WG_PATH, 'db.json');
this.#db = await JSONFilePreset(dbFilePath, DEFAULT_DATABASE); this.#db = await JSONFilePreset(dbFilePath, DEFAULT_DATABASE);
} }
@ -30,6 +31,9 @@ export default class LowDB extends DatabaseProvider {
* @throws * @throws
*/ */
async connect() { async connect() {
if (this.#connected) {
return;
}
try { try {
await this.__init(); await this.__init();
DEBUG('Running Migrations'); DEBUG('Running Migrations');
@ -39,11 +43,16 @@ export default class LowDB extends DatabaseProvider {
DEBUG(e); DEBUG(e);
throw new DatabaseError(DatabaseError.ERROR_INIT); throw new DatabaseError(DatabaseError.ERROR_INIT);
} }
this.#connected = true;
DEBUG('Connected successfully'); DEBUG('Connected successfully');
} }
get connected() {
return this.#connected;
}
async disconnect() { async disconnect() {
this.#connected = false;
DEBUG('Disconnected successfully'); DEBUG('Disconnected successfully');
} }
@ -57,6 +66,7 @@ export default class LowDB extends DatabaseProvider {
return system; return system;
} }
// TODO: return copy to avoid mutation (everywhere)
async getUsers() { async getUsers() {
return this.#db.data.users; return this.#db.data.users;
} }
@ -78,6 +88,7 @@ export default class LowDB extends DatabaseProvider {
throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ); throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ);
} }
// TODO: multiple names are no problem
const isUserExist = this.#db.data.users.find( const isUserExist = this.#db.data.users.find(
(user) => user.username === username (user) => user.username === username
); );
@ -98,15 +109,16 @@ export default class LowDB extends DatabaseProvider {
updatedAt: now, updatedAt: now,
}; };
this.#db.update((data) => data.users.push(newUser)); await this.#db.update((data) => data.users.push(newUser));
} }
async updateUser(user: User) { async updateUser(user: User) {
// TODO: avoid mutation, prefer .update, updatedAt
let oldUser = await this.getUser(user.id); let oldUser = await this.getUser(user.id);
if (oldUser) { if (oldUser) {
DEBUG('Update User'); DEBUG('Update User');
oldUser = user; oldUser = user;
this.#db.write(); await this.#db.write();
} }
} }
@ -114,7 +126,89 @@ export default class LowDB extends DatabaseProvider {
DEBUG('Delete User'); DEBUG('Delete User');
const idx = this.#db.data.users.findIndex((user) => user.id === id); const idx = this.#db.data.users.findIndex((user) => user.id === id);
if (idx !== -1) { if (idx !== -1) {
this.#db.update((data) => data.users.splice(idx, 1)); await this.#db.update((data) => data.users.splice(idx, 1));
}
}
async getClients() {
DEBUG('GET Clients');
return this.#db.data.clients;
}
async getClient(id: string) {
DEBUG('Get Client');
return this.#db.data.clients[id];
}
async createClient(client: NewClient) {
DEBUG('Create Client');
const now = new Date();
const newClient: Client = { ...client, createdAt: now, updatedAt: now };
await this.#db.update((data) => {
data.clients[client.id] = newClient;
});
}
async deleteClient(id: string) {
DEBUG('Delete Client');
await this.#db.update((data) => {
// TODO: find something better than delete
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete data.clients[id];
});
}
async toggleClient(id: string, enable: boolean) {
DEBUG('Toggle Client');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].enabled = enable;
}
});
}
async updateClientName(id: string, name: string) {
DEBUG('Update Client Name');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].name = name;
}
});
}
async updateClientAddress(id: string, address: string) {
DEBUG('Update Client Address');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].address = address;
}
});
}
async updateClientExpirationDate(id: string, expirationDate: Date | null) {
DEBUG('Update Client Address');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].expiresAt = expirationDate;
}
});
}
async deleteOneTimeLink(id: string) {
DEBUG('Update Client Address');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].oneTimeLink = null;
}
});
}
async createOneTimeLink(id: string, oneTimeLink: OneTimeLink) {
DEBUG('Update Client Address');
await this.#db.update((data) => {
if (data.clients[id]) {
data.clients[id].oneTimeLink = oneTimeLink;
} }
});
} }
} }

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

@ -3,61 +3,43 @@ import type { Database } from '../repositories/database';
import packageJson from '@@/package.json'; import packageJson from '@@/package.json';
import { ChartType } from '../repositories/system'; import { ChartType } from '../repositories/system';
// TODO: use variables inside up/down script
const DEFAULT_ADDRESS = '10.8.0.x';
const DEFAULT_DEVICE = 'eth0';
const DEFAULT_WG_PORT = 51820;
const DEFAULT_POST_UP = `
iptables -t nat -A POSTROUTING -s ${DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${DEFAULT_DEVICE} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport ${DEFAULT_WG_PORT} -j ACCEPT;
iptables -A FORWARD -i wg0 -j ACCEPT;
iptables -A FORWARD -o wg0 -j ACCEPT;
`
.split('\n')
.join(' ');
const DEFAULT_POST_DOWN = `
iptables -t nat -D POSTROUTING -s ${DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${DEFAULT_DEVICE} -j MASQUERADE;
iptables -D INPUT -p udp -m udp --dport ${DEFAULT_WG_PORT} -j ACCEPT;
iptables -D FORWARD -i wg0 -j ACCEPT;
iptables -D FORWARD -o wg0 -j ACCEPT;
`
.split('\n')
.join(' ');
export async function run1(db: Low<Database>) { export async function run1(db: Low<Database>) {
const privateKey = await exec('wg genkey'); const privateKey = await exec('wg genkey');
const publicKey = await exec(`echo ${privateKey} | wg pubkey`, { const publicKey = await exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey', log: 'echo ***hidden*** | wg pubkey',
}); });
const addressRange = '10.8.0.x';
const database: Database = { const database: Database = {
migrations: [], migrations: [],
system: { system: {
// TODO: move to var, no need for database
release: packageJson.release.version, release: packageJson.release.version,
interface: { interface: {
privateKey: privateKey, privateKey: privateKey,
publicKey: publicKey, publicKey: publicKey,
address: DEFAULT_ADDRESS.replace('x', '1'), address: addressRange.replace('x', '1'),
}, },
sessionTimeout: 3600, // 1 hour sessionTimeout: 3600, // 1 hour
lang: 'en', lang: 'en',
userConfig: { userConfig: {
mtu: 1420, mtu: 1420,
persistentKeepalive: 0, persistentKeepalive: 0,
// TODO: assume handle CIDR to compute next ip in WireGuard // TODO: handle CIDR to compute next ip in WireGuard
rangeAddress: '10.8.0.0/24', //addressRange: '10.8.0.0/24',
addressRange: addressRange,
defaultDns: ['1.1.1.1'], defaultDns: ['1.1.1.1'],
allowedIps: ['0.0.0.0/0', '::/0'], allowedIps: ['0.0.0.0/0', '::/0'],
}, },
wgPath: WG_PATH, wgDevice: 'wg0',
wgDevice: DEFAULT_DEVICE, // TODO: wgHost has to be configured when onboarding
wgHost: WG_HOST || '', wgHost: '',
wgPort: DEFAULT_WG_PORT, wgPort: 51820,
wgConfigPort: 51820, wgConfigPort: 51820,
iptables: { iptables: {
PreUp: '', PreUp: '',
PostUp: DEFAULT_POST_UP, PostUp: '',
PreDown: '', PreDown: '',
PostDown: DEFAULT_POST_DOWN, PostDown: '',
}, },
trafficStats: { trafficStats: {
enabled: false, enabled: false,
@ -79,12 +61,32 @@ export async function run1(db: Low<Database>) {
sessionConfig: { sessionConfig: {
password: getRandomHex(256), password: getRandomHex(256),
name: 'wg-easy', name: 'wg-easy',
cookie: undefined, cookie: {},
}, },
cookieMaxAge: 24 * 60,
}, },
users: [], users: [],
clients: {},
}; };
// TODO: use variables inside up/down script
database.system.iptables.PostUp = `
iptables -t nat -A POSTROUTING -s ${database.system.userConfig.addressRange.replace('x', '0')}/24 -o ${database.system.wgDevice} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport ${database.system.wgPort} -j ACCEPT;
iptables -A FORWARD -i wg0 -j ACCEPT;
iptables -A FORWARD -o wg0 -j ACCEPT;
`
.split('\n')
.join(' ');
database.system.iptables.PostDown = `
iptables -t nat -D POSTROUTING -s ${database.system.userConfig.addressRange.replace('x', '0')}/24 -o ${database.system.wgDevice} -j MASQUERADE;
iptables -D INPUT -p udp -m udp --dport ${database.system.wgPort} -j ACCEPT;
iptables -D FORWARD -i wg0 -j ACCEPT;
iptables -D FORWARD -o wg0 -j ACCEPT;
`
.split('\n')
.join(' ');
db.data = database; db.data = database;
db.write(); db.write();
} }

43
src/services/database/repositories/client.ts

@ -0,0 +1,43 @@
export type OneTimeLink = {
oneTimeLink: string;
expiresAt: Date;
};
export type Client = {
id: string;
name: string;
address: string;
privateKey: string;
publicKey: string;
preSharedKey: string;
expiresAt: Date | null;
endpoint: string | null;
allowedIPs: string[];
oneTimeLink: OneTimeLink | null;
createdAt: Date;
updatedAt: Date;
enabled: boolean;
persistentKeepalive: number;
};
export type NewClient = Omit<Client, 'createdAt' | 'updatedAt'>;
/**
* Interface for client-related database operations.
* This interface provides methods for managing client data.
*/
export interface ClientRepository {
getClients(): Promise<Record<string, Client>>;
getClient(id: string): Promise<Client | undefined>;
createClient(client: NewClient): Promise<void>;
deleteClient(id: string): Promise<void>;
toggleClient(id: string, enable: boolean): Promise<void>;
updateClientName(id: string, name: string): Promise<void>;
updateClientAddress(id: string, address: string): Promise<void>;
updateClientExpirationDate(
id: string,
expirationDate: Date | null
): Promise<void>;
deleteOneTimeLink(id: string): Promise<void>;
createOneTimeLink(id: string, oneTimeLink: OneTimeLink): Promise<void>;
}

27
src/services/database/repositories/database.ts

@ -1,3 +1,9 @@
import type {
ClientRepository,
Client,
NewClient,
OneTimeLink,
} from './client';
import type { System, SystemRepository } from './system'; import type { System, SystemRepository } from './system';
import type { User, UserRepository } from './user'; import type { User, UserRepository } from './user';
@ -6,12 +12,14 @@ export type Database = {
migrations: string[]; migrations: string[];
system: System; system: System;
users: User[]; users: User[];
clients: Record<string, Client>;
}; };
export const DEFAULT_DATABASE: Database = { export const DEFAULT_DATABASE: Database = {
migrations: [], migrations: [],
system: null as never, system: null as never,
users: [], users: [],
clients: {},
}; };
/** /**
@ -22,7 +30,7 @@ export const DEFAULT_DATABASE: Database = {
* *
*/ */
export abstract class DatabaseProvider export abstract class DatabaseProvider
implements SystemRepository, UserRepository implements SystemRepository, UserRepository, ClientRepository
{ {
/** /**
* Connects to the database. * Connects to the database.
@ -44,6 +52,23 @@ export abstract class DatabaseProvider
): Promise<void>; ): Promise<void>;
abstract updateUser(user: User): Promise<void>; abstract updateUser(user: User): Promise<void>;
abstract deleteUser(id: string): Promise<void>; abstract deleteUser(id: string): Promise<void>;
abstract getClients(): Promise<Record<string, Client>>;
abstract getClient(id: string): Promise<Client | undefined>;
abstract createClient(client: NewClient): Promise<void>;
abstract deleteClient(id: string): Promise<void>;
abstract toggleClient(id: string, enable: boolean): Promise<void>;
abstract updateClientName(id: string, name: string): Promise<void>;
abstract updateClientAddress(id: string, address: string): Promise<void>;
abstract updateClientExpirationDate(
id: string,
expirationDate: Date | null
): Promise<void>;
abstract deleteOneTimeLink(id: string): Promise<void>;
abstract createOneTimeLink(
id: string,
oneTimeLink: OneTimeLink
): Promise<void>;
} }
/** /**

4
src/services/database/repositories/system.ts

@ -18,7 +18,7 @@ export type WGInterface = {
export type WGConfig = { export type WGConfig = {
mtu: number; mtu: number;
persistentKeepalive: number; persistentKeepalive: number;
rangeAddress: string; addressRange: string;
defaultDns: string[]; defaultDns: string[];
allowedIps: string[]; allowedIps: string[];
}; };
@ -57,7 +57,6 @@ export type System = {
userConfig: WGConfig; userConfig: WGConfig;
wgPath: string;
wgDevice: string; wgDevice: string;
wgHost: string; wgHost: string;
wgPort: number; wgPort: number;
@ -72,6 +71,7 @@ export type System = {
prometheus: Prometheus; prometheus: Prometheus;
sessionConfig: SessionConfig; sessionConfig: SessionConfig;
cookieMaxAge: number;
}; };
/** /**

Loading…
Cancel
Save