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

add wg_path, update documentation
pull/1648/head
Bernd Storath 7 months ago
committed by Bernd Storath
parent
commit
5b57e691ce
  1. 2
      .gitignore
  2. 1
      Dockerfile
  3. 1
      Dockerfile.dev
  4. 42
      README.md
  5. 2
      docker-compose.dev.yml
  6. 29
      docker-compose.yml
  7. 7
      src/app/app.vue
  8. 20
      src/app/components/Client/Charts.vue
  9. 4
      src/app/components/Client/Client.vue
  10. 8
      src/app/components/Client/ExpireDate.vue
  11. 2
      src/app/components/Client/LastSeen.vue
  12. 4
      src/app/components/Client/OneTimeLink.vue
  13. 2
      src/app/components/Client/OneTimeLinkBtn.vue
  14. 5
      src/app/components/Clients/CreateDialog.vue
  15. 2
      src/app/components/Clients/Sort.vue
  16. 4
      src/app/layouts/Header.vue
  17. 2
      src/app/pages/login.vue
  18. 2
      src/app/stores/auth.ts
  19. 5
      src/app/stores/clients.ts
  20. 70
      src/app/stores/global.ts
  21. 44
      src/app/utils/api.ts
  22. 26
      src/server/api/cnf/[oneTimeLink].ts
  23. 9
      src/server/api/features.get.ts
  24. 6
      src/server/api/release.get.ts
  25. 5
      src/server/api/remember-me.get.ts
  26. 10
      src/server/api/session.post.ts
  27. 11
      src/server/api/ui-chart-type.get.ts
  28. 11
      src/server/api/ui-sort-clients.get.ts
  29. 11
      src/server/api/ui-traffic-stats.get.ts
  30. 11
      src/server/api/wg-enable-expire-time.get.ts
  31. 11
      src/server/api/wg-enable-one-time-links.get.ts
  32. 3
      src/server/api/wireguard/client/[clientId]/generateOneTimeLink.post.ts
  33. 4
      src/server/middleware/session.ts
  34. 3
      src/server/middleware/setup.ts
  35. 1
      src/server/utils/Database.ts
  36. 398
      src/server/utils/WireGuard.ts
  37. 64
      src/server/utils/config.ts
  38. 12
      src/server/utils/types.ts
  39. 110
      src/services/database/lowdb.ts
  40. 64
      src/services/database/migrations/1.ts
  41. 43
      src/services/database/repositories/client.ts
  42. 27
      src/services/database/repositories/database.ts
  43. 4
      src/services/database/repositories/system.ts

2
.gitignore

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

1
Dockerfile

@ -36,6 +36,7 @@ RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables
# Set Environment
ENV DEBUG=Server,WireGuard,LowDB
ENV PORT=51821
ENV HOST=0.0.0.0
# Run Web UI
CMD ["/usr/bin/dumb-init", "node", "server/index.mjs"]

1
Dockerfile.dev

@ -26,3 +26,4 @@ RUN update-alternatives --install /sbin/iptables iptables /sbin/iptables-legacy
# Set Environment
ENV DEBUG=Server,WireGuard,LowDB
ENV PORT=51821
ENV HOST=0.0.0.0

42
README.md

@ -27,7 +27,7 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
- Traffic Stats (default off)
- One Time Links (default off)
- Client Expiration (default off)
- Prometheus metrics support
- Prometheus metrics support (default off)
## Requirements
@ -67,13 +67,10 @@ And log in again.
To automatically install & run wg-easy, simply run:
```
```bash
docker run -d \
--name=wg-easy \
-e LANG=de \
-e WG_HOST=<🚨YOUR_SERVER_IP> \
-e PORT=51821 \
-e WG_PORT=51820 \
-v ~/.wg-easy:/etc/wireguard \
-p 51820:51820/udp \
-p 51821:51821/tcp \
@ -106,34 +103,11 @@ Donation to core component: [WireGuard](https://www.wireguard.com/donations/)
These options can be configured by setting environment variables using `-e KEY="VALUE"` in the `docker run` command.
| Env | Default | Example | Description |
| ----------------------------- | ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | `51821` | `6789` | TCP port for Web UI. |
| `WEBUI_HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. |
| `WG_HOST` | - | `vpn.myserver.com` | The public hostname of your VPN server. |
| `WG_DEVICE` | `eth0` | `ens6f0` | Ethernet device the wireguard traffic should be forwarded through. |
| `WG_PORT` | `51820` | `12345` | The public UDP port of your VPN server. WireGuard will listen on that (othwise default) inside the Docker container. |
| `WG_CONFIG_PORT` | `51820` | `12345` | The UDP port used on [Home Assistant Plugin](https://github.com/adriy-be/homeassistant-addons-jdeath/tree/main/wgeasy) |
| `WG_MTU` | `null` | `1420` | The MTU the clients will use. Server uses default WG MTU. |
| `WG_PERSISTENT_KEEPALIVE` | `0` | `25` | Value in seconds to keep the "connection" open. If this value is 0, then connections won't be kept alive. |
| `WG_DEFAULT_ADDRESS` | `10.8.0.x` | `10.6.0.x` | Clients IP address range. |
| `WG_DEFAULT_DNS` | `1.1.1.1` | `8.8.8.8, 8.8.4.4` | DNS server clients will use. If set to blank value, clients will not use any DNS. |
| `WG_ALLOWED_IPS` | `0.0.0.0/0, ::/0` | `192.168.15.0/24, 10.0.1.0/24` | Allowed IPs clients will use. |
| `WG_PRE_UP` | `...` | - | See [config.js](https://github.com/wg-easy/wg-easy/blob/master/src/config.js#L19) for the default value. |
| `WG_POST_UP` | `...` | `iptables ...` | See [config.js](https://github.com/wg-easy/wg-easy/blob/master/src/config.js#L20) for the default value. |
| `WG_PRE_DOWN` | `...` | - | See [config.js](https://github.com/wg-easy/wg-easy/blob/master/src/config.js#L27) for the default value. |
| `WG_POST_DOWN` | `...` | `iptables ...` | See [config.js](https://github.com/wg-easy/wg-easy/blob/master/src/config.js#L28) for the default value. |
| `WG_ENABLE_EXPIRES_TIME` | `false` | `true` | Enable expire time for clients |
| `LANG` | `en` | `de` | Web UI language (Supports: en, ua, ru, tr, no, pl, fr, de, ca, es, ko, vi, nl, is, pt, chs, cht, it, th, hi). |
| `UI_TRAFFIC_STATS` | `false` | `true` | Enable detailed RX / TX client stats in Web UI |
| `UI_CHART_TYPE` | `0` | `1` | UI_CHART_TYPE=0 # Charts disabled, UI_CHART_TYPE=1 # Line chart, UI_CHART_TYPE=2 # Area chart, UI_CHART_TYPE=3 # Bar chart |
| `WG_ENABLE_ONE_TIME_LINKS` | `false` | `true` | Enable display and generation of short one time download links (expire after 5 minutes) |
| `MAX_AGE` | `0` | `1440` | The maximum age of Web UI sessions in minutes. `0` means that the session will exist until the browser is closed. |
| `UI_ENABLE_SORT_CLIENTS` | `false` | `true` | Enable UI sort clients by name |
| `ENABLE_PROMETHEUS_METRICS` | `false` | `true` | Enable Prometheus metrics `http://0.0.0.0:51821/metrics` and `http://0.0.0.0:51821/metrics/json` |
| `PROMETHEUS_METRICS_PASSWORD` | - | `$2y$05$Ci...` | If set, Basic Auth is required when requesting metrics. See [How to generate an bcrypt hash.md]("https://github.com/wg-easy/wg-easy/blob/master/How_to_generate_an_bcrypt_hash.md") for know how generate the hash. |
> If you change `WG_PORT`, make sure to also change the exposed port.
| Env | Default | Example | Description |
| --------- | ----------------- | ------------- | -------------------------------------------- |
| `PORT` | `51821` | `6789` | TCP port for Web UI. |
| `HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. |
| `WG_PATH` | `/etc/wireguard/` | `/home/user/` | The Path your `wg0.conf` and `db.json` lives |
## Updating
@ -152,7 +126,7 @@ With Docker Compose WireGuard Easy can be updated with a single command:
Compose file and it is not `latest`, make sure that it is changed to the desired
one; by default it is omitted and
[defaults to `latest`](https://docs.docker.com/engine/reference/run/#image-references)). \
The WireGuared Easy container will be automatically recreated if a newer image
The WireGuard Easy container will be automatically recreated if a newer image
was pulled.
## Common Use Cases

2
docker-compose.dev.yml

@ -14,8 +14,6 @@ services:
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- WG_HOST=192.168.1.233
# folders should be generated inside container
volumes:

29
docker-compose.yml

@ -4,33 +4,10 @@ volumes:
services:
wg-easy:
environment:
# Change Language:
# (Supports: en, ua, ru, tr, no, pl, fr, de, ca, es, ko, vi, nl, is, pt, chs, cht, it, th, hi, ja, si)
- LANG=en
# ⚠️ Required:
# Change this to your host's public address
- WG_HOST=raspberrypi.local
- PORT=51821
# Optional:
# - PORT=51821
# - WG_PORT=51820
# - WG_CONFIG_PORT=92820
# - WG_DEFAULT_ADDRESS=10.8.0.x
# - WG_DEFAULT_DNS=1.1.1.1
# - WG_MTU=1420
# - WG_ALLOWED_IPS=192.168.15.0/24, 10.0.1.0/24
# - WG_PERSISTENT_KEEPALIVE=25
# - WG_PRE_UP=echo "Pre Up" > /etc/wireguard/pre-up.txt
# - WG_POST_UP=echo "Post Up" > /etc/wireguard/post-up.txt
# - WG_PRE_DOWN=echo "Pre Down" > /etc/wireguard/pre-down.txt
# - WG_POST_DOWN=echo "Post Down" > /etc/wireguard/post-down.txt
# - UI_TRAFFIC_STATS=true
# - UI_CHART_TYPE=0 # (0 Charts disabled, 1 # Line chart, 2 # Area chart, 3 # Bar chart)
# - WG_ENABLE_ONE_TIME_LINKS=true
# - UI_ENABLE_SORT_CLIENTS=true
# - WG_ENABLE_EXPIRES_TIME=true
# - ENABLE_PROMETHEUS_METRICS=false
# - PROMETHEUS_METRICS_PASSWORD=$$2a$$12$$vkvKpeEAHD78gasyawIod.1leBMKg8sBwKW.pQyNsq78bXV3INf2G # (needs double $$, hash of 'prometheus_password'; see "How_to_generate_an_bcrypt_hash.md" for generate the hash)
# - HOST=0.0.0.0
# - WG_PATH=/etc/wireguard/
image: ghcr.io/wg-easy/wg-easy
container_name: wg-easy

7
src/app/app.vue

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

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

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

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

@ -14,7 +14,7 @@
>
<ClientAddress :client="client" />
<ClientInlineTransfer
v-if="!globalStore.uiTrafficStats"
v-if="!globalStore.features.trafficStats.enabled"
:client="client"
/>
<ClientLastSeen :client="client" />
@ -25,7 +25,7 @@
<!-- Info -->
<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"
>
<ClientTransfer :client="client" />

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

@ -1,6 +1,6 @@
<template>
<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"
>
<span class="group">
@ -24,7 +24,7 @@
<span
v-show="clientEditExpireDateId !== client.id"
class="inline-block"
>{{ expiredDateFormat(client.expireAt) }}</span
>{{ expiredDateFormat(client.expiresAt) }}</span
>
<!-- Edit -->
@ -32,8 +32,8 @@
v-show="clientEditExpireDateId !== client.id"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
@click="
clientEditExpireDate = client.expireAt
? client.expireAt.slice(0, 10)
clientEditExpireDate = client.expiresAt
? client.expiresAt.slice(0, 10)
: 'yyyy-mm-dd';
clientEditExpireDateId = client.id;
nextTick(() => clientExpireDateInput?.select());

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

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

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

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

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

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

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

@ -71,7 +71,10 @@
/>
</p>
</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">
<label
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>
<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"
@click="globalStore.sortClient = !globalStore.sortClient"
>

4
src/app/layouts/Header.vue

@ -36,7 +36,7 @@
</button>
<!-- Show / hide charts -->
<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"
:title="$t('toggleCharts')"
>
@ -96,8 +96,6 @@ const currentRelease = ref<null | number>(null);
const latestRelease = ref<null | { version: number; changelog: string }>(null);
const theme = useTheme();
globalStore.fetchChartType();
const uiShowCharts = ref(getItem('uiShowCharts') === '1');
function toggleTheme() {

2
src/app/pages/login.vue

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

2
src/app/stores/auth.ts

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

70
src/app/stores/global.ts

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

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() {
// TODO?: use useFetch
return $fetch('/api/session', {
@ -158,13 +128,7 @@ class API {
});
}
async getSortClients() {
return useFetch('/api/ui-sort-clients', {
method: 'get',
});
}
async newAccount({
async createAccount({
username,
password,
}: {
@ -176,6 +140,12 @@ class API {
body: { username, password },
});
}
async getFeatures() {
return useFetch('/api/features', {
method: 'get',
});
}
}
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) => {
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
if (!system.oneTimeLinks.enabled) {
throw createError({
status: 404,
message: 'Invalid state',
statusCode: 404,
statusMessage: 'Invalid state',
});
}
// TODO: validate with zod
const clientOneTimeLink = getRouterParam(event, 'clientOneTimeLink');
const { oneTimeLink } = await getValidatedRouterParams(
event,
validateZod(oneTimeLinkType)
);
const clients = await WireGuard.getClients();
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 config = await WireGuard.getClientConfiguration({ clientId });
await WireGuard.eraseOneTimeLink({ clientId });
setHeader(
event,
'Content-Disposition',
`attachment; filename="${clientOneTimeLink}.conf"`
`attachment; filename="${client.name}.conf"`
);
setHeader(event, 'Content-Type', 'text/plain');
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 () => {
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
const latestRelease = await fetchLatestRelease();
return {
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();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
const conf: SessionConfig = system.sessionConfig;
if (MAX_AGE && remember) {
if (remember) {
conf.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) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') {
const system = await Database.getSystem();
if (!system.oneTimeLinks.enabled) {
throw createError({
status: 404,
message: 'Invalid state',

4
src/server/middleware/session.ts

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

3
src/server/middleware/setup.ts

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

398
src/server/utils/WireGuard.ts

@ -1,127 +1,39 @@
import fs from 'node:fs/promises';
import path from 'path';
import debug_logger from 'debug';
import debug from 'debug';
import crypto from 'node:crypto';
import QRCode from 'qrcode';
import CRC32 from 'crc-32';
const debug = debug_logger('WireGuard');
type Server = {
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>;
};
import type { NewClient } from '~~/services/database/repositories/client';
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;
}
const DEBUG = debug('WireGuard');
class WireGuard {
async saveConfig() {
const config = await this.getConfig();
await this.__saveConfig(config);
await this.__syncConfig();
await this.#saveWireguardConfig();
await this.#syncWireguardConfig();
}
async __saveConfig(config: Config) {
async #saveWireguardConfig() {
const system = await Database.getSystem();
const clients = await Database.getClients();
let result = `
# Note: Do not edit this file directly.
# Your changes will be overwritten!
# Server
[Interface]
PrivateKey = ${config.server.privateKey}
Address = ${config.server.address}/24
ListenPort = ${WG_PORT}
PreUp = ${WG_PRE_UP}
PostUp = ${WG_POST_UP}
PreDown = ${WG_PRE_DOWN}
PostDown = ${WG_POST_DOWN}
PrivateKey = ${system.interface.privateKey}
Address = ${system.interface.address}/24
ListenPort = ${system.wgPort}
PreUp = ${system.iptables.PreUp}
PostUp = ${system.iptables.PostUp}
PreDown = ${system.iptables.PreDown}
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;
result += `
@ -134,49 +46,39 @@ ${
}AllowedIPs = ${client.address}/32`;
}
debug('Config saving...');
await fs.writeFile(
path.join(WG_PATH, 'wg0.json'),
JSON.stringify(config, undefined, 2),
{
mode: 0o660,
}
);
DEBUG('Config saving...');
await fs.writeFile(path.join(WG_PATH, 'wg0.conf'), result, {
mode: 0o600,
});
debug('Config saved.');
DEBUG('Config saved.');
}
async __syncConfig() {
debug('Config syncing...');
async #syncWireguardConfig() {
DEBUG('Config syncing...');
await exec('wg syncconf wg0 <(wg-quick strip wg0)');
debug('Config synced.');
DEBUG('Config synced.');
}
async getClients() {
const config = await this.getConfig();
const clients = Object.entries(config.clients).map(
([clientId, client]) => ({
id: clientId,
name: client.name,
enabled: client.enabled,
address: client.address,
publicKey: client.publicKey,
createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt),
expireAt: client.expireAt !== null ? new Date(client.expireAt) : null,
allowedIPs: client.allowedIPs,
oneTimeLink: client.oneTimeLink ?? null,
oneTimeLinkExpiresAt: client.oneTimeLinkExpiresAt ?? null,
downloadableConfig: 'privateKey' in client,
persistentKeepalive: null as string | null,
latestHandshakeAt: null as Date | null,
endpoint: null as string | null,
transferRx: null as number | null,
transferTx: null as number | null,
})
);
const dbClients = await Database.getClients();
const clients = Object.entries(dbClients).map(([clientId, client]) => ({
id: clientId,
name: client.name,
enabled: client.enabled,
address: client.address,
publicKey: client.publicKey,
createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt),
expiresAt: client.expiresAt,
allowedIPs: client.allowedIPs,
oneTimeLink: client.oneTimeLink,
downloadableConfig: 'privateKey' in client,
persistentKeepalive: null as string | null,
latestHandshakeAt: null as Date | null,
endpoint: null as string | null,
transferRx: null as number | null,
transferTx: null as number | null,
}));
// Loop WireGuard status
const dump = await exec('wg show wg0 dump', {
@ -215,8 +117,7 @@ ${
}
async getClient({ clientId }: { clientId: string }) {
const config = await this.getConfig();
const client = config.clients[clientId];
const client = await Database.getClient(clientId);
if (!client) {
throw createError({
statusCode: 404,
@ -228,23 +129,22 @@ ${
}
async getClientConfiguration({ clientId }: { clientId: string }) {
const config = await this.getConfig();
const system = await Database.getSystem();
const client = await this.getClient({ clientId });
return `
[Interface]
PrivateKey = ${client.privateKey ? `${client.privateKey}` : 'REPLACE_ME'}
Address = ${client.address}/24
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\
${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\
DNS = ${system.userConfig.defaultDns.join(',')}
MTU = ${system.userConfig.mtu}
[Peer]
PublicKey = ${config.server.publicKey}
${
client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
}AllowedIPs = ${WG_ALLOWED_IPS}
PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE}
Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
PublicKey = ${system.interface.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIPs}
PersistentKeepalive = ${client.persistentKeepalive}
Endpoint = ${system.wgHost}:${system.wgConfigPort}`;
}
async getClientQRCodeSVG({ clientId }: { clientId: string }) {
@ -266,7 +166,8 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
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 publicKey = await exec(`echo ${privateKey} | wg pubkey`, {
@ -274,15 +175,19 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
});
const preSharedKey = await exec('wg genpsk');
// TODO: cidr
// Calculate next IP
let address;
for (let i = 2; i < 255; i++) {
const client = Object.values(config.clients).find((client) => {
return client.address === WG_DEFAULT_ADDRESS.replace('x', i.toString());
const client = Object.values(clients).find((client) => {
return (
client.address ===
system.userConfig.addressRange.replace('x', i.toString())
);
});
if (!client) {
address = WG_DEFAULT_ADDRESS.replace('x', i.toString());
address = system.userConfig.addressRange.replace('x', i.toString());
break;
}
}
@ -293,22 +198,20 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
// Create Client
const id = crypto.randomUUID();
const client: Client = {
const client: NewClient = {
id,
name,
address,
privateKey,
publicKey,
preSharedKey,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
endpoint: null,
oneTimeLink: null,
oneTimeLinkExpiresAt: null,
expireAt: null,
expiresAt: null,
enabled: true,
allowedIPs: system.userConfig.allowedIps,
persistentKeepalive: system.userConfig.persistentKeepalive,
};
if (expireDate) {
@ -316,10 +219,10 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
date.setHours(23);
date.setMinutes(59);
date.setSeconds(59);
client.expireAt = date.toISOString();
client.expiresAt = date;
}
config.clients[id] = client;
await Database.createClient(client);
await this.saveConfig();
@ -327,50 +230,34 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
}
async deleteClient({ clientId }: { clientId: string }) {
const config = await this.getConfig();
if (config.clients[clientId]) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete config.clients[clientId];
await this.saveConfig();
}
await Database.deleteClient(clientId);
await this.saveConfig();
}
async enableClient({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId });
client.enabled = true;
client.updatedAt = new Date().toISOString();
await Database.toggleClient(clientId, true);
await this.saveConfig();
}
async generateOneTimeLink({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId });
const key = `${clientId}-${Math.floor(Math.random() * 1000)}`;
client.oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
client.oneTimeLinkExpiresAt = new Date(
Date.now() + 5 * 60 * 1000
).toISOString();
client.updatedAt = new Date().toISOString();
const oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
await Database.createOneTimeLink(clientId, {
oneTimeLink,
expiresAt,
});
await this.saveConfig();
}
async eraseOneTimeLink({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId });
// client.oneTimeLink = null;
client.oneTimeLinkExpiresAt = new Date(
Date.now() + 10 * 1000
).toISOString();
client.updatedAt = new Date().toISOString();
await Database.deleteOneTimeLink(clientId);
await this.saveConfig();
}
async disableClient({ clientId }: { clientId: string }) {
const client = await this.getClient({ clientId });
client.enabled = false;
client.updatedAt = new Date().toISOString();
await Database.toggleClient(clientId, false);
await this.saveConfig();
}
@ -382,10 +269,7 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
clientId: string;
name: string;
}) {
const client = await this.getClient({ clientId });
client.name = name;
client.updatedAt = new Date().toISOString();
await Database.updateClientName(clientId, name);
await this.saveConfig();
}
@ -397,8 +281,6 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
clientId: string;
address: string;
}) {
const client = await this.getClient({ clientId });
if (!isValidIPv4(address)) {
throw createError({
statusCode: 400,
@ -406,8 +288,7 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
});
}
client.address = address;
client.updatedAt = new Date().toISOString();
await Database.updateClientAddress(clientId, address);
await this.saveConfig();
}
@ -419,42 +300,81 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
clientId: string;
expireDate: string | null;
}) {
const client = await this.getClient({ clientId });
let updatedDate: Date | null = null;
if (expireDate) {
const date = new Date(expireDate);
date.setHours(23);
date.setMinutes(59);
date.setSeconds(59);
client.expireAt = date.toISOString();
} else {
client.expireAt = null;
updatedDate = date;
}
client.updatedAt = new Date().toISOString();
await this.saveConfig();
}
await Database.updateClientExpirationDate(clientId, updatedDate);
async __reloadConfig() {
await this.__buildConfig();
await this.__syncConfig();
await this.saveConfig();
}
async restoreConfiguration(config: string) {
debug('Starting configuration restore process.');
// TODO: reimplement database restore
async restoreConfiguration(_config: string) {
/* DEBUG('Starting configuration restore process.');
// TODO: sanitize config
const _config = JSON.parse(config);
await this.__saveConfig(_config);
await this.__reloadConfig();
debug('Configuration restore process completed.');
DEBUG('Configuration restore process completed.'); */
}
// TODO: reimplement database restore
async backupConfiguration() {
debug('Starting configuration backup.');
/* DEBUG('Starting configuration backup.');
const config = await this.getConfig();
const backup = JSON.stringify(config, null, 2);
debug('Configuration backup completed.');
return backup;
DEBUG('Configuration backup completed.');
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
@ -462,47 +382,31 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
await exec('wg-quick down wg0').catch(() => {});
}
async cronJobEveryMinute() {
const config = await this.getConfig();
async cronJob() {
const clients = await Database.getClients();
const system = await Database.getSystem();
if (!system) {
throw new Error('Invalid Database');
}
let needSaveConfig = false;
// Expires Feature
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.expireAt !== null &&
new Date() > new Date(client.expireAt)
) {
debug(`Client ${client.id} expired.`);
needSaveConfig = true;
client.enabled = false;
client.updatedAt = new Date().toISOString();
if (client.expiresAt !== null && new Date() > client.expiresAt) {
DEBUG(`Client ${client.id} expired.`);
await Database.toggleClient(client.id, false);
}
}
}
// One Time Link Feature
if (system.oneTimeLinks.enabled) {
for (const client of Object.values(config.clients)) {
for (const client of Object.values(clients)) {
if (
client.oneTimeLink !== null &&
client.oneTimeLinkExpiresAt !== null &&
new Date() > new Date(client.oneTimeLinkExpiresAt)
new Date() > client.oneTimeLink.expiresAt
) {
debug(`Client ${client.id} One Time Link expired.`);
needSaveConfig = true;
client.oneTimeLink = null;
client.oneTimeLinkExpiresAt = null;
client.updatedAt = new Date().toISOString();
DEBUG(`Client ${client.id} One Time Link expired.`);
await Database.deleteOneTimeLink(client.id);
}
}
}
if (needSaveConfig) {
await this.saveConfig();
}
}
async getMetrics() {
@ -580,15 +484,9 @@ Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
}
const inst = new WireGuard();
// This also has to also start the WireGuard Server
async function cronJobEveryMinute() {
await inst.cronJobEveryMinute().catch((err) => {
debug('Running Cron Job failed.');
console.error(err);
});
setTimeout(cronJobEveryMinute, 60 * 1000);
}
cronJobEveryMinute();
inst.Startup().catch((v) => {
console.error(v);
process.exit(1);
});
export default inst;

64
src/server/utils/config.ts

@ -1,69 +1,5 @@
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');

12
src/server/utils/types.ts

@ -42,6 +42,11 @@ const expireDate = z
.pipe(safeStringRefine)
.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(
{
clientId: id,
@ -70,6 +75,13 @@ export const expireDateType = z.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(
{
name: name,

110
src/services/database/lowdb.ts

@ -13,15 +13,15 @@ import type { Low } from 'lowdb';
import type { User } from './repositories/user';
import type { Database } from './repositories/database';
import { migrationRunner } from './migrations';
import type { Client, NewClient, OneTimeLink } from './repositories/client';
const DEBUG = debug('LowDB');
export default class LowDB extends DatabaseProvider {
#db!: Low<Database>;
#connected = false;
// is this really needed?
private async __init() {
// TODO: assume path to db file
const dbFilePath = join(WG_PATH, 'db.json');
this.#db = await JSONFilePreset(dbFilePath, DEFAULT_DATABASE);
}
@ -30,6 +30,9 @@ export default class LowDB extends DatabaseProvider {
* @throws
*/
async connect() {
if (this.#connected) {
return;
}
try {
await this.__init();
DEBUG('Running Migrations');
@ -39,11 +42,16 @@ export default class LowDB extends DatabaseProvider {
DEBUG(e);
throw new DatabaseError(DatabaseError.ERROR_INIT);
}
this.#connected = true;
DEBUG('Connected successfully');
}
get connected() {
return this.#connected;
}
async disconnect() {
this.#connected = false;
DEBUG('Disconnected successfully');
}
@ -57,6 +65,7 @@ export default class LowDB extends DatabaseProvider {
return system;
}
// TODO: return copy to avoid mutation (everywhere)
async getUsers() {
return this.#db.data.users;
}
@ -78,6 +87,7 @@ export default class LowDB extends DatabaseProvider {
throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ);
}
// TODO: multiple names are no problem
const isUserExist = this.#db.data.users.find(
(user) => user.username === username
);
@ -98,15 +108,16 @@ export default class LowDB extends DatabaseProvider {
updatedAt: now,
};
this.#db.update((data) => data.users.push(newUser));
await this.#db.update((data) => data.users.push(newUser));
}
async updateUser(user: User) {
// TODO: avoid mutation, prefer .update, updatedAt
let oldUser = await this.getUser(user.id);
if (oldUser) {
DEBUG('Update User');
oldUser = user;
this.#db.write();
await this.#db.write();
}
}
@ -114,7 +125,94 @@ export default class LowDB extends DatabaseProvider {
DEBUG('Delete User');
const idx = this.#db.data.users.findIndex((user) => user.id === id);
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]) {
if (data.clients[id].oneTimeLink) {
// Bug where Client makes 2 requests
data.clients[id].oneTimeLink.expiresAt = new Date(
Date.now() + 10 * 1000
).toISOString();
}
}
});
}
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 { 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>) {
const privateKey = await exec('wg genkey');
const publicKey = await exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey',
});
const addressRange = '10.8.0.x';
const database: Database = {
migrations: [],
system: {
// TODO: move to var, no need for database
release: packageJson.release.version,
interface: {
privateKey: privateKey,
publicKey: publicKey,
address: DEFAULT_ADDRESS.replace('x', '1'),
address: addressRange.replace('x', '1'),
},
sessionTimeout: 3600, // 1 hour
lang: 'en',
userConfig: {
mtu: 1420,
persistentKeepalive: 0,
// TODO: assume handle CIDR to compute next ip in WireGuard
rangeAddress: '10.8.0.0/24',
// TODO: handle CIDR to compute next ip in WireGuard
//addressRange: '10.8.0.0/24',
addressRange: addressRange,
defaultDns: ['1.1.1.1'],
allowedIps: ['0.0.0.0/0', '::/0'],
},
wgPath: WG_PATH,
wgDevice: DEFAULT_DEVICE,
wgHost: WG_HOST || '',
wgPort: DEFAULT_WG_PORT,
wgDevice: 'wg0',
// TODO: wgHost has to be configured when onboarding
wgHost: '',
wgPort: 51820,
wgConfigPort: 51820,
iptables: {
PreUp: '',
PostUp: DEFAULT_POST_UP,
PostUp: '',
PreDown: '',
PostDown: DEFAULT_POST_DOWN,
PostDown: '',
},
trafficStats: {
enabled: false,
@ -79,12 +61,32 @@ export async function run1(db: Low<Database>) {
sessionConfig: {
password: getRandomHex(256),
name: 'wg-easy',
cookie: undefined,
cookie: {},
},
cookieMaxAge: 24 * 60,
},
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.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 { User, UserRepository } from './user';
@ -6,12 +12,14 @@ export type Database = {
migrations: string[];
system: System;
users: User[];
clients: Record<string, Client>;
};
export const DEFAULT_DATABASE: Database = {
migrations: [],
system: null as never,
users: [],
clients: {},
};
/**
@ -22,7 +30,7 @@ export const DEFAULT_DATABASE: Database = {
*
*/
export abstract class DatabaseProvider
implements SystemRepository, UserRepository
implements SystemRepository, UserRepository, ClientRepository
{
/**
* Connects to the database.
@ -44,6 +52,23 @@ export abstract class DatabaseProvider
): Promise<void>;
abstract updateUser(user: User): 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 = {
mtu: number;
persistentKeepalive: number;
rangeAddress: string;
addressRange: string;
defaultDns: string[];
allowedIps: string[];
};
@ -57,7 +57,6 @@ export type System = {
userConfig: WGConfig;
wgPath: string;
wgDevice: string;
wgHost: string;
wgPort: number;
@ -72,6 +71,7 @@ export type System = {
prometheus: Prometheus;
sessionConfig: SessionConfig;
cookieMaxAge: number;
};
/**

Loading…
Cancel
Save