diff --git a/.gitignore b/.gitignore index e6fce2a6..ed408701 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ /src/node_modules .DS_Store *.swp +# lowdb data file +db.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 45647025..16276e92 100644 --- a/Dockerfile +++ b/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 \ diff --git a/How_to_generate_an_bcrypt_hash.md b/How_to_generate_an_bcrypt_hash.md deleted file mode 100644 index 7376fc56..00000000 --- a/How_to_generate_an_bcrypt_hash.md +++ /dev/null @@ -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 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 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. diff --git a/README.md b/README.md index 37b158a7..2fa78f8f 100644 --- a/README.md +++ b/README.md @@ -14,37 +14,37 @@ 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 We provide more then 1 docker image to get, this will help you decide which one is best for you. For **stable** versions instead of nightly or development please read **README** from the **production** branch! -| 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`. | -| `13` | production | `ghcr.io/wg-easy/wg-easy:13` | 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`. | -| `development` | pull requests | `ghcr.io/wg-easy/wg-easy:development` | used for development, testing code from PRs before landing into `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`. | +| `13` | production | `ghcr.io/wg-easy/wg-easy:13` | 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`. | +| `development` | pull requests | `ghcr.io/wg-easy/wg-easy:development` | used for development, testing code from PRs before landing into `master`. | ## Installation @@ -69,7 +69,6 @@ To automatically install & run wg-easy, simply run: --name=wg-easy \ -e LANG=de \ -e WG_HOST=<🚨YOUR_SERVER_IP> \ - -e PASSWORD_HASH=<🚨YOUR_ADMIN_PASSWORD_HASH> \ -e PORT=51821 \ -e WG_PORT=51820 \ -v ~/.wg-easy:/etc/wireguard \ @@ -84,8 +83,6 @@ To automatically install & run wg-easy, simply run: ``` > 💡 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 know how generate the hash. The Web UI will now be available on `http://0.0.0.0:51821`. @@ -105,33 +102,32 @@ Are you enjoying this project? [Buy Emile a beer!](https://github.com/sponsors/W 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). | -| `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. @@ -157,7 +153,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). diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index aef0b7b5..8bfbd9d5 100644 --- a/docker-compose.dev.yml +++ b/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 diff --git a/docker-compose.yml b/docker-compose.yml index 095557bf..78a86aba 100644 --- a/docker-compose.yml +++ b/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 diff --git a/package.json b/package.json index 68823c12..b4123fc5 100644 --- a/package.json +++ b/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" diff --git a/src/i18n.config.ts b/src/i18n.config.ts index aa9cad63..e84365b3 100644 --- a/src/i18n.config.ts +++ b/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 diff --git a/src/localeDetector.ts b/src/localeDetector.ts new file mode 100644 index 00000000..c766d486 --- /dev/null +++ b/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; +}); diff --git a/src/nuxt.config.ts b/src/nuxt.config.ts index 7f4e9165..59648d76 100644 --- a/src/nuxt.config.ts +++ b/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', + }, + }, }); diff --git a/src/package.json b/src/package.json index 9c948a2d..354f8e7c 100644 --- a/src/package.json +++ b/src/package.json @@ -30,6 +30,7 @@ "crc-32": "^1.2.2", "debug": "^4.3.6", "js-sha256": "^0.11.0", + "lowdb": "^7.0.1", "nuxt": "^3.12.4", "pinia": "^2.2.1", "qrcode": "^1.5.4", diff --git a/src/pages/login.vue b/src/pages/login.vue index ee7840b1..d034ee3a 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -18,6 +18,15 @@ + + const authenticating = ref(false); const remember = ref(false); +const username = ref(null); const password = ref(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('/'); } diff --git a/src/pages/setup.vue b/src/pages/setup.vue new file mode 100644 index 00000000..e1a2b24b --- /dev/null +++ b/src/pages/setup.vue @@ -0,0 +1,59 @@ + + + + Welcome to your first setup of wg-easy ! + Please first enter an admin username and a strong password. + + + Username + + + + New Password + + + + I accept the condition. + + + Save + + + + + + diff --git a/src/pnpm-lock.yaml b/src/pnpm-lock.yaml index a55a8750..4d3699c3 100644 --- a/src/pnpm-lock.yaml +++ b/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/watcher@2.4.1)(@types/node@22.0.2)(eslint@9.8.0)(ioredis@5.4.1)(magicast@0.3.4)(optionator@0.9.4)(rollup@4.19.2)(terser@5.31.3)(typescript@5.5.4)(vite@5.3.5(@types/node@22.0.2)(terser@5.31.3))(vue-tsc@2.0.29(typescript@5.5.4)) @@ -2868,6 +2871,10 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lowdb@7.0.1: + resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} + engines: {node: '>=18'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3841,6 +3848,10 @@ packages: std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + steno@4.0.2: + resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} + engines: {node: '>=18'} + streamx@2.18.0: resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} @@ -7582,6 +7593,10 @@ snapshots: lodash@4.17.21: {} + lowdb@7.0.1: + dependencies: + steno: 4.0.2 + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -8659,6 +8674,8 @@ snapshots: std-env@3.7.0: {} + steno@4.0.2: {} + streamx@2.18.0: dependencies: fast-fifo: 1.3.2 diff --git a/src/server/api/account/new.post.ts b/src/server/api/account/new.post.ts new file mode 100644 index 00000000..bcace054 --- /dev/null +++ b/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 !'); + } + } +}); diff --git a/src/server/api/cnf/[clientOneTimeLink].ts b/src/server/api/cnf/[clientOneTimeLink].ts index 0ff88d62..79ef77e2 100644 --- a/src/server/api/cnf/[clientOneTimeLink].ts +++ b/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', diff --git a/src/server/api/lang.get.ts b/src/server/api/lang.get.ts index 274ec407..4a926a7a 100644 --- a/src/server/api/lang.get.ts +++ b/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(); }); diff --git a/src/server/api/release.get.ts b/src/server/api/release.get.ts index 7d6beaf8..6fe97555 100644 --- a/src/server/api/release.get.ts +++ b/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, }; }); diff --git a/src/server/api/session.get.ts b/src/server/api/session.get.ts index be5e2426..9aa91658 100644 --- a/src/server/api/session.get.ts +++ b/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, }; }); diff --git a/src/server/api/session.post.ts b/src/server/api/session.post.ts index 9dcd830d..625b9bd8 100644 --- a/src/server/api/session.post.ts +++ b/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 }; }); diff --git a/src/server/api/ui-chart-type.get.ts b/src/server/api/ui-chart-type.get.ts index 2d2f9d23..46bb0566 100644 --- a/src/server/api/ui-chart-type.get.ts +++ b/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; } diff --git a/src/server/api/ui-traffic-stats.get.ts b/src/server/api/ui-traffic-stats.get.ts index f8353308..12d969c8 100644 --- a/src/server/api/ui-traffic-stats.get.ts +++ b/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; }); diff --git a/src/server/api/wg-enable-expire-time.get.ts b/src/server/api/wg-enable-expire-time.get.ts index 9d17d2ba..8d3031e7 100644 --- a/src/server/api/wg-enable-expire-time.get.ts +++ b/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; }); diff --git a/src/server/api/wg-enable-one-time-links.get.ts b/src/server/api/wg-enable-one-time-links.get.ts index ff734e2b..12488126 100644 --- a/src/server/api/wg-enable-one-time-links.get.ts +++ b/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; }); diff --git a/src/server/api/wireguard/client/[clientId]/expireDate.put.ts b/src/server/api/wireguard/client/[clientId]/expireDate.put.ts index ec913d94..fc4e9fb3 100644 --- a/src/server/api/wireguard/client/[clientId]/expireDate.put.ts +++ b/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 }; }); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index f6076544..ce90ece2 100644 --- a/src/server/middleware/auth.ts +++ b/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); } } diff --git a/src/server/middleware/session.ts b/src/server/middleware/session.ts index 5623e7ce..91ee154a 100644 --- a/src/server/middleware/session.ts +++ b/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({ diff --git a/src/server/middleware/setup.ts b/src/server/middleware/setup.ts new file mode 100644 index 00000000..53c2a7a7 --- /dev/null +++ b/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); + } +}); diff --git a/src/server/utils/Database.ts b/src/server/utils/Database.ts new file mode 100644 index 00000000..eecc80e6 --- /dev/null +++ b/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; diff --git a/src/server/utils/config.ts b/src/server/utils/config.ts index 675af12a..f147e3ba 100644 --- a/src/server/utils/config.ts +++ b/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 = { diff --git a/src/server/utils/password.ts b/src/server/utils/password.ts index 460ad2d2..2f01e01f 100644 --- a/src/server/utils/password.ts +++ b/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); } diff --git a/src/server/utils/session.ts b/src/server/utils/session.ts index 825379cf..0761cfed 100644 --- a/src/server/utils/session.ts +++ b/src/server/utils/session.ts @@ -4,6 +4,8 @@ export type WGSession = { authenticated: boolean; }; -export function useWGSession(event: H3Event) { - return useSession>(event, SESSION_CONFIG); +export async function useWGSession(event: H3Event) { + const system = await Database.getSystem(); + if (!system) throw new Error('Invalid'); + return useSession>(event, system.sessionConfig); } diff --git a/src/server/utils/types.ts b/src/server/utils/types.ts index 3b19c4a2..1b09c115 100644 --- a/src/server/utils/types.ts +++ b/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(schema: ZodSchema) { return async (data: unknown) => { try { diff --git a/src/services/database/inmemory.ts b/src/services/database/inmemory.ts new file mode 100644 index 00000000..d415b92f --- /dev/null +++ b/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); + } + } +} diff --git a/src/services/database/lowdb.ts b/src/services/database/lowdb.ts new file mode 100644 index 00000000..72cd435e --- /dev/null +++ b/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; + + 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)); + } + } +} diff --git a/src/services/database/repositories/database.ts b/src/services/database/repositories/database.ts new file mode 100644 index 00000000..48e33af8 --- /dev/null +++ b/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; + + /** + * Disconnects from the database. + */ + abstract disconnect(): Promise; + + abstract getSystem(): Promise; + abstract getLang(): Promise; + + abstract getUsers(): Promise>; + abstract getUser(id: ID): Promise; + abstract newUserWithPassword( + username: string, + password: string + ): Promise; + abstract updateUser(_user: User): Promise; + abstract deleteUser(id: ID): Promise; +} + +/** + * 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'; + } +} diff --git a/src/services/database/repositories/system/index.ts b/src/services/database/repositories/system/index.ts new file mode 100644 index 00000000..d08dc8b9 --- /dev/null +++ b/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; diff --git a/src/services/database/repositories/system/model.ts b/src/services/database/repositories/system/model.ts new file mode 100644 index 00000000..aadcb741 --- /dev/null +++ b/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; +}; diff --git a/src/services/database/repositories/system/repository.ts b/src/services/database/repositories/system/repository.ts new file mode 100644 index 00000000..b1b05da0 --- /dev/null +++ b/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} A promise that resolves to the system data + * if found, or `undefined` if the system data is not available. + */ + getSystem(): Promise; + + /** + * Retrieves the system's language setting. + * @returns {Promise} The current language setting of the system. + */ + getLang(): Promise; +} diff --git a/src/services/database/repositories/types.ts b/src/services/database/repositories/types.ts new file mode 100644 index 00000000..07e9785f --- /dev/null +++ b/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; + allowedIps: Array; +}; +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; +}; diff --git a/src/services/database/repositories/user/model.ts b/src/services/database/repositories/user/model.ts new file mode 100644 index 00000000..bd1910e4 --- /dev/null +++ b/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; +}; diff --git a/src/services/database/repositories/user/repository.ts.ts b/src/services/database/repositories/user/repository.ts.ts new file mode 100644 index 00000000..e1532c3a --- /dev/null +++ b/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>} A array of users data. + */ + getUsers(): Promise>; + + /** + * 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} A promise that resolves to the user data + * if found, or `undefined` if the user is not available. + */ + getUser(id: ID): Promise; + + newUserWithPassword(username: string, password: string): Promise; + + /** + * Updates a user in the database. + * @param {User} user - The user to be saved. + * + * @returns {Promise} A promise that resolves when the operation is complete. + */ + updateUser(user: User): Promise; + + /** + * Deletes a user from the database. + * @param {ID} id - The ID of the user or a User object to be deleted. + * @returns {Promise} A promise that resolves when the user has been deleted. + */ + deleteUser(id: ID): Promise; +} diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 4e6470ca..06af7b36 100644 --- a/src/stores/auth.ts +++ b/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 }; }); diff --git a/src/stores/global.ts b/src/stores/global.ts index 33a5a4f8..d8bfcd62 100644 --- a/src/stores/global.ts +++ b/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; } diff --git a/src/utils/api.ts b/src/utils/api.ts index 15553875..3a8423b2 100644 --- a/src/utils/api.ts +++ b/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< diff --git a/src/wgpw.js b/src/wgpw.js deleted file mode 100644 index 8be5a982..00000000 --- a/src/wgpw.js +++ /dev/null @@ -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); - } -})(); diff --git a/src/wgpw.sh b/src/wgpw.sh deleted file mode 100755 index aac6afa1..00000000 --- a/src/wgpw.sh +++ /dev/null @@ -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 "$@" \ No newline at end of file
Please first enter an admin username and a strong password.