Browse Source

add database (#1330)

* add: database abstraction

* update: get lang from database

* udpate: with repositories

* add: interfaces to connect a database provider

- easy swapping between database provider

* add: setup page

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

* update: Dockerfile

* fix: review done

- remove: REQUIRES_PASSWORD & RELEASE environment variables

* fix: i18n translation

- rename directories

* update: use database

* fix: typecheck

* fix: review

* rebase & add: persistent lowdb provider

* update: french translation

* revert: due to rebase

* remove & document
pull/1648/head
tetuaoro 7 months ago
committed by Bernd Storath
parent
commit
9788f36a60
  1. 2
      .gitignore
  2. 4
      Dockerfile
  3. 42
      How_to_generate_an_bcrypt_hash.md
  4. 132
      README.md
  5. 1
      docker-compose.dev.yml
  6. 1
      docker-compose.yml
  7. 2
      package.json
  8. 33
      src/i18n.config.ts
  9. 18
      src/localeDetector.ts
  10. 6
      src/nuxt.config.ts
  11. 1
      src/package.json
  12. 19
      src/pages/login.vue
  13. 59
      src/pages/setup.vue
  14. 17
      src/pnpm-lock.yaml
  15. 24
      src/server/api/account/new.post.ts
  16. 8
      src/server/api/cnf/[clientOneTimeLink].ts
  17. 2
      src/server/api/lang.get.ts
  18. 10
      src/server/api/release.get.ts
  19. 6
      src/server/api/session.get.ts
  20. 39
      src/server/api/session.post.ts
  21. 11
      src/server/api/ui-chart-type.get.ts
  22. 13
      src/server/api/ui-traffic-stats.get.ts
  23. 12
      src/server/api/wg-enable-expire-time.get.ts
  24. 12
      src/server/api/wg-enable-one-time-links.get.ts
  25. 5
      src/server/api/wireguard/client/[clientId]/expireDate.put.ts
  26. 4
      src/server/middleware/auth.ts
  27. 22
      src/server/middleware/session.ts
  28. 16
      src/server/middleware/setup.ts
  29. 17
      src/server/utils/Database.ts
  30. 5
      src/server/utils/config.ts
  31. 46
      src/server/utils/password.ts
  32. 6
      src/server/utils/session.ts
  33. 15
      src/server/utils/types.ts
  34. 93
      src/services/database/inmemory.ts
  35. 121
      src/services/database/lowdb.ts
  36. 89
      src/services/database/repositories/database.ts
  37. 55
      src/services/database/repositories/system/index.ts
  38. 45
      src/services/database/repositories/system/model.ts
  39. 22
      src/services/database/repositories/system/repository.ts
  40. 61
      src/services/database/repositories/types.ts
  41. 29
      src/services/database/repositories/user/model.ts
  42. 39
      src/services/database/repositories/user/repository.ts.ts
  43. 14
      src/stores/auth.ts
  44. 7
      src/stores/global.ts
  45. 17
      src/utils/api.ts
  46. 77
      src/wgpw.js
  47. 5
      src/wgpw.sh

2
.gitignore

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

4
Dockerfile

@ -22,10 +22,6 @@ HEALTHCHECK CMD /usr/bin/timeout 5s /bin/sh -c "/usr/bin/wg show | /bin/grep -q
# Copy build
COPY --from=build /app/.output /app
# Copy the needed wg-password scripts
COPY --from=build /app/wgpw.sh /bin/wgpw
RUN chmod +x /bin/wgpw
# Install Linux packages
RUN apk add --no-cache \
dpkg \

42
How_to_generate_an_bcrypt_hash.md

@ -1,42 +0,0 @@
# wg-password
`wg-password` (wgpw) is a script that generates bcrypt password hashes for use with `wg-easy`, enhancing security by requiring passwords.
## Features
- Generate bcrypt password hashes.
- Easily integrate with `wg-easy` to enforce password requirements.
## Usage with Docker
To generate a bcrypt password hash using docker, run the following command :
```sh
docker run --rm -it ghcr.io/wg-easy/wg-easy wgpw 'YOUR_PASSWORD'
PASSWORD_HASH='$2b$12$coPqCsPtcFO.Ab99xylBNOW4.Iu7OOA2/ZIboHN6/oyxca3MWo7fW' // literally YOUR_PASSWORD
```
If a password is not provided, the tool will prompt you for one :
```sh
docker run --rm -it ghcr.io/wg-easy/wg-easy wgpw
Enter your password: // hidden prompt, type in your password
PASSWORD_HASH='$2b$12$coPqCsPtcFO.Ab99xylBNOW4.Iu7OOA2/ZIboHN6/oyxca3MWo7fW'
```
**Important** : make sure to enclose your password in **single quotes** when you run `docker run` command :
```bash
$ echo $2b$12$coPqCsPtcF <-- not correct
b2
$ echo "$2b$12$coPqCsPtcF" <-- not correct
b2
$ echo '$2b$12$coPqCsPtcF' <-- correct
$2b$12$coPqCsPtcF
```
**Important** : Please note: don't wrap the generated hash password in single quotes when you use `docker-compose.yml`. Instead, replace each `$` symbol with two `$$` symbols. For example:
``` yaml
- PASSWORD_HASH=$$2y$$10$$hBCoykrB95WSzuV4fafBzOHWKu9sbyVa34GJr8VV5R/pIelfEMYyG
```
This hash is for the password 'foobar123', obtained using the command `docker run ghcr.io/wg-easy/wg-easy wgpw 'foobar123'` and then inserted an additional `$` before each existing `$` symbol.

132
README.md

@ -14,25 +14,25 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
## Features
* All-in-one: WireGuard + Web UI.
* Easy installation, simple to use.
* List, create, edit, delete, enable & disable clients.
* Show a client's QR code.
* Download a client's configuration file.
* Statistics for which clients are connected.
* Tx/Rx charts for each connected client.
* Gravatar support.
* Automatic Light / Dark Mode
* Multilanguage Support
* Traffic Stats (default off)
* One Time Links (default off)
* Client Expiry (default off)
* Prometheus metrics support
- All-in-one: WireGuard + Web UI.
- Easy installation, simple to use.
- List, create, edit, delete, enable & disable clients.
- Show a client's QR code.
- Download a client's configuration file.
- Statistics for which clients are connected.
- Tx/Rx charts for each connected client.
- Gravatar support.
- Automatic Light / Dark Mode
- Multilanguage Support
- Traffic Stats (default off)
- One Time Links (default off)
- Client Expiry (default off)
- Prometheus metrics support
## Requirements
* A host with a kernel that supports WireGuard (all modern kernels).
* A host with Docker installed.
- A host with a kernel that supports WireGuard (all modern kernels).
- A host with Docker installed.
## Versions
@ -42,12 +42,12 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
We provide more than 1 docker image tag, the following will help you decide
which one suites the best for you.
| tag | Branch | Example | Description |
| - | - | - | - |
| `latest` | [`production`](https://github.com/wg-easy/wg-easy/tree/production) | `ghcr.io/wg-easy/wg-easy:latest` or `ghcr.io/wg-easy/wg-easy` | stable as possbile get bug fixes quickly when needed, deployed against [`production`](https://github.com/wg-easy/wg-easy/tree/production). |
| `14` | [`production`](https://github.com/wg-easy/wg-easy/tree/production) | `ghcr.io/wg-easy/wg-easy:14` | same as latest, stick to a version tag. |
| `nightly` | [`master`](https://github.com/wg-easy/wg-easy/tree/master) | `ghcr.io/wg-easy/wg-easy:nightly` | mostly unstable gets frequent package and code updates, deployed against [`master`](https://github.com/wg-easy/wg-easy/tree/master). |
| `development` | pull requests | `ghcr.io/wg-easy/wg-easy:development` | used for development, testing code from PRs before landing into [`master`](https://github.com/wg-easy/wg-easy/tree/master). |
| tag | Branch | Example | Description |
| ------------- | ------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `latest` | production | `ghcr.io/wg-easy/wg-easy:latest` or `ghcr.io/wg-easy/wg-easy` | stable as possbile get bug fixes quickly when needed, deployed against [`production`](https://github.com/wg-easy/wg-easy/tree/production). |
| `14` | production | `ghcr.io/wg-easy/wg-easy:14` | same as latest, stick to a version tag. |
| `nightly` | master | `ghcr.io/wg-easy/wg-easy:nightly` | mostly unstable gets frequent package and code updates, deployed against [`master`](https://github.com/wg-easy/wg-easy/tree/master). |
| `development` | pull requests | `ghcr.io/wg-easy/wg-easy:development` | used for development, testing code from PRs before landing into [`master`](https://github.com/wg-easy/wg-easy/tree/master). |
## Installation
@ -67,28 +67,25 @@ And log in again.
To automatically install & run wg-easy, simply run:
```shell
docker run --detach \
--name wg-easy \
--env LANG=de \
--env WG_HOST=<🚨YOUR_SERVER_IP> \
--env PASSWORD_HASH='<🚨YOUR_ADMIN_PASSWORD_HASH>' \
--env PORT=51821 \
--env WG_PORT=51820 \
--volume ~/.wg-easy:/etc/wireguard \
--publish 51820:51820/udp \
--publish 51821:51821/tcp \
--cap-add NET_ADMIN \
--cap-add SYS_MODULE \
--sysctl 'net.ipv4.conf.all.src_valid_mark=1' \
--sysctl 'net.ipv4.ip_forward=1' \
```
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 \
--cap-add=NET_ADMIN \
--cap-add=SYS_MODULE \
--sysctl="net.ipv4.conf.all.src_valid_mark=1" \
--sysctl="net.ipv4.ip_forward=1" \
--restart unless-stopped \
ghcr.io/wg-easy/wg-easy
```
> 💡 Replace `<🚨YOUR_SERVER_IP>` with your WAN IP, or a Dynamic DNS hostname.
>
> 💡 Replace `<🚨YOUR_ADMIN_PASSWORD_HASH>` with a bcrypt password hash to log in on the Web UI. See [How_to_generate_an_bcrypt_hash.md](./How_to_generate_an_bcrypt_hash.md) for instructions on how to generate a hashed password.
The Web UI will now be available on `http://0.0.0.0:51821`.
@ -109,33 +106,32 @@ 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. |
| `PASSWORD_HASH` | - | `$2y$05$Ci...` | When set, requires a password when logging in to the Web UI. 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. |
| `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, ja, si). |
| `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. |
| 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.
@ -161,7 +157,7 @@ was pulled.
## Common Use Cases
* [Using WireGuard-Easy with Pi-Hole](https://github.com/wg-easy/wg-easy/wiki/Using-WireGuard-Easy-with-Pi-Hole)
* [Using WireGuard-Easy with nginx/SSL](https://github.com/wg-easy/wg-easy/wiki/Using-WireGuard-Easy-with-nginx-SSL)
- [Using WireGuard-Easy with Pi-Hole](https://github.com/wg-easy/wg-easy/wiki/Using-WireGuard-Easy-with-Pi-Hole)
- [Using WireGuard-Easy with nginx/SSL](https://github.com/wg-easy/wg-easy/wiki/Using-WireGuard-Easy-with-nginx-SSL)
For less common or specific edge-case scenarios, please refer to the detailed information provided in the [Wiki](https://github.com/wg-easy/wg-easy/wiki).

1
docker-compose.dev.yml

@ -15,7 +15,6 @@ services:
- NET_ADMIN
- SYS_MODULE
environment:
- PASSWORD_HASH=$$2y$$10$$Vhi2tF1i2c/ReW3LdLOru.z7LDITqBgb2wrSVw6sa.KEtbpYgSAf2 # foobar123
- WG_HOST=192.168.1.233
# folders should be generated inside container

1
docker-compose.yml

@ -12,7 +12,6 @@ services:
- WG_HOST=raspberrypi.local
# Optional:
# - PASSWORD_HASH=$$2y$$10$$hBCoykrB95WSzuV4fafBzOHWKu9sbyVa34GJr8VV5R/pIelfEMYyG # (needs double $$, hash of 'foobar123'; see "How_to_generate_an_bcrypt_hash.md" for generate the hash)
# - PORT=51821
# - WG_PORT=51820
# - WG_CONFIG_PORT=92820

2
package.json

@ -2,7 +2,7 @@
"version": "1.0.1",
"scripts": {
"sudobuild": "DOCKER_BUILDKIT=1 sudo docker build --tag wg-easy .",
"build": "DOCKER_BUILDKIT=1 docker build --tag wg-easy .",
"build:docker": "DOCKER_BUILDKIT=1 docker build --tag wg-easy .",
"serve": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up",
"sudostart": "sudo docker run --env WG_HOST=0.0.0.0 --name wg-easy --cap-add=NET_ADMIN --cap-add=SYS_MODULE --sysctl=\"net.ipv4.conf.all.src_valid_mark=1\" --mount type=bind,source=\"$(pwd)\"/config,target=/etc/wireguard -p 51820:51820/udp -p 51821:51821/tcp wg-easy",
"start": "docker run --env WG_HOST=0.0.0.0 --name wg-easy --cap-add=NET_ADMIN --cap-add=SYS_MODULE --sysctl=\"net.ipv4.conf.all.src_valid_mark=1\" --mount type=bind,source=\"$(pwd)\"/config,target=/etc/wireguard -p 51820:51820/udp -p 51821:51821/tcp wg-easy"

33
src/i18n.config.ts

@ -46,6 +46,12 @@ export default defineI18nConfig(() => ({
ExpireDate: 'Expire Date',
Permanent: 'Permanent',
OneTimeLink: 'Generate short one time link',
errorInit: 'Initialization failed.',
errorDatabaseConn: 'Failed to connect to the database.',
errorPasswordReq:
'Password does not meet the strength requirements. It must be at least 12 characters long, with at least one uppercase letter, one lowercase letter, one number, and one special character.',
errorUsernameReq: 'Username must be longer than 8 characters.',
errorUserExist: 'User already exists.',
},
ua: {
name: 'Ім`я',
@ -238,7 +244,7 @@ export default defineI18nConfig(() => ({
clients: 'Clients',
new: 'Nouveau',
deleteClient: 'Supprimer ce client',
deleteDialog1: 'Êtes-vous que vous voulez supprimer',
deleteDialog1: 'Êtes-vous sûr de vouloir supprimer',
deleteDialog2: 'Cette action ne peut pas être annulée.',
cancel: 'Annuler',
create: 'Créer',
@ -250,14 +256,35 @@ export default defineI18nConfig(() => ({
disableClient: 'Désactiver ce client',
enableClient: 'Activer ce client',
noClients: 'Aucun client pour le moment.',
showQR: 'Afficher le code à réponse rapide (QR Code)',
noPrivKey:
"Ce client n'a pas de clé privée connue. Impossible de créer la configuration.",
showQR: 'Afficher le code QR',
downloadConfig: 'Télécharger la configuration',
madeBy: 'Développé par',
donate: 'Soutenir',
donate: 'Faire un don',
toggleCharts: 'Afficher/masquer les graphiques',
theme: {
dark: 'Thème sombre',
light: 'Thème clair',
system: 'Thème du système',
},
restore: 'Restaurer',
backup: 'Sauvegarder',
titleRestoreConfig: 'Restaurer votre configuration',
titleBackupConfig: 'Sauvegarder votre configuration',
rememberMe: 'Se souvenir de moi',
titleRememberMe: 'Restez connecté après la fermeture du navigateur',
sort: 'Trier',
ExpireDate: "Date d'expiration",
Permanent: 'Permanent',
OneTimeLink: 'Générer un lien court à usage unique',
errorInit: "Échec de l'initialisation.",
errorDatabaseConn: 'Échec de la connexion à la base de données.',
errorPasswordReq:
'Le mot de passe ne répond pas aux exigences de sécurité. Il doit comporter au moins 12 caractères, dont au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère spécial.',
errorUsernameReq:
"Le nom d'utilisateur doit comporter plus de 8 caractères.",
errorUserExist: "L'utilisateur existe déjà.",
},
de: {
// github.com/florian-asche

18
src/localeDetector.ts

@ -0,0 +1,18 @@
export default defineI18nLocaleDetector((event, config) => {
const query = tryQueryLocale(event, { lang: '' });
if (query) {
return query.toString();
}
const cookie = tryCookieLocale(event, { lang: '', name: 'i18n_locale' });
if (cookie) {
return cookie.toString();
}
const header = tryHeaderLocale(event, { lang: '' });
if (header) {
return header.toString();
}
return config.defaultLocale;
});

6
src/nuxt.config.ts

@ -17,4 +17,10 @@ export default defineNuxtConfig({
classSuffix: '',
cookieName: 'theme',
},
i18n: {
// https://i18n.nuxtjs.org/docs/guide/server-side-translations
experimental: {
localeDetector: './localeDetector.ts',
},
},
});

1
src/package.json

@ -30,6 +30,7 @@
"crc-32": "^1.2.2",
"debug": "^4.3.7",
"js-sha256": "^0.11.0",
"lowdb": "^7.0.1",
"nuxt": "^3.12.4",
"pinia": "^2.2.1",
"qrcode": "^1.5.4",

19
src/pages/login.vue

@ -18,6 +18,15 @@
<IconsAvatar class="w-10 h-10 m-5 text-white dark:text-white" />
</div>
<input
v-model="username"
type="text"
name="username"
:placeholder="$t('username')"
autocomplete="username"
class="px-3 py-2 text-sm dark:bg-neutral-700 text-gray-500 dark:text-gray-500 mb-5 border-2 border-gray-100 dark:border-neutral-800 rounded-lg w-full focus:border-red-800 dark:focus:border-red-800 dark:placeholder:text-neutral-400 outline-none"
/>
<input
v-model="password"
type="password"
@ -77,6 +86,7 @@
<script setup lang="ts">
const authenticating = ref(false);
const remember = ref(false);
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const authStore = useAuthStore();
const globalStore = useGlobalStore();
@ -84,12 +94,15 @@ const globalStore = useGlobalStore();
async function login(e: Event) {
e.preventDefault();
if (!password.value) return;
if (authenticating.value) return;
if (!username.value || !password.value || authenticating.value) return;
authenticating.value = true;
try {
const res = await authStore.login(password.value, remember.value);
const res = await authStore.login(
username.value,
password.value,
remember.value
);
if (res) {
await navigateTo('/');
}

59
src/pages/setup.vue

@ -0,0 +1,59 @@
<template>
<main>
<div>
<h1>Welcome to your first setup of wg-easy !</h1>
<p>Please first enter an admin username and a strong password.</p>
<form @submit="newAccount">
<div>
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
name="username"
autocomplete="username"
/>
</div>
<div>
<label for="password">New Password</label>
<input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="new-password"
/>
</div>
<div>
<label for="accept">I accept the condition.</label>
<input id="accept" type="checkbox" name="accept" />
</div>
<button type="submit">Save</button>
</form>
</div>
</main>
</template>
<script setup lang="ts">
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const authStore = useAuthStore();
async function newAccount(e: Event) {
e.preventDefault();
if (!username.value || !password.value) return;
try {
const res = await authStore.signup(username.value, password.value);
if (res) {
navigateTo('/login');
}
} catch (error) {
if (error instanceof Error) {
// TODO: replace alert with actual ui error message
alert(error.message || error.toString());
}
}
}
</script>

17
src/pnpm-lock.yaml

@ -41,6 +41,9 @@ importers:
js-sha256:
specifier: ^0.11.0
version: 0.11.0
lowdb:
specifier: ^7.0.1
version: 7.0.1
nuxt:
specifier: ^3.12.4
version: 3.12.4(@parcel/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
@ -2868,6 +2871,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
[email protected]:
resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@ -3838,6 +3845,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
[email protected]:
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==}
@ -7579,6 +7590,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
steno: 4.0.2
[email protected]: {}
[email protected]:
@ -8654,6 +8669,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fast-fifo: 1.3.2

24
src/server/api/account/new.post.ts

@ -0,0 +1,24 @@
import { DatabaseError } from '~/services/database/repositories/database';
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
try {
const { username, password } = await readValidatedBody(
event,
validateZod(passwordType)
);
await Database.newUserWithPassword(username, password);
return { success: true };
} catch (error) {
if (error instanceof DatabaseError) {
const t = await useTranslation(event);
throw createError({
statusCode: 400,
statusMessage: t(error.message),
message: error.message,
});
} else {
throw createError('Something happened !');
}
}
});

8
src/server/api/cnf/[clientOneTimeLink].ts

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

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

@ -1,4 +1,4 @@
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return LANG;
return Database.getLang();
});

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

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

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

@ -1,11 +1,9 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
const authenticated = REQUIRES_PASSWORD
? session.data.authenticated === true
: true;
const authenticated = session.data.authenticated;
return {
requiresPassword: REQUIRES_PASSWORD,
requiresPassword: true,
authenticated,
};
});

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

@ -1,43 +1,54 @@
import type { SessionConfig } from 'h3';
export default defineEventHandler(async (event) => {
const { password, remember } = await readValidatedBody(
const { username, password, remember } = await readValidatedBody(
event,
validateZod(passwordType)
validateZod(credentialsType)
);
if (!REQUIRES_PASSWORD) {
// if no password is required, the API should never be called.
// Do not automatically authenticate the user.
const users = await Database.getUsers();
const user = users.find((user) => user.username == username);
if (!user)
throw createError({
statusCode: 401,
statusMessage: 'Invalid state',
statusCode: 400,
statusMessage: 'Incorrect credentials',
});
}
if (!isPasswordValid(password, PASSWORD_HASH)) {
const userHashPassword = user.password;
if (!isPasswordValid(password, userHashPassword)) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect Password',
statusMessage: 'Incorrect credentials',
});
}
const conf: SessionConfig = SESSION_CONFIG;
// TODO: timing againts timing attack
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
const conf: SessionConfig = system.sessionConfig;
if (MAX_AGE && remember) {
conf.cookie = {
...(SESSION_CONFIG.cookie ?? {}),
...(system.sessionConfig.cookie ?? {}),
maxAge: MAX_AGE,
};
}
const session = await useSession(event, {
...SESSION_CONFIG,
...system.sessionConfig,
});
const data = await session.update({
authenticated: true,
userId: user.id,
});
SERVER_DEBUG(`New Session: ${data.id}`);
return { success: true, requiresPassword: REQUIRES_PASSWORD };
return { success: true, requiresPassword: true };
});

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

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

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

@ -1,6 +1,11 @@
export default defineEventHandler((event) => {
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
// Weird issue with auto import not working. This alias is needed
const stats = UI_TRAFFIC_STATS;
return stats === 'true' ? true : false;
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
return system.trafficStats.enabled;
});

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

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

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

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

5
src/server/api/wireguard/client/[clientId]/expireDate.put.ts

@ -7,6 +7,9 @@ export default defineEventHandler(async (event) => {
event,
validateZod(expireDateType)
);
await WireGuard.updateClientExpireDate({ clientId, expireDate });
await WireGuard.updateClientExpireDate({
clientId,
expireDate,
});
return { success: true };
});

4
src/server/middleware/auth.ts

@ -2,12 +2,12 @@ export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const session = await useWGSession(event);
if (url.pathname === '/login') {
if (!REQUIRES_PASSWORD || session.data.authenticated) {
if (session.data.authenticated) {
return sendRedirect(event, '/', 302);
}
}
if (url.pathname === '/') {
if (!session.data.authenticated && REQUIRES_PASSWORD) {
if (!session.data.authenticated) {
return sendRedirect(event, '/login', 302);
}
}

22
src/server/middleware/session.ts

@ -1,8 +1,8 @@
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
if (
!REQUIRES_PASSWORD ||
!url.pathname.startsWith('/api/') ||
url.pathname === '/api/account/new' ||
url.pathname === '/api/session' ||
url.pathname === '/api/lang' ||
url.pathname === '/api/release' ||
@ -11,14 +11,30 @@ export default defineEventHandler(async (event) => {
) {
return;
}
const session = await getSession(event, SESSION_CONFIG);
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 500,
statusMessage: 'Invalid',
});
const session = await getSession(event, system.sessionConfig);
if (session.id && session.data.authenticated) {
return;
}
const authorization = getHeader(event, 'Authorization');
if (url.pathname.startsWith('/api/') && authorization) {
if (isPasswordValid(authorization, PASSWORD_HASH)) {
const users = await Database.getUsers();
const user = users.find((user) => user.id == session.data.userId);
if (!user)
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
const userHashPassword = user.password;
if (isPasswordValid(authorization, userHashPassword)) {
return;
}
throw createError({

16
src/server/middleware/setup.ts

@ -0,0 +1,16 @@
/* First setup of wg-easy app */
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
if (
url.pathname.startsWith('/setup') ||
url.pathname === '/api/account/new'
) {
return;
}
const users = await Database.getUsers();
if (users.length === 0) {
return sendRedirect(event, '/setup', 302);
}
});

17
src/server/utils/Database.ts

@ -0,0 +1,17 @@
/**
* Changing the Database Provider
* This design allows for easy swapping of different database implementations.
*
*/
// import InMemory from '~/services/database/inmemory';
import LowDb from '~/services/database/lowdb';
const provider = new LowDb();
provider.connect().catch((err) => {
console.error(err);
process.exit(1);
});
export default provider;

5
src/server/utils/config.ts

@ -1,13 +1,9 @@
import type { SessionConfig } from 'h3';
import packageJSON from '../../package.json';
import debug from 'debug';
const version = packageJSON.release.version;
export const RELEASE = version;
export const PORT = process.env.PORT || '51821';
export const WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0';
export const PASSWORD_HASH = process.env.PASSWORD_HASH;
export const MAX_AGE = process.env.MAX_AGE
? parseInt(process.env.MAX_AGE, 10) * 60
: 0;
@ -64,7 +60,6 @@ export const ENABLE_PROMETHEUS_METRICS =
export const PROMETHEUS_METRICS_PASSWORD =
process.env.PROMETHEUS_METRICS_PASSWORD;
export const REQUIRES_PASSWORD = !!PASSWORD_HASH;
export const REQUIRES_PROMETHEUS_PASSWORD = !!PROMETHEUS_METRICS_PASSWORD;
export const SESSION_CONFIG = {

46
src/server/utils/password.ts

@ -1,17 +1,47 @@
import bcrypt from 'bcryptjs';
/**
* Checks if `password` matches the PASSWORD_HASH.
* Checks if `password` matches the user password.
*
* If environment variable is not set, the password is always invalid.
* @param {string} password string to test
* @returns {boolean} `true` if matching user password, otherwise `false`
*/
export function isPasswordValid(password: string, hash: string): boolean {
return bcrypt.compareSync(password, hash);
}
/**
* Checks if a password is strong based on following criteria :
*
* - minimum length of 12 characters
* - contains at least one uppercase letter
* - contains at least one lowercase letter
* - contains at least one number
* - contains at least one special character (e.g., !@#$%^&*(),.?":{}|<>).
*
* @param {string} password String to test
* @returns {boolean} true if matching environment, otherwise false
* @param {string} password - The password to validate
* @returns {boolean} `true` if the password is strong, otherwise `false`
*/
export function isPasswordValid(password: string, hash?: string): boolean {
if (hash) {
return bcrypt.compareSync(password, hash);
export function isPasswordStrong(password: string): boolean {
if (password.length < 12) {
return false;
}
return false;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
return hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;
}
/**
* Hashes a password.
*
* @param {string} password - The plaintext password to hash
* @returns {string} The hash of the password
*/
export function hashPassword(password: string): string {
return bcrypt.hashSync(password, 12);
}

6
src/server/utils/session.ts

@ -4,6 +4,8 @@ export type WGSession = {
authenticated: boolean;
};
export function useWGSession(event: H3Event) {
return useSession<Partial<WGSession>>(event, SESSION_CONFIG);
export async function useWGSession(event: H3Event) {
const system = await Database.getSystem();
if (!system) throw new Error('Invalid');
return useSession<Partial<WGSession>>(event, system.sessionConfig);
}

15
src/server/utils/types.ts

@ -26,6 +26,10 @@ const file = z
.string({ message: 'File must be a valid string' })
.pipe(safeStringRefine);
const username = z
.string({ message: 'Username must be a valid string' })
.pipe(safeStringRefine);
const password = z
.string({ message: 'Password must be a valid string' })
.pipe(safeStringRefine);
@ -81,14 +85,23 @@ export const fileType = z.object(
{ message: 'Body must be a valid object' }
);
export const passwordType = z.object(
export const credentialsType = z.object(
{
username: username,
password: password,
remember: remember,
},
{ message: 'Body must be a valid object' }
);
export const passwordType = z.object(
{
username: username,
password: password,
},
{ message: 'Body must be a valid object' }
);
export function validateZod<T>(schema: ZodSchema<T>) {
return async (data: unknown) => {
try {

93
src/services/database/inmemory.ts

@ -0,0 +1,93 @@
import crypto from 'node:crypto';
import debug from 'debug';
import DatabaseProvider, { DatabaseError } from './repositories/database';
import { hashPassword, isPasswordStrong } from '~/server/utils/password';
import { Lang } from './repositories/types';
import SYSTEM from './repositories/system';
import type { User } from './repositories/user/model';
import type { ID } from './repositories/types';
const DEBUG = debug('InMemoryDB');
// In-Memory Database Provider
export default class InMemory extends DatabaseProvider {
async connect() {
this.data.system = SYSTEM;
DEBUG('Connection done');
}
async disconnect() {
this.data = { system: null, users: [] };
DEBUG('Diconnect done');
}
async getSystem() {
DEBUG('Get System');
return this.data.system;
}
async getLang() {
return this.data.system?.lang || Lang.EN;
}
async getUsers() {
return this.data.users;
}
async getUser(id: ID) {
DEBUG('Get User');
return this.data.users.find((user) => user.id === id);
}
async newUserWithPassword(username: string, password: string) {
DEBUG('New User');
if (username.length < 8) {
throw new DatabaseError(DatabaseError.ERROR_USERNAME_REQ);
}
if (!isPasswordStrong(password)) {
throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ);
}
const isUserExist = this.data.users.find(
(user) => user.username === username
);
if (isUserExist) {
throw new DatabaseError(DatabaseError.ERROR_USER_EXIST);
}
const now = new Date();
const isUserEmpty = this.data.users.length === 0;
const newUser: User = {
id: crypto.randomUUID(),
password: hashPassword(password),
username,
role: isUserEmpty ? 'ADMIN' : 'CLIENT',
enabled: true,
createdAt: now,
updatedAt: now,
};
this.data.users.push(newUser);
}
async updateUser(user: User) {
let _user = await this.getUser(user.id);
if (_user) {
DEBUG('Update User');
_user = user;
}
}
async deleteUser(id: ID) {
DEBUG('Delete User');
const idx = this.data.users.findIndex((user) => user.id === id);
if (idx !== -1) {
this.data.users.splice(idx, 1);
}
}
}

121
src/services/database/lowdb.ts

@ -0,0 +1,121 @@
import crypto from 'node:crypto';
import debug from 'debug';
import { join } from 'path';
import DatabaseProvider, { DatabaseError } from './repositories/database';
import { hashPassword, isPasswordStrong } from '~/server/utils/password';
import { JSONFilePreset } from 'lowdb/node';
import { Lang } from './repositories/types';
import SYSTEM from './repositories/system';
import type { User } from './repositories/user/model';
import type { DBData } from './repositories/database';
import type { ID } from './repositories/types';
import type { Low } from 'lowdb';
const DEBUG = debug('LowDB');
export default class LowDB extends DatabaseProvider {
private _db!: Low<DBData>;
private async __init() {
// TODO: assume path to db file
const dbFilePath = join(WG_PATH, 'db.json');
this._db = await JSONFilePreset(dbFilePath, this.data);
}
async connect() {
try {
// load file db
await this._db.read();
DEBUG('Connection done');
return;
} catch (error) {
DEBUG('Database does not exist : ', error);
}
try {
await this.__init();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
throw new DatabaseError(DatabaseError.ERROR_INIT);
}
this._db.update((data) => (data.system = SYSTEM));
DEBUG('Connection done');
}
async disconnect() {
DEBUG('Diconnect done');
}
async getSystem() {
DEBUG('Get System');
return this._db.data.system;
}
async getLang() {
return this._db.data.system?.lang || Lang.EN;
}
async getUsers() {
return this._db.data.users;
}
async getUser(id: ID) {
DEBUG('Get User');
return this._db.data.users.find((user) => user.id === id);
}
async newUserWithPassword(username: string, password: string) {
DEBUG('New User');
if (username.length < 8) {
throw new DatabaseError(DatabaseError.ERROR_USERNAME_REQ);
}
if (!isPasswordStrong(password)) {
throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ);
}
const isUserExist = this._db.data.users.find(
(user) => user.username === username
);
if (isUserExist) {
throw new DatabaseError(DatabaseError.ERROR_USER_EXIST);
}
const now = new Date();
const isUserEmpty = this._db.data.users.length === 0;
const newUser: User = {
id: crypto.randomUUID(),
password: hashPassword(password),
username,
role: isUserEmpty ? 'ADMIN' : 'CLIENT',
enabled: true,
createdAt: now,
updatedAt: now,
};
this._db.update((data) => data.users.push(newUser));
}
async updateUser(user: User) {
let _user = await this.getUser(user.id);
if (_user) {
DEBUG('Update User');
_user = user;
this._db.write();
}
}
async deleteUser(id: ID) {
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));
}
}
}

89
src/services/database/repositories/database.ts

@ -0,0 +1,89 @@
import type SystemRepository from './system/repository';
import type UserRepository from './user/repository.ts';
import type { Lang, ID } from './types';
import type { User } from './user/model';
import type { System } from './system/model';
// TODO: re-export type from /user & /system
// Represent data structure
export type DBData = {
system: System | null;
users: User[];
};
/**
* Abstract class for database operations.
* Provides methods to connect, disconnect, and interact with system and user data.
*
* **Note :** Always throw `DatabaseError` to ensure proper API error handling.
*
*/
export default abstract class DatabaseProvider
implements SystemRepository, UserRepository
{
protected data: DBData = { system: null, users: [] };
/**
* Connects to the database.
*/
abstract connect(): Promise<void>;
/**
* Disconnects from the database.
*/
abstract disconnect(): Promise<void>;
abstract getSystem(): Promise<System | null>;
abstract getLang(): Promise<Lang>;
abstract getUsers(): Promise<Array<User>>;
abstract getUser(id: ID): Promise<User | undefined>;
abstract newUserWithPassword(
username: string,
password: string
): Promise<void>;
abstract updateUser(_user: User): Promise<void>;
abstract deleteUser(id: ID): Promise<void>;
}
/**
* Represents a specialized error class for database-related operations.
* This class is designed to work with internationalization (i18n) by using message keys.
* The actual error messages are expected to be retrieved using these keys from an i18n system.
*
* ### Usage:
* When throwing a `DatabaseError`, you provide an i18n key as the message.
* The key will be used by the i18n system to retrieve the corresponding localized error message.
*
* Example:
* ```typescript
* throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ);
* ...
* // event handler routes
* if (error instanceof DatabaseError) {
* const t = await useTranslation(event);
* throw createError({
* statusCode: 400,
* statusMessage: t(error.message),
* message: error.message,
* });
* } else {
* throw createError('Something happened !');
* }
* ```
*
* @extends {Error}
*/
export class DatabaseError extends Error {
static readonly ERROR_INIT = 'errorInit';
static readonly ERROR_PASSWORD_REQ = 'errorPasswordReq';
static readonly ERROR_USER_EXIST = 'errorUserExist';
static readonly ERROR_DATABASE_CONNECTION = 'errorDatabaseConn';
static readonly ERROR_USERNAME_REQ = 'errorUsernameReq';
constructor(message: string) {
super(message);
this.name = 'DatabaseError';
}
}

55
src/services/database/repositories/system/index.ts

@ -0,0 +1,55 @@
import packageJson from '@/package.json';
import { ChartType, Lang } from '../types';
import type { System } from './model';
const DEFAULT_SYSTEM_MODEL: System = {
release: packageJson.release.version,
interface: {
privateKey: '',
publicKey: '',
address: '10.8.0.1',
},
port: PORT ? Number(PORT) : 51821,
webuiHost: '0.0.0.0',
sessionTimeout: 3600, // 1 hour
lang: Lang.EN,
userConfig: {
mtu: 1420,
persistentKeepalive: 0,
// TODO: assume handle CIDR to compute next ip in WireGuard
rangeAddress: '10.8.0.0/24',
defaultDns: ['1.1.1.1'],
allowedIps: ['0.0.0.0/0', '::/0'],
},
wgPath: WG_PATH,
wgDevice: 'wg0',
wgHost: WG_HOST || '',
wgPort: 51820,
wgConfigPort: 51820,
iptables: {
wgPreUp: '',
wgPostUp: '',
wgPreDown: '',
wgPostDown: '',
},
trafficStats: {
enabled: false,
type: ChartType.None,
},
wgEnableExpiresTime: false,
wgEnableOneTimeLinks: false,
wgEnableSortClients: false,
prometheus: {
enabled: false,
password: null,
},
sessionConfig: {
password: getRandomHex(256),
name: 'wg-easy',
cookie: undefined,
},
};
export default DEFAULT_SYSTEM_MODEL;

45
src/services/database/repositories/system/model.ts

@ -0,0 +1,45 @@
import type { SessionConfig } from 'h3';
import type {
Url,
IpTables,
Lang,
Port,
Prometheus,
SessionTimeOut,
TrafficStats,
Version,
WGConfig,
WGInterface,
} from '../types';
/**
* Representing the WireGuard network configuration data structure of a computer interface system.
*/
export type System = {
interface: WGInterface;
release: Version;
port: number;
webuiHost: string;
// maxAge
sessionTimeout: SessionTimeOut;
lang: Lang;
userConfig: WGConfig;
wgPath: string;
wgDevice: string;
wgHost: Url;
wgPort: Port;
wgConfigPort: Port;
iptables: IpTables;
trafficStats: TrafficStats;
wgEnableExpiresTime: boolean;
wgEnableOneTimeLinks: boolean;
wgEnableSortClients: boolean;
prometheus: Prometheus;
sessionConfig: SessionConfig;
};

22
src/services/database/repositories/system/repository.ts

@ -0,0 +1,22 @@
import type { Lang } from '../types';
import type { System } from './model';
/**
* Interface for system-related database operations.
* This interface provides methods for retrieving system configuration data
* and specific system properties, such as the language setting, from the database.
*/
export default interface SystemRepository {
/**
* Retrieves the system configuration data from the database.
* @returns {Promise<System | null>} A promise that resolves to the system data
* if found, or `undefined` if the system data is not available.
*/
getSystem(): Promise<System | null>;
/**
* Retrieves the system's language setting.
* @returns {Promise<Lang>} The current language setting of the system.
*/
getLang(): Promise<Lang>;
}

61
src/services/database/repositories/types.ts

@ -0,0 +1,61 @@
import type * as crypto from 'node:crypto';
export enum Lang {
/* english */
EN = 'en',
/* french */
FR = 'fr',
}
export type Ipv4 = `${number}.${number}.${number}.${number}`;
export type Ipv4CIDR = `${number}.${number}.${number}.${number}/${number}`;
export type Ipv6 =
`${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}`;
export type Ipv6CIDR =
`${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}/${number}`;
export type Address = Ipv4 | Ipv4CIDR | Ipv6 | Ipv6CIDR | '::/0';
export type UrlHttp = `http://${string}`;
export type UrlHttps = `https://${string}`;
export type Url = string | UrlHttp | UrlHttps | Address;
export type ID = crypto.UUID;
export type Version = string;
export type SessionTimeOut = number;
export type Port = number;
export type HashPassword = string;
export type Command = string;
export type Key = string;
export type IpTables = {
wgPreUp: Command;
wgPostUp: Command;
wgPreDown: Command;
wgPostDown: Command;
};
export type WGInterface = {
privateKey: Key;
publicKey: Key;
address: Address;
};
export type WGConfig = {
mtu: number;
persistentKeepalive: number;
rangeAddress: Address;
defaultDns: Array<Address>;
allowedIps: Array<Address>;
};
export enum ChartType {
None = 0,
Line = 1,
Area = 2,
Bar = 3,
}
export type TrafficStats = {
enabled: boolean;
type: ChartType;
};
export type Prometheus = {
enabled: boolean;
password: HashPassword | null;
};

29
src/services/database/repositories/user/model.ts

@ -0,0 +1,29 @@
import type { Address, ID, Key, HashPassword } from '../types';
/**
* Represents user roles within the application, each with specific permissions :
*
* - `ADMIN`: Full permissions to all resources, including the app, database, etc
* - `EDITOR`: Granted write and read permissions on their own resources as well as
* `CLIENT` resources, but without `ADMIN` privileges
* - `CLIENT`: Granted write and read permissions only on their own resources.
*/
export type ROLE = 'ADMIN' | 'EDITOR' | 'CLIENT';
/**
* Representing a user data structure.
*/
export type User = {
id: ID;
role: ROLE;
username: string;
password: HashPassword;
name?: string;
address?: Address;
privateKey?: Key;
publicKey?: Key;
preSharedKey?: string;
createdAt: Date;
updatedAt: Date;
enabled: boolean;
};

39
src/services/database/repositories/user/repository.ts.ts

@ -0,0 +1,39 @@
import type { ID } from '../types';
import type { User } from './model';
/**
* Interface for user-related database operations.
* This interface provides methods for managing user data.
*/
export default interface UserRepository {
/**
* Retrieves all users from the database.
* @returns {Promise<Array<User>>} A array of users data.
*/
getUsers(): Promise<Array<User>>;
/**
* Retrieves a user by their ID or User object from the database.
* @param {ID} id - The ID of the user or a User object.
* @returns {Promise<User | undefined>} A promise that resolves to the user data
* if found, or `undefined` if the user is not available.
*/
getUser(id: ID): Promise<User | undefined>;
newUserWithPassword(username: string, password: string): Promise<void>;
/**
* Updates a user in the database.
* @param {User} user - The user to be saved.
*
* @returns {Promise<void>} A promise that resolves when the operation is complete.
*/
updateUser(user: User): Promise<void>;
/**
* Deletes a user from the database.
* @param {ID} id - The ID of the user or a User object to be deleted.
* @returns {Promise<void>} A promise that resolves when the user has been deleted.
*/
deleteUser(id: ID): Promise<void>;
}

14
src/stores/auth.ts

@ -4,8 +4,16 @@ export const useAuthStore = defineStore('Auth', () => {
/**
* @throws if unsuccessful
*/
async function login(password: string, remember: boolean) {
const response = await api.createSession({ password, remember });
async function signup(username: string, password: string) {
const response = await api.newAccount({ username, password });
return response.success;
}
/**
* @throws if unsuccessful
*/
async function login(username: string, password: string, remember: boolean) {
const response = await api.createSession({ username, password, remember });
requiresPassword.value = response.requiresPassword;
return true as const;
}
@ -26,5 +34,5 @@ export const useAuthStore = defineStore('Auth', () => {
requiresPassword.value = session.requiresPassword;
}
return { requiresPassword, login, logout, update };
return { requiresPassword, login, logout, update, signup };
});

7
src/stores/global.ts

@ -29,11 +29,14 @@ export const useGlobalStore = defineStore('Global', () => {
// this is still called on client. why?
const { data: release } = await api.getRelease();
if (release.value!.currentRelease >= release.value!.latestRelease.version) {
if (
Number(release.value!.currentRelease) >=
release.value!.latestRelease.version
) {
return;
}
currentRelease.value = release.value!.currentRelease;
currentRelease.value = Number(release.value!.currentRelease);
latestRelease.value = release.value!.latestRelease;
}

17
src/utils/api.ts

@ -49,15 +49,17 @@ class API {
}
async createSession({
username,
password,
remember,
}: {
username: string;
password: string | null;
remember: boolean;
}) {
return $fetch('/api/session', {
method: 'post',
body: { password, remember },
body: { username, password, remember },
});
}
@ -161,6 +163,19 @@ class API {
method: 'get',
});
}
async newAccount({
username,
password,
}: {
username: string;
password: string;
}) {
return $fetch('/api/account/new', {
method: 'post',
body: { username, password },
});
}
}
type WGClientReturn = Awaited<

77
src/wgpw.js

@ -1,77 +0,0 @@
// Import needed libraries
import bcrypt from 'bcryptjs';
import { Writable } from 'stream';
import readline from 'readline';
// Function to generate hash
const generateHash = async (password) => {
try {
const salt = await bcrypt.genSalt(12);
const hash = await bcrypt.hash(password, salt);
console.log(`PASSWORD_HASH='${hash}'`);
} catch (error) {
throw new Error(`Failed to generate hash : ${error}`);
}
};
// Function to compare password with hash
const comparePassword = async (password, hash) => {
try {
const match = await bcrypt.compare(password, hash);
if (match) {
console.log('Password matches the hash !');
} else {
console.log('Password does not match the hash.');
}
} catch (error) {
throw new Error(`Failed to compare password and hash : ${error}`);
}
};
const readStdinPassword = () => {
return new Promise((resolve) => {
process.stdout.write('Enter your password: ');
const rl = readline.createInterface({
input: process.stdin,
output: new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}),
terminal: true,
});
rl.question('', (answer) => {
rl.close();
// Print a new line after password prompt
process.stdout.write('\n');
resolve(answer);
});
});
};
(async () => {
try {
// Retrieve command line arguments
const args = process.argv.slice(2); // Ignore the first two arguments
if (args.length > 2) {
throw new Error('Usage : wgpw [YOUR_PASSWORD] [HASH]');
}
const [password, hash] = args;
if (password && hash) {
await comparePassword(password, hash);
} else if (password) {
await generateHash(password);
} else {
const password = await readStdinPassword();
await generateHash(password);
}
} catch (error) {
console.error(error);
process.exit(1);
}
})();

5
src/wgpw.sh

@ -1,5 +0,0 @@
#!/bin/sh
# This script is intended to be run only inside a docker container, not on the development host machine
set -e
# proxy command
node /app/wgpw.mjs "$@"
Loading…
Cancel
Save