This commit is contained in:
MrLening 2025-04-02 14:16:12 +03:00
parent 95d648aaa0
commit 5a4624bdc6
26 changed files with 1358 additions and 295 deletions

23
.editorconfig Normal file
View File

@ -0,0 +1,23 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# The JSON files contain newlines inconsistently
[*.json]
insert_final_newline = ignore
# Minified JavaScript files shouldn't be changed
[**.min.js]
indent_style = ignore
insert_final_newline = ignore
[*.md]
trim_trailing_whitespace = false

5
.env
View File

@ -1,10 +1,11 @@
WG_HOST=🚨YOUR_SERVER_IP
PASSWORD=password
# (Supports: en, ru, tr, no, pl, fr, de, ca, es)
LANGUAGE=ru
LANGUAGE=en
PORT=51821
WG_DEVICE=eth0
WG_PORT=51820
WG_DEFAULT_ADDRESS=10.8.0.x
WG_DEFAULT_DNS=1.1.1.1
WG_ALLOWED_IPS=0.0.0.0/0, ::/0
DICEBEAR_TYPE=bottts
USE_GRAVATAR=true

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
/src/node_modules
.DS_Store
*.swp
.idea

View File

@ -17,11 +17,6 @@ FROM amneziavpn/amnezia-wg:latest
HEALTHCHECK CMD /usr/bin/timeout 5s /bin/sh -c "/usr/bin/wg show | /bin/grep -q interface || exit 1" --interval=1m --timeout=5s --retries=3
COPY --from=build_node_modules /app /app
# Install Node.js
RUN apk add --no-cache \
nodejs \
npm
# Move node_modules one directory up, so during development
# we don't have to mount it in a volume.
# This results in much faster reloading!
@ -31,11 +26,17 @@ RUN apk add --no-cache \
# than what runs inside of docker.
COPY --from=build_node_modules /node_modules /node_modules
# Copy the needed wg-password scripts
COPY --from=build_node_modules /app/wgpw.sh /bin/wgpw
RUN chmod +x /bin/wgpw
# Install Linux packages
RUN apk add --no-cache \
dpkg \
dumb-init \
iptables
iptables \
nodejs \
npm
# Use iptables-legacy
RUN update-alternatives --install /sbin/iptables iptables /sbin/iptables-legacy 10 --slave /sbin/iptables-restore iptables-restore /sbin/iptables-legacy-restore --slave /sbin/iptables-save iptables-save /sbin/iptables-legacy-save

View File

@ -0,0 +1,42 @@
# 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 -it ghcr.io/w0rng/amnezia-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 -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.

119
README.md
View File

@ -1,6 +1,6 @@
# AmnewziaWG Easy
You have found the easiest way to install & manage AmneziaWG on any Linux host!
You have found the easiest way to install & manage WireGuard on any Linux host!
<p align="center">
<img src="./assets/screenshot.png" width="802" />
@ -11,29 +11,22 @@ You have found the easiest way to install & manage AmneziaWG on any Linux host!
* All-in-one: AmneziaWG + 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.
* Gravatar support or random avatars.
* Automatic Light / Dark Mode
* Multilanguage Support
* UI_TRAFFIC_STATS (default off)
* Traffic Stats (default off)
* One Time Links (default off)
* Client Expiry (default off)
* Prometheus metrics support
## Requirements
* 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.
| 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
### 1. Install Docker
@ -50,12 +43,14 @@ And log in again.
### 2. Run AmneziaWG Easy
To automatically install & run wg-easy, simply run:
```
docker run -d \
--name=amnezia-wg-easy \
-e LANGUAGE=en \
-e LANG=en \
-e WG_HOST=<🚨YOUR_SERVER_IP> \
-e PASSWORD=<🚨YOUR_ADMIN_PASSWORD> \
-e PASSWORD_HASH=<🚨YOUR_ADMIN_PASSWORD_HASH> \
-e PORT=51821 \
-e WG_PORT=51820 \
-v ~/.amnezia-wg-easy:/etc/wireguard \
@ -67,55 +62,62 @@ And log in again.
--sysctl="net.ipv4.ip_forward=1" \
--device=/dev/net/tun:/dev/net/tun \
--restart unless-stopped \
ghcr.io/spcfox/amnezia-wg-easy
ghcr.io/w0rng/amnezia-wg-easy
```
> 💡 Replace `YOUR_SERVER_IP` with your WAN IP, or a Dynamic DNS hostname.
>
> 💡 Replace `YOUR_ADMIN_PASSWORD` with a password to log in on the Web UI.
> 💡 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`.
> 💡 Your configuration files will be saved in `~/.amnezia-wg-easy`
The Prometheus metrics will now be available on `http://0.0.0.0:51821/metrics`. Grafana dashboard [21733](https://grafana.com/grafana/dashboards/21733-wireguard/)
AmneziaWG Easy can be launched with Docker Compose as well - just download
[`docker-compose.yml`](docker-compose.yml), make necessary adjustments and
execute `docker compose up --detach`.
> 💡 Your configuration files will be saved in `~/.amnezia-wg-easy`
## Options
These options can be configured by setting environment variables using `-e KEY="VALUE"` in the `docker run` command.
| Env | Default | Example | Description |
| - | - | - | - |
| `LANGUAGE` | `en` | `de` | Web UI language (Supports: en, ru, tr, no, pl, fr, de, ca, es). |
| `CHECK_UPDATE` | `true` | `false` | Check for a new version and display a notification about its availability |
| `PORT` | `51821` | `6789` | TCP port for Web UI. |
| `WEBUI_HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. |
| `PASSWORD` | - | `foobar123` | When set, requires a password when logging in to the Web UI. |
| `WG_HOST` | - | `vpn.myserver.com` | The public hostname of your VPN server. |
| `WG_DEVICE` | `eth0` | `ens6f0` | Ethernet device the AmneziaWG traffic should be forwarded through. |
| `WG_PORT` | `51820` | `12345` | The public UDP port of your VPN server. AmneziaWG will listen on that (othwise default) inside the Docker container. |
| `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](/src/config.js#L21) for the default value. |
| `WG_POST_UP` | `...` | `iptables ...` | See [config.js](/src/config.js#L22) for the default value. |
| `WG_PRE_DOWN` | `...` | - | See [config.js](/src/config.js#L29) for the default value. |
| `WG_POST_DOWN` | `...` | `iptables ...` | See [config.js](/src/config.js#L30) for the default value. |
| `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 |
| `JC` | `random` | `5` | Junk packet count — number of packets with random data that are sent before the start of the session. |
| `JMIN` | `50` | `25` | Junk packet minimum size — minimum packet size for Junk packet. That is, all randomly generated packets will have a size no smaller than Jmin. |
| `JMAX` | `1000` | `250` | Junk packet maximum size — maximum size for Junk packets. |
| `S1` | `random` | `75` | Init packet junk size — the size of random data that will be added to the init packet, the size of which is initially fixed. |
| `S2` | `random` | `75` | Response packet junk size — the size of random data that will be added to the response packet, the size of which is initially fixed. |
| `H1` | `random` | `1234567891` | Init packet magic header — the header of the first byte of the handshake. Must be < uint_max. |
| `H2` | `random` | `1234567892` | Response packet magic header — header of the first byte of the handshake response. Must be < uint_max. |
| `H3` | `random` | `1234567893` | Underload packet magic header — UnderLoad packet header. Must be < uint_max. |
| `H4` | `random` | `1234567894` | Transport packet magic header — header of the packet of the data packet. Must be < uint_max. |
| 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 |
| `DICEBEAR_TYPE` | `false` | `bottts` | see [dicebear types](https://www.dicebear.com/styles/) |
| `USE_GRAVATAR` | `false` | `true` | Use or not GRAVATAR service |
| `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. |
| `JC` | `random` | `5` | Junk packet count — number of packets with random data that are sent before the start of the session. |
| `JMIN` | `50` | `25` | Junk packet minimum size — minimum packet size for Junk packet. That is, all randomly generated packets will have a size no smaller than Jmin. |
| `JMAX` | `1000` | `250` | Junk packet maximum size — maximum size for Junk packets. |
| `S1` | `random` | `75` | Init packet junk size — the size of random data that will be added to the init packet, the size of which is initially fixed. |
| `S2` | `random` | `75` | Response packet junk size — the size of random data that will be added to the response packet, the size of which is initially fixed. |
| `H1` | `random` | `1234567891` | Init packet magic header — the header of the first byte of the handshake. Must be < uint_max. |
| `H2` | `random` | `1234567892` | Response packet magic header — header of the first byte of the handshake response. Must be < uint_max. |
| `H3` | `random` | `1234567893` | Underload packet magic header — UnderLoad packet header. Must be < uint_max. |
| `H4` | `random` | `1234567894` | Transport packet magic header — header of the packet of the data packet. Must be < uint_max. |
> If you change `WG_PORT`, make sure to also change the exposed port.
@ -126,19 +128,12 @@ To update to the latest version, simply run:
```bash
docker stop amnezia-wg-easy
docker rm amnezia-wg-easy
docker pull ghcr.io/spcfox/amnezia-wg-easy
docker pull ghcr.io/w0rng/amnezia-wg-easy
```
And then run the `docker run -d \ ...` command above again.
With Docker Compose AmneziaWG Easy can be updated with a single command:
`docker compose up --detach --pull always` (if an image tag is specified in the
Compose file and it is not `latest`, make sure that it is changed to the desired
one; by default it is omitted and
[defaults to `latest`](https://docs.docker.com/engine/reference/run/#image-references)). \
The WireGuared Easy container will be automatically recreated if a newer image
was pulled.
## Thanks
Based on [wg-easy](https://github.com/wg-easy/wg-easy) by Emile Nijssen.
Based on [wg-easy](https://github.com/wg-easy/wg-easy) by Emile Nijssen.
Use integrations with AmneziaWg from [amnezia-wg-easy](https://github.com/spcfox/amnezia-wg-easy) by Viktor Yudov.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -1,23 +1,24 @@
volumes:
etc_amneziawg:
etc_wireguard:
services:
amnezia-wg-easy:
env_file:
- .env
image: ghcr.io/spcfox/amnezia-wg-easy
image: ghcr.io/w0rng/amnezia-wg-easy
container_name: amnezia-wg-easy
volumes:
- ~/.amnezia-wg-easy:/etc/wireguard
- etc_wireguard:/etc/wireguard
ports:
- "${WG_PORT}:51820/udp"
- "${WG_PORT}:${WG_PORT}/udp"
- "${PORT}:${PORT}/tcp"
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
# - NET_RAW # ⚠️ Uncomment if using Podman
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
devices:
- /dev/net/tun:/dev/net/tun
- /dev/net/tun:/dev/net/tun

View File

@ -1,4 +1,16 @@
{
"1": "Initial version. Enjoy!",
"2": "UI_TRAFFIC_STATS, UI_CHART_TYPE, other upgrades from wg-easy and bugfixes."
"2": "You can now rename a client & update the address. Enjoy!",
"3": "Many improvements and small changes. Enjoy!",
"4": "Now with pretty charts for client's network speed. Enjoy!",
"5": "Many small improvements & feature requests. Enjoy!",
"6": "Many small performance improvements & bug fixes. Enjoy!",
"7": "Improved the look & performance of the upload/download chart.",
"8": "Updated to Node.js v18.",
"9": "Fixed issue running on devices with older kernels.",
"10": "Added sessionless HTTP API auth & automatic dark mode.",
"11": "Multilanguage Support & various bugfixes.",
"12": "UI_TRAFFIC_STATS, Import json configurations with no PreShared-Key, allow clients with no privateKey & more.",
"13": "New framework (h3), UI_CHART_TYPE, some bugfixes & more.",
"14": "Home Assistent support, PASSWORD_HASH (inc. Helper), translation updates bugfixes & more."
}

6
package-lock.json generated
View File

@ -1,11 +1,11 @@
{
"name": "amnezia-wg-easy",
"version": "1.0.0",
"name": "wg-easy",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"version": "1.0.0"
"version": "1.0.1"
}
}
}

View File

@ -1,7 +1,10 @@
{
"version": "1.0.0",
"version": "1.0.1",
"scripts": {
"build": "DOCKER_BUILDKIT=1 docker build --tag amnezia-wg-easy .",
"start": "docker run --env WG_HOST=0.0.0.0 --name amnezia-wg-easy --device=/dev/net/tun:/dev/net/tun --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 amnezia-wg-easy"
"sudobuild": "DOCKER_BUILDKIT=1 sudo docker build --tag wg-easy .",
"build": "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"
}
}

View File

@ -1,16 +1,17 @@
'use strict';
const { release } = require('./package.json');
const { release: { version } } = require('./package.json');
module.exports.CHECK_UPDATE = process.env.CHECK_UPDATE ? process.env.CHECK_UPDATE.toLowerCase() === 'true' : true;
module.exports.RELEASE = release;
module.exports.RELEASE = version;
module.exports.PORT = process.env.PORT || '51821';
module.exports.WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0';
module.exports.PASSWORD = process.env.PASSWORD;
module.exports.PASSWORD_HASH = process.env.PASSWORD_HASH;
module.exports.MAX_AGE = parseInt(process.env.MAX_AGE, 10) * 1000 * 60 || 0;
module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/';
module.exports.WG_DEVICE = process.env.WG_DEVICE || 'eth0';
module.exports.WG_HOST = process.env.WG_HOST;
module.exports.WG_PORT = process.env.WG_PORT || '51820';
module.exports.WG_CONFIG_PORT = process.env.WG_CONFIG_PORT || process.env.WG_PORT || '51820';
module.exports.WG_MTU = process.env.WG_MTU || null;
module.exports.WG_PERSISTENT_KEEPALIVE = process.env.WG_PERSISTENT_KEEPALIVE || '0';
module.exports.WG_DEFAULT_ADDRESS = process.env.WG_DEFAULT_ADDRESS || '10.8.0.x';
@ -34,9 +35,17 @@ iptables -D INPUT -p udp -m udp --dport ${module.exports.WG_PORT} -j ACCEPT;
iptables -D FORWARD -i wg0 -j ACCEPT;
iptables -D FORWARD -o wg0 -j ACCEPT;
`.split('\n').join(' ');
module.exports.LANG = process.env.LANGUAGE || 'en';
module.exports.LANG = process.env.LANG || 'en';
module.exports.UI_TRAFFIC_STATS = process.env.UI_TRAFFIC_STATS || 'false';
module.exports.UI_CHART_TYPE = process.env.UI_CHART_TYPE || 0;
module.exports.WG_ENABLE_ONE_TIME_LINKS = process.env.WG_ENABLE_ONE_TIME_LINKS || 'false';
module.exports.UI_ENABLE_SORT_CLIENTS = process.env.UI_ENABLE_SORT_CLIENTS || 'false';
module.exports.WG_ENABLE_EXPIRES_TIME = process.env.WG_ENABLE_EXPIRES_TIME || 'false';
module.exports.ENABLE_PROMETHEUS_METRICS = process.env.ENABLE_PROMETHEUS_METRICS || 'false';
module.exports.PROMETHEUS_METRICS_PASSWORD = process.env.PROMETHEUS_METRICS_PASSWORD;
module.exports.DICEBEAR_TYPE = process.env.DICEBEAR_TYPE || false;
module.exports.USE_GRAVATAR = process.env.USE_GRAVATAR || false;
const getRandomInt = (min, max) => min + Math.floor(Math.random() * (max - min));
const getRandomJunkSize = () => getRandomInt(15, 150);

View File

@ -1,6 +1,8 @@
'use strict';
const bcrypt = require('bcryptjs');
const crypto = require('node:crypto');
const basicAuth = require('basic-auth');
const { createServer } = require('node:http');
const { stat, readFile } = require('node:fs/promises');
const { resolve, sep } = require('node:path');
@ -24,16 +26,50 @@ const {
const WireGuard = require('../services/WireGuard');
const {
CHECK_UPDATE,
PORT,
WEBUI_HOST,
RELEASE,
PASSWORD,
PASSWORD_HASH,
MAX_AGE,
LANG,
UI_TRAFFIC_STATS,
UI_CHART_TYPE,
WG_ENABLE_ONE_TIME_LINKS,
UI_ENABLE_SORT_CLIENTS,
WG_ENABLE_EXPIRES_TIME,
ENABLE_PROMETHEUS_METRICS,
PROMETHEUS_METRICS_PASSWORD,
DICEBEAR_TYPE,
USE_GRAVATAR,
} = require('../config');
const requiresPassword = !!PASSWORD_HASH;
const requiresPrometheusPassword = !!PROMETHEUS_METRICS_PASSWORD;
/**
* Checks if `password` matches the PASSWORD_HASH.
*
* If environment variable is not set, the password is always invalid.
*
* @param {string} password String to test
* @returns {boolean} true if matching environment, otherwise false
*/
const isPasswordValid = (password, hash) => {
if (typeof password !== 'string') {
return false;
}
if (hash) {
return bcrypt.compareSync(password, hash);
}
return false;
};
const cronJobEveryMinute = async () => {
await WireGuard.cronJobEveryMinute();
setTimeout(cronJobEveryMinute, 60 * 1000);
};
module.exports = class Server {
constructor() {
@ -55,19 +91,19 @@ module.exports = class Server {
return RELEASE;
}))
.get('/api/check-update', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return CHECK_UPDATE;
}))
.get('/api/lang', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `"${LANG}"`;
}))
.get('/api/remember-me', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return MAX_AGE > 0;
}))
.get('/api/ui-traffic-stats', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return UI_TRAFFIC_STATS;
return `${UI_TRAFFIC_STATS}`;
}))
.get('/api/ui-chart-type', defineEventHandler((event) => {
@ -75,9 +111,31 @@ module.exports = class Server {
return `"${UI_CHART_TYPE}"`;
}))
.get('/api/wg-enable-one-time-links', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `${WG_ENABLE_ONE_TIME_LINKS}`;
}))
.get('/api/ui-sort-clients', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `${UI_ENABLE_SORT_CLIENTS}`;
}))
.get('/api/wg-enable-expire-time', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `${WG_ENABLE_EXPIRES_TIME}`;
}))
.get('/api/ui-avatar-settings', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return {
dicebear: DICEBEAR_TYPE,
gravatar: USE_GRAVATAR,
}
}))
// Authentication
.get('/api/session', defineEventHandler((event) => {
const requiresPassword = !!process.env.PASSWORD;
const authenticated = requiresPassword
? !!(event.node.req.session && event.node.req.session.authenticated)
: true;
@ -87,35 +145,58 @@ module.exports = class Server {
authenticated,
};
}))
.get('/cnf/:clientOneTimeLink', defineEventHandler(async (event) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') {
throw createError({
status: 404,
message: 'Invalid state',
});
}
const clientOneTimeLink = getRouterParam(event, 'clientOneTimeLink');
const clients = await WireGuard.getClients();
const client = clients.find((client) => client.oneTimeLink === clientOneTimeLink);
if (!client) return;
const clientId = client.id;
const config = await WireGuard.getClientConfiguration({ clientId });
await WireGuard.eraseOneTimeLink({ clientId });
setHeader(event, 'Content-Disposition', `attachment; filename="${clientOneTimeLink}.conf"`);
setHeader(event, 'Content-Type', 'text/plain');
return config;
}))
.post('/api/session', defineEventHandler(async (event) => {
const { password } = await readBody(event);
const { password, remember } = await readBody(event);
if (typeof password !== 'string') {
if (!requiresPassword) {
// if no password is required, the API should never be called.
// Do not automatically authenticate the user.
throw createError({
status: 401,
message: 'Missing: Password',
message: 'Invalid state',
});
}
if (password !== PASSWORD) {
if (!isPasswordValid(password, PASSWORD_HASH)) {
throw createError({
status: 401,
message: 'Incorrect Password',
});
}
if (MAX_AGE && remember) {
event.node.req.session.cookie.maxAge = MAX_AGE;
}
event.node.req.session.authenticated = true;
event.node.req.session.save();
debug(`New Session: ${event.node.req.session.id}`);
return { succcess: true };
return { success: true };
}));
// WireGuard
app.use(
fromNodeMiddleware((req, res, next) => {
if (!PASSWORD || !req.url.startsWith('/api/')) {
if (!requiresPassword || !req.url.startsWith('/api/')) {
return next();
}
@ -123,6 +204,15 @@ module.exports = class Server {
return next();
}
if (req.url.startsWith('/api/') && req.headers['authorization']) {
if (isPasswordValid(req.headers['authorization'], PASSWORD_HASH)) {
return next();
}
return res.status(401).json({
error: 'Incorrect Password',
});
}
return res.status(401).json({
error: 'Not Logged In',
});
@ -165,7 +255,8 @@ module.exports = class Server {
}))
.post('/api/wireguard/client', defineEventHandler(async (event) => {
const { name } = await readBody(event);
await WireGuard.createClient({ name });
const { expiredDate } = await readBody(event);
await WireGuard.createClient({ name, expiredDate });
return { success: true };
}))
.delete('/api/wireguard/client/:clientId', defineEventHandler(async (event) => {
@ -181,6 +272,20 @@ module.exports = class Server {
await WireGuard.enableClient({ clientId });
return { success: true };
}))
.post('/api/wireguard/client/:clientId/generateOneTimeLink', defineEventHandler(async (event) => {
if (WG_ENABLE_ONE_TIME_LINKS === 'false') {
throw createError({
status: 404,
message: 'Invalid state',
});
}
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
await WireGuard.generateOneTimeLink({ clientId });
return { success: true };
}))
.post('/api/wireguard/client/:clientId/disable', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
@ -206,6 +311,15 @@ module.exports = class Server {
const { address } = await readBody(event);
await WireGuard.updateClientAddress({ clientId, address });
return { success: true };
}))
.put('/api/wireguard/client/:clientId/expireDate', defineEventHandler(async (event) => {
const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
throw createError({ status: 403 });
}
const { expireDate } = await readBody(event);
await WireGuard.updateClientExpireDate({ clientId, expireDate });
return { success: true };
}));
const safePathJoin = (base, target) => {
@ -231,6 +345,67 @@ module.exports = class Server {
});
};
// Check Prometheus credentials
app.use(
fromNodeMiddleware((req, res, next) => {
if (!requiresPrometheusPassword || !req.url.startsWith('/metrics')) {
return next();
}
const user = basicAuth(req);
if (!user) {
res.statusCode = 401;
return { error: 'Not Logged In' };
}
if (user.pass) {
if (isPasswordValid(user.pass, PROMETHEUS_METRICS_PASSWORD)) {
return next();
}
res.statusCode = 401;
return { error: 'Incorrect Password' };
}
res.statusCode = 401;
return { error: 'Not Logged In' };
}),
);
// Prometheus Metrics API
const routerPrometheusMetrics = createRouter();
app.use(routerPrometheusMetrics);
// Prometheus Routes
routerPrometheusMetrics
.get('/metrics', defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'text/plain');
if (ENABLE_PROMETHEUS_METRICS === 'true') {
return WireGuard.getMetrics();
}
return '';
}))
.get('/metrics/json', defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
if (ENABLE_PROMETHEUS_METRICS === 'true') {
return WireGuard.getMetricsJSON();
}
return '';
}));
// backup_restore
const router3 = createRouter();
app.use(router3);
router3
.get('/api/wireguard/backup', defineEventHandler(async (event) => {
const config = await WireGuard.backupConfiguration();
setHeader(event, 'Content-Disposition', 'attachment; filename="wg0.json"');
setHeader(event, 'Content-Type', 'text/json');
return config;
}))
.put('/api/wireguard/restore', defineEventHandler(async (event) => {
const { file } = await readBody(event);
await WireGuard.restoreConfiguration(file);
return { success: true };
}));
// Static assets
const publicDir = '/app/www';
app.use(
@ -265,6 +440,8 @@ module.exports = class Server {
createServer(toNodeListener(app)).listen(PORT, WEBUI_HOST);
debug(`Listening on http://${WEBUI_HOST}:${PORT}`);
cronJobEveryMinute();
}
};

View File

@ -5,6 +5,7 @@ const path = require('path');
const debug = require('debug')('WireGuard');
const crypto = require('node:crypto');
const QRCode = require('qrcode');
const CRC32 = require('crc-32');
const Util = require('./Util');
const ServerError = require('./ServerError');
@ -13,6 +14,7 @@ const {
WG_PATH,
WG_HOST,
WG_PORT,
WG_CONFIG_PORT,
WG_MTU,
WG_DEFAULT_DNS,
WG_DEFAULT_ADDRESS,
@ -22,6 +24,8 @@ const {
WG_POST_UP,
WG_PRE_DOWN,
WG_POST_DOWN,
WG_ENABLE_EXPIRES_TIME,
WG_ENABLE_ONE_TIME_LINKS,
JC,
JMIN,
JMAX,
@ -35,65 +39,69 @@ const {
module.exports = class WireGuard {
async __buildConfig() {
this.__configPromise = Promise.resolve().then(async () => {
if (!WG_HOST) {
throw new Error('WG_HOST Environment Variable Not Set!');
}
debug('Loading configuration...');
let config;
try {
config = await fs.readFile(path.join(WG_PATH, 'wg0.json'), 'utf8');
config = JSON.parse(config);
debug('Configuration loaded.');
} catch (err) {
const privateKey = await Util.exec('wg genkey');
const publicKey = await Util.exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey',
});
const address = WG_DEFAULT_ADDRESS.replace('x', '1');
config = {
server: {
privateKey,
publicKey,
address,
jc: JC,
jmin: JMIN,
jmax: JMAX,
s1: S1,
s2: S2,
h1: H1,
h2: H2,
h3: H3,
h4: H4,
},
clients: {},
};
debug('Configuration generated.');
}
return config;
});
return this.__configPromise;
}
async getConfig() {
if (!this.__configPromise) {
this.__configPromise = Promise.resolve().then(async () => {
if (!WG_HOST) {
throw new Error('WG_HOST Environment Variable Not Set!');
const config = await this.__buildConfig();
await this.__saveConfig(config);
await Util.exec('wg-quick down wg0').catch(() => {});
await Util.exec('wg-quick up wg0').catch((err) => {
if (err && err.message && err.message.includes('Cannot find device "wg0"')) {
throw new Error('WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!');
}
debug('Loading configuration...');
let config;
try {
config = await fs.readFile(path.join(WG_PATH, 'wg0.json'), 'utf8');
config = JSON.parse(config);
debug('Configuration loaded.');
} catch (err) {
const privateKey = await Util.exec('wg genkey');
const publicKey = await Util.exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey',
});
const address = WG_DEFAULT_ADDRESS.replace('x', '1');
config = {
server: {
privateKey,
publicKey,
address,
jc: JC,
jmin: JMIN,
jmax: JMAX,
s1: S1,
s2: S2,
h1: H1,
h2: H2,
h3: H3,
h4: H4,
},
clients: {},
};
debug('Configuration generated.');
}
await this.__saveConfig(config);
await Util.exec('wg-quick down wg0').catch(() => { });
await Util.exec('wg-quick up wg0').catch((err) => {
if (err && err.message && err.message.includes('Cannot find device "wg0"')) {
throw new Error('WireGuard exited with the error: Cannot find device "wg0"\nThis usually means that your host\'s kernel does not support WireGuard!');
}
throw err;
});
// await Util.exec(`iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ' + WG_DEVICE + ' -j MASQUERADE`);
// await Util.exec('iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT');
// await Util.exec('iptables -A FORWARD -i wg0 -j ACCEPT');
// await Util.exec('iptables -A FORWARD -o wg0 -j ACCEPT');
await this.__syncConfig();
return config;
throw err;
});
// await Util.exec(`iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ' + WG_DEVICE + ' -j MASQUERADE`);
// await Util.exec('iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT');
// await Util.exec('iptables -A FORWARD -i wg0 -j ACCEPT');
// await Util.exec('iptables -A FORWARD -o wg0 -j ACCEPT');
await this.__syncConfig();
}
return this.__configPromise;
@ -128,7 +136,6 @@ H1 = ${config.server.h1}
H2 = ${config.server.h2}
H3 = ${config.server.h3}
H4 = ${config.server.h4}
Jc = ${config.server.jc}
`;
for (const [clientId, client] of Object.entries(config.clients)) {
@ -169,12 +176,18 @@ ${client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
publicKey: client.publicKey,
createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt),
expiredAt: client.expiredAt !== null
? new Date(client.expiredAt)
: null,
allowedIPs: client.allowedIPs,
oneTimeLink: client.oneTimeLink ?? null,
oneTimeLinkExpiresAt: client.oneTimeLinkExpiresAt ?? null,
downloadableConfig: 'privateKey' in client,
persistentKeepalive: null,
latestHandshakeAt: null,
transferRx: null,
transferTx: null,
endpoint: null,
}));
// Loop WireGuard status
@ -203,6 +216,7 @@ ${client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
client.latestHandshakeAt = latestHandshakeAt === '0'
? null
: new Date(Number(`${latestHandshakeAt}000`));
client.endpoint = endpoint === '(none)' ? null : endpoint;
client.transferRx = Number(transferRx);
client.transferTx = Number(transferTx);
client.persistentKeepalive = persistentKeepalive;
@ -228,7 +242,7 @@ ${client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
return `
[Interface]
PrivateKey = ${client.privateKey ? `${client.privateKey}` : 'REPLACE_ME'}
Address = ${client.address}
Address = ${client.address}/24
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\
${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\
Jc = ${config.server.jc}
@ -246,7 +260,7 @@ PublicKey = ${config.server.publicKey}
${client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
}AllowedIPs = ${WG_ALLOWED_IPS}
PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE}
Endpoint = ${WG_HOST}:${WG_PORT}`;
Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
}
async getClientQRCodeSVG({ clientId }) {
@ -257,7 +271,7 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
});
}
async createClient({ name }) {
async createClient({ name, expiredDate }) {
if (!name) {
throw new Error('Missing: Name');
}
@ -265,7 +279,9 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
const config = await this.getConfig();
const privateKey = await Util.exec('wg genkey');
const publicKey = await Util.exec(`echo ${privateKey} | wg pubkey`);
const publicKey = await Util.exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey',
});
const preSharedKey = await Util.exec('wg genpsk');
// Calculate next IP
@ -284,7 +300,6 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
if (!address) {
throw new Error('Maximum number of clients reached.');
}
// Create Client
const id = crypto.randomUUID();
const client = {
@ -297,10 +312,15 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
createdAt: new Date(),
updatedAt: new Date(),
expiredAt: null,
enabled: true,
};
if (expiredDate) {
client.expiredAt = new Date(expiredDate);
client.expiredAt.setHours(23);
client.expiredAt.setMinutes(59);
client.expiredAt.setSeconds(59);
}
config.clients[id] = client;
await this.saveConfig();
@ -326,6 +346,23 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
await this.saveConfig();
}
async generateOneTimeLink({ clientId }) {
const client = await this.getClient({ clientId });
const key = `${clientId}-${Math.floor(Math.random() * 1000)}`;
client.oneTimeLink = Math.abs(CRC32.str(key)).toString(16);
client.oneTimeLinkExpiresAt = new Date(Date.now() + 5 * 60 * 1000);
client.updatedAt = new Date();
await this.saveConfig();
}
async eraseOneTimeLink({ clientId }) {
const client = await this.getClient({ clientId });
// client.oneTimeLink = null;
client.oneTimeLinkExpiresAt = new Date(Date.now() + 10 * 1000);
client.updatedAt = new Date();
await this.saveConfig();
}
async disableClient({ clientId }) {
const client = await this.getClient({ clientId });
@ -357,9 +394,149 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
await this.saveConfig();
}
async updateClientExpireDate({ clientId, expireDate }) {
const client = await this.getClient({ clientId });
if (expireDate) {
client.expiredAt = new Date(expireDate);
client.expiredAt.setHours(23);
client.expiredAt.setMinutes(59);
client.expiredAt.setSeconds(59);
} else {
client.expiredAt = null;
}
client.updatedAt = new Date();
await this.saveConfig();
}
async __reloadConfig() {
await this.__buildConfig();
await this.__syncConfig();
}
async restoreConfiguration(config) {
debug('Starting configuration restore process.');
const _config = JSON.parse(config);
await this.__saveConfig(_config);
await this.__reloadConfig();
debug('Configuration restore process completed.');
}
async backupConfiguration() {
debug('Starting configuration backup.');
const config = await this.getConfig();
const backup = JSON.stringify(config, null, 2);
debug('Configuration backup completed.');
return backup;
}
// Shutdown wireguard
async Shutdown() {
await Util.exec('wg-quick down wg0').catch(() => { });
await Util.exec('wg-quick down wg0').catch(() => {});
}
async cronJobEveryMinute() {
const config = await this.getConfig();
let needSaveConfig = false;
// Expires Feature
if (WG_ENABLE_EXPIRES_TIME === 'true') {
for (const client of Object.values(config.clients)) {
if (client.enabled !== true) continue;
if (client.expiredAt !== null && new Date() > new Date(client.expiredAt)) {
debug(`Client ${client.id} expired.`);
needSaveConfig = true;
client.enabled = false;
client.updatedAt = new Date();
}
}
}
// One Time Link Feature
if (WG_ENABLE_ONE_TIME_LINKS === 'true') {
for (const client of Object.values(config.clients)) {
if (client.oneTimeLink !== null && new Date() > new Date(client.oneTimeLinkExpiresAt)) {
debug(`Client ${client.id} One Time Link expired.`);
needSaveConfig = true;
client.oneTimeLink = null;
client.oneTimeLinkExpiresAt = null;
client.updatedAt = new Date();
}
}
}
if (needSaveConfig) {
await this.saveConfig();
}
}
async getMetrics() {
const clients = await this.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
let wireguardSentBytes = '';
let wireguardReceivedBytes = '';
let wireguardLatestHandshakeSeconds = '';
for (const client of Object.values(clients)) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (client.endpoint !== null) {
wireguardConnectedPeersCount++;
}
wireguardSentBytes += `wireguard_sent_bytes{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${Number(client.transferTx)}\n`;
wireguardReceivedBytes += `wireguard_received_bytes{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${Number(client.transferRx)}\n`;
wireguardLatestHandshakeSeconds += `wireguard_latest_handshake_seconds{interface="wg0",enabled="${client.enabled}",address="${client.address}",name="${client.name}"} ${client.latestHandshakeAt ? (new Date().getTime() - new Date(client.latestHandshakeAt).getTime()) / 1000 : 0}\n`;
}
let returnText = '# HELP wg-easy and wireguard metrics\n';
returnText += '\n# HELP wireguard_configured_peers\n';
returnText += '# TYPE wireguard_configured_peers gauge\n';
returnText += `wireguard_configured_peers{interface="wg0"} ${Number(wireguardPeerCount)}\n`;
returnText += '\n# HELP wireguard_enabled_peers\n';
returnText += '# TYPE wireguard_enabled_peers gauge\n';
returnText += `wireguard_enabled_peers{interface="wg0"} ${Number(wireguardEnabledPeersCount)}\n`;
returnText += '\n# HELP wireguard_connected_peers\n';
returnText += '# TYPE wireguard_connected_peers gauge\n';
returnText += `wireguard_connected_peers{interface="wg0"} ${Number(wireguardConnectedPeersCount)}\n`;
returnText += '\n# HELP wireguard_sent_bytes Bytes sent to the peer\n';
returnText += '# TYPE wireguard_sent_bytes counter\n';
returnText += `${wireguardSentBytes}`;
returnText += '\n# HELP wireguard_received_bytes Bytes received from the peer\n';
returnText += '# TYPE wireguard_received_bytes counter\n';
returnText += `${wireguardReceivedBytes}`;
returnText += '\n# HELP wireguard_latest_handshake_seconds UNIX timestamp seconds of the last handshake\n';
returnText += '# TYPE wireguard_latest_handshake_seconds gauge\n';
returnText += `${wireguardLatestHandshakeSeconds}`;
return returnText;
}
async getMetricsJSON() {
const clients = await this.getClients();
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
for (const client of Object.values(clients)) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
}
if (client.endpoint !== null) {
wireguardConnectedPeersCount++;
}
}
return {
wireguard_configured_peers: Number(wireguardPeerCount),
wireguard_enabled_peers: Number(wireguardEnabledPeersCount),
wireguard_connected_peers: Number(wireguardConnectedPeersCount),
};
}
};

199
src/package-lock.json generated
View File

@ -1,23 +1,27 @@
{
"name": "amnezia-wg-easy",
"version": "1.0.0",
"name": "wg-easy",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "amnezia-wg-easy",
"version": "1.0.0",
"license": "GPL",
"name": "wg-easy",
"version": "1.0.1",
"license": "CC BY-NC-SA 4.0",
"dependencies": {
"debug": "^4.3.6",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"crc-32": "^1.2.2",
"debug": "^4.3.7",
"express-session": "^1.18.0",
"h3": "^1.12.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"eslint-config-athom": "^3.1.3",
"nodemon": "^3.1.4",
"tailwindcss": "^3.4.9"
"tailwindcss": "^3.4.10"
},
"engines": {
"node": ">=18"
@ -450,6 +454,26 @@
"node": ">=14"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
"integrity": "sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mini-svg-data-uri": "^1.2.3"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -976,6 +1000,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.1.2"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -1202,6 +1244,18 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -1299,12 +1353,11 @@
}
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
"license": "MIT",
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "2.1.2"
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@ -1742,9 +1795,9 @@
}
},
"node_modules/eslint-module-utils": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz",
"integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.9.0.tgz",
"integrity": "sha512-McVbYmwA3NEKwRQY5g4aWMdcZE5xZxV8i8l7CqJSrameuGSQJtSWaL/LxTEzSKKaCcOhlpDR8XEfYXWPrdo/ZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1803,27 +1856,28 @@
}
},
"node_modules/eslint-plugin-import": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz",
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz",
"integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-includes": "^3.1.7",
"array.prototype.findlastindex": "^1.2.3",
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
"array.prototype.findlastindex": "^1.2.5",
"array.prototype.flat": "^1.3.2",
"array.prototype.flatmap": "^1.3.2",
"debug": "^3.2.7",
"doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.8.0",
"hasown": "^2.0.0",
"is-core-module": "^2.13.1",
"eslint-module-utils": "^2.9.0",
"hasown": "^2.0.2",
"is-core-module": "^2.15.1",
"is-glob": "^4.0.3",
"minimatch": "^3.1.2",
"object.fromentries": "^2.0.7",
"object.groupby": "^1.0.1",
"object.values": "^1.1.7",
"object.fromentries": "^2.0.8",
"object.groupby": "^1.0.3",
"object.values": "^1.2.0",
"semver": "^6.3.1",
"tsconfig-paths": "^3.15.0"
},
@ -2152,6 +2206,26 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express-session/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2815,9 +2889,9 @@
}
},
"node_modules/is-core-module": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz",
"integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==",
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
"integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3216,9 +3290,9 @@
}
},
"node_modules/micromatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3241,6 +3315,16 @@
"node": ">=10.0.0"
}
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
"dev": true,
"license": "MIT",
"bin": {
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -3275,10 +3359,9 @@
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/mz": {
"version": "2.7.0",
@ -3697,9 +3780,9 @@
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"dev": true,
"license": "ISC"
},
@ -3756,9 +3839,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.41",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
"integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
"version": "8.4.45",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz",
"integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==",
"dev": true,
"funding": [
{
@ -4201,23 +4284,9 @@
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-regex-test": {
@ -4680,9 +4749,9 @@
"peer": true
},
"node_modules/tailwindcss": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
"integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
"integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5127,9 +5196,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
"integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
"dev": true,
"license": "ISC",
"bin": {

View File

@ -1,8 +1,10 @@
{
"release": "2",
"name": "amnezia-wg-easy",
"version": "1.0.0",
"description": "The easiest way to run AmneziaWG VPN + Web-based Admin UI.",
"release": {
"version": "14"
},
"name": "wg-easy",
"version": "1.0.1",
"description": "The easiest way to run WireGuard VPN + Web-based Admin UI.",
"main": "server.js",
"scripts": {
"serve": "DEBUG=Server,WireGuard npx nodemon server.js",
@ -10,18 +12,22 @@
"lint": "eslint .",
"buildcss": "npx tailwindcss -i ./www/src/css/app.css -o ./www/css/app.css"
},
"author": "Viktor Yudov",
"license": "GPL",
"author": "Emile Nijssen",
"license": "CC BY-NC-SA 4.0",
"dependencies": {
"debug": "^4.3.6",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"crc-32": "^1.2.2",
"debug": "^4.3.7",
"express-session": "^1.18.0",
"h3": "^1.12.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"eslint-config-athom": "^3.1.3",
"nodemon": "^3.1.4",
"tailwindcss": "^3.4.9"
"tailwindcss": "^3.4.10"
},
"nodemonConfig": {
"ignore": [

82
src/wgpw.mjs Normal file
View File

@ -0,0 +1,82 @@
'use strict';
// 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);
// eslint-disable-next-line no-console
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) {
// eslint-disable-next-line no-console
console.log('Password matches the hash !');
} else {
// eslint-disable-next-line no-console
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) {
// eslint-disable-next-line no-console
console.error(error);
// eslint-disable-next-line no-process-exit
process.exit(1);
}
})();

5
src/wgpw.sh Normal file
View File

@ -0,0 +1,5 @@
#!/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 "$@"

View File

@ -1,5 +1,5 @@
/*
! tailwindcss v3.4.9 | MIT License | https://tailwindcss.com
! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com
*/
/*
@ -714,6 +714,10 @@ video {
margin-bottom: 2.5rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
@ -798,6 +802,11 @@ video {
display: none;
}
.size-6 {
width: 1.5rem;
height: 1.5rem;
}
.h-1 {
height: 0.25rem;
}
@ -842,6 +851,10 @@ video {
min-height: 100vh;
}
.w-1 {
width: 0.25rem;
}
.w-10 {
width: 2.5rem;
}
@ -1037,6 +1050,16 @@ video {
border-radius: 0.375rem;
}
.rounded-l-full {
border-top-left-radius: 9999px;
border-bottom-left-radius: 9999px;
}
.rounded-r-full {
border-top-right-radius: 9999px;
border-bottom-right-radius: 9999px;
}
.border {
border-width: 1px;
}
@ -1141,6 +1164,10 @@ video {
fill: #4b5563;
}
.p-0 {
padding: 0px;
}
.p-1 {
padding: 0.25rem;
}
@ -1446,10 +1473,18 @@ video {
cursor: default;
}
.p-0 {
padding: 0;
}
.last\:border-b-0:last-child {
border-bottom-width: 0px;
}
.hover\:cursor-pointer:hover {
cursor: pointer;
}
.hover\:border-red-800:hover {
--tw-border-opacity: 1;
border-color: rgb(153 27 27 / var(--tw-border-opacity));
@ -1521,6 +1556,25 @@ video {
fill: #4b5563;
}
@media not all and (min-width: 768px) {
.max-md\:hidden {
display: none;
}
.max-md\:border-x-0 {
border-left-width: 0px;
border-right-width: 0px;
}
.max-md\:border-l-0 {
border-left-width: 0px;
}
.max-md\:border-r-0 {
border-right-width: 0px;
}
}
@media (min-width: 450px) {
.xxs\:flex-row {
flex-direction: row;
@ -1648,6 +1702,14 @@ video {
}
@media (min-width: 768px) {
.md\:mr-2 {
margin-right: 0.5rem;
}
.md\:block {
display: block;
}
.md\:inline-block {
display: inline-block;
}
@ -1656,10 +1718,18 @@ video {
min-width: 6rem;
}
.md\:flex-shrink-0 {
flex-shrink: 0;
}
.md\:gap-4 {
gap: 1rem;
}
.md\:rounded {
border-radius: 0.25rem;
}
.md\:px-0 {
padding-left: 0px;
padding-right: 0px;
@ -1783,6 +1853,11 @@ video {
color: rgb(115 115 115 / var(--tw-text-opacity));
}
.dark\:text-neutral-600:where(.dark, .dark *) {
--tw-text-opacity: 1;
color: rgb(82 82 82 / var(--tw-text-opacity));
}
.dark\:text-red-300:where(.dark, .dark *) {
--tw-text-opacity: 1;
color: rgb(252 165 165 / var(--tw-text-opacity));

BIN
src/www/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -6,15 +6,19 @@
<meta charset="utf-8"/>
<link href="./css/app.css" rel="stylesheet">
<link rel="manifest" href="./manifest.json">
<link rel="icon" href="img/favicon.ico" sizes="any">
<link rel="icon" href="./img/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="./img/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
</head>
<style>
[v-cloak] {
display: none;
}
.line-chart .apexcharts-svg{
transform: translateY(3px);
}
</style>
<body class="bg-gray-50 dark:bg-neutral-800">
@ -39,7 +43,7 @@
<path stroke-linecap="round" stroke-linejoin="round"
d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"
class="w-5 h-5 fill-gray-600 dark:fill-neutral-400">
<path
d="M12,2.2c-5.4,0-9.8,4.4-9.8,9.8s4.4,9.8,9.8,9.8s9.8-4.4,9.8-9.8S17.4,2.2,12,2.2z M3.8,12c0-4.5,3.7-8.2,8.2-8.2v16.5C7.5,20.2,3.8,16.5,3.8,12z" />
@ -78,7 +82,7 @@
<p>{{latestRelease.changelog}}</p>
</div>
<a href="https://github.com/spcfox/amnezia-wg-easy#updating" target="_blank"
<a href="https://github.com/wg-easy/wg-easy#updating" target="_blank"
class="p-3 rounded-md bg-white dark:bg-red-100 float-right font-sm font-semibold text-red-800 dark:text-red-600 flex-shrink-0 border-2 border-red-800 dark:border-red-600 hover:border-white dark:hover:border-red-600 hover:text-white dark:hover:text-red-100 hover:bg-red-800 dark:hover:bg-red-600 transition-all">
{{$t("update")}} →
</a>
@ -90,15 +94,47 @@
<div class="flex-grow">
<p class="text-2xl font-medium dark:text-neutral-200">{{$t("clients")}}</p>
</div>
<div class="flex-shrink-0">
<button @click="clientCreate = true; clientCreateName = '';"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded inline-flex items-center transition">
<svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
<div class="flex md:block md:flex-shrink-0">
<!-- Restore configuration -->
<label for="inputRC" :title="$t('titleRestoreConfig')"
class="hover:cursor-pointer hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-r-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded-l-full md:rounded inline-flex items-center transition">
<svg inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"></path>
</svg>
<span class="max-md:hidden text-sm">{{$t("restore")}}</span>
<input id="inputRC" type="file" name="configurationfile" accept="text/*,.json" @change="restoreConfig" class="hidden"/>
</label>
<!-- Backup configuration -->
<a href="./api/wireguard/backup" :title="$t('titleBackupConfig')"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 md:rounded inline-flex items-center transition">
<svg inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"></path>
</svg>
<span class="max-md:hidden text-sm">{{$t("backup")}}</span>
</a>
<!-- Sort client -->
<button v-if="enableSortClient" @click="sortClient = !sortClient;"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-x-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 md:rounded inline-flex items-center transition">
<svg v-if="sortClient === true" inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path d="M12 19.75C11.9015 19.7504 11.8038 19.7312 11.7128 19.6934C11.6218 19.6557 11.5392 19.6001 11.47 19.53L5.47 13.53C5.33752 13.3878 5.2654 13.1997 5.26882 13.0054C5.27225 12.8111 5.35096 12.6258 5.48838 12.4883C5.62579 12.3509 5.81118 12.2722 6.00548 12.2688C6.19978 12.2654 6.38782 12.3375 6.53 12.47L12 17.94L17.47 12.47C17.6122 12.3375 17.8002 12.2654 17.9945 12.2688C18.1888 12.2722 18.3742 12.3509 18.5116 12.4883C18.649 12.6258 18.7277 12.8111 18.7312 13.0054C18.7346 13.1997 18.6625 13.3878 18.53 13.53L12.53 19.53C12.4608 19.6001 12.3782 19.6557 12.2872 19.6934C12.1962 19.7312 12.0985 19.7504 12 19.75Z" fill="#000000"/>
<path d="M12 19.75C11.8019 19.7474 11.6126 19.6676 11.4725 19.5275C11.3324 19.3874 11.2526 19.1981 11.25 19V5C11.25 4.80109 11.329 4.61032 11.4697 4.46967C11.6103 4.32902 11.8011 4.25 12 4.25C12.1989 4.25 12.3897 4.32902 12.5303 4.46967C12.671 4.61032 12.75 4.80109 12.75 5V19C12.7474 19.1981 12.6676 19.3874 12.5275 19.5275C12.3874 19.6676 12.1981 19.7474 12 19.75Z" fill="#000000"/>
</svg>
<svg v-if="sortClient === false" inline class="w-4 md:mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path d="M18 11.75C17.9015 11.7505 17.8038 11.7313 17.7128 11.6935C17.6218 11.6557 17.5392 11.6001 17.47 11.53L12 6.06001L6.53 11.53C6.38782 11.6625 6.19978 11.7346 6.00548 11.7312C5.81118 11.7278 5.62579 11.649 5.48838 11.5116C5.35096 11.3742 5.27225 11.1888 5.26882 10.9945C5.2654 10.8002 5.33752 10.6122 5.47 10.47L11.47 4.47001C11.6106 4.32956 11.8012 4.25067 12 4.25067C12.1987 4.25067 12.3894 4.32956 12.53 4.47001L18.53 10.47C18.6705 10.6106 18.7493 10.8013 18.7493 11C18.7493 11.1988 18.6705 11.3894 18.53 11.53C18.4608 11.6001 18.3782 11.6557 18.2872 11.6935C18.1962 11.7313 18.0985 11.7505 18 11.75Z" fill="#000000"/>
<path d="M12 19.75C11.8019 19.7474 11.6126 19.6676 11.4725 19.5275C11.3324 19.3874 11.2526 19.1981 11.25 19V5C11.25 4.80109 11.329 4.61032 11.4697 4.46967C11.6103 4.32902 11.8011 4.25 12 4.25C12.1989 4.25 12.3897 4.32902 12.5303 4.46967C12.671 4.61032 12.75 4.80109 12.75 5V19C12.7474 19.1981 12.6676 19.3874 12.5275 19.5275C12.3874 19.6676 12.1981 19.7474 12 19.75Z" fill="#000000"/>
</svg>
<span class="max-md:hidden text-sm">{{$t("sort")}}</span>
</button>
<!-- New client -->
<button @click="clientCreate = true; clientCreateName = ''; clientExpiredDate = '';"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 dark:text-neutral-200 max-md:border-l-0 border-2 border-gray-100 dark:border-neutral-600 py-2 px-4 rounded-r-full md:rounded inline-flex items-center transition">
<svg class="w-4 md:mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span class="text-sm">{{$t("new")}}</span>
<span class="max-md:hidden text-sm">{{$t("new")}}</span>
</button>
</div>
</div>
@ -109,11 +145,11 @@
class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid">
<!-- Chart -->
<div v-if="uiChartType" class="absolute z-0 bottom-0 left-0 right-0 h-6" >
<div v-if="uiChartType" :class="`absolute z-0 bottom-0 left-0 right-0 h-6 ${uiChartType === 1 && 'line-chart'}`" >
<apexchart width="100%" height="100%" :options="chartOptionsTX" :series="client.transferTxSeries">
</apexchart>
</div>
<div v-if="uiChartType" class="absolute z-0 top-0 left-0 right-0 h-6" >
<div v-if="uiChartType" :class="`absolute z-0 top-0 left-0 right-0 h-6 ${uiChartType === 1 && 'line-chart'}`" >
<apexchart width="100%" height="100%" :options="chartOptionsRX" :series="client.transferRxSeries"
style="transform: scaleY(-1);">
</apexchart>
@ -203,7 +239,7 @@
</svg>
{{client.transferTxCurrent | bytes}}/s
</span>
<!-- Inline Transfer RX -->
<span v-if="!uiTrafficStats && client.transferRx" class="whitespace-nowrap" :title="$t('totalUpload') + bytes(client.transferRx)">
·
@ -220,6 +256,35 @@
{{!uiTrafficStats ? " · " : ""}}{{new Date(client.latestHandshakeAt) | timeago}}
</span>
</div>
<div v-if="enableOneTimeLinks && client.oneTimeLink !== null && client.oneTimeLink !== ''" :ref="'client-' + client.id + '-link'" class="text-gray-400 text-xs">
<a :href="'./cnf/' + client.oneTimeLink + ''">{{document.location.protocol}}//{{document.location.host}}/cnf/{{client.oneTimeLink}}</a>
</div>
<!-- Expire Date -->
<div v-show="enableExpireTime" class=" block md:inline-block pb-1 md:pb-0 text-gray-500 dark:text-neutral-400 text-xs">
<span class="group">
<!-- Show -->
<input v-show="clientEditExpireDateId === client.id" v-model="clientEditExpireDate"
v-on:keyup.enter="updateClientExpireDate(client, clientEditExpireDate); clientEditExpireDate = null; clientEditExpireDateId = null;"
v-on:keyup.escape="clientEditExpireDate = null; clientEditExpireDateId = null;"
:ref="'client-' + client.id + '-expire'"
type="text"
class="rounded border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 outline-none w-70 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500 text-xs p-0" />
<span v-show="clientEditExpireDateId !== client.id"
class="inline-block ">{{client.expiredAt | expiredDateFormat}}</span>
<!-- Edit -->
<span v-show="clientEditExpireDateId !== client.id"
@click="clientEditExpireDate = client.expiredAt ? client.expiredAt.toISOString().slice(0, 10) : 'yyyy-mm-dd'; clientEditExpireDateId = client.id; setTimeout(() => $refs['client-' + client.id + '-expire'][0].select(), 1);"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</span>
</span>
</div>
</div>
<!-- Info -->
@ -284,6 +349,23 @@
<div class="rounded-full w-4 h-4 m-1 bg-white"></div>
</div>
<!-- Show QR-->
<button :disabled="!client.downloadableConfig"
class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white': client.downloadableConfig,
'is-disabled': !client.downloadableConfig
}"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('showQR')"
@click="qrcode = `./api/wireguard/client/${client.id}/qrcode.svg`">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
</button>
<!-- Download Config -->
<a :disabled="!client.downloadableConfig"
:href="'./api/wireguard/client/' + client.id + '/configuration'"
@ -302,6 +384,22 @@
</svg>
</a>
<!-- Short OneTime Link -->
<button v-if="enableOneTimeLinks" :disabled="!client.downloadableConfig"
class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:class="{
'hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white': client.downloadableConfig,
'is-disabled': !client.downloadableConfig
}"
:title="!client.downloadableConfig ? $t('noPrivKey') : $t('OneTimeLink')"
@click="if(client.downloadableConfig) { showOneTimeLink(client); }">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.213 9.787a3.391 3.391 0 0 0-4.795 0l-3.425 3.426a3.39 3.39 0 0 0 4.795 4.794l.321-.304m-.321-4.49a3.39 3.39 0 0 0 4.795 0l3.424-3.426a3.39 3.39 0 0 0-4.794-4.795l-1.028.961"/>
</svg>
</button>
<!-- Delete -->
<button
@ -322,7 +420,7 @@
<div v-if="clients && clients.length === 0">
<p class="text-center m-10 text-gray-400 dark:text-neutral-400 text-sm">
{{$t("noClients")}}<br /><br />
<button @click="clientCreate = true; clientCreateName = '';"
<button @click="clientCreate = true; clientCreateName = ''; clientExpiredDate = '';"
class="bg-red-800 hover:bg-red-700 text-white border-2 border-none py-2 px-4 rounded inline-flex items-center transition">
<svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
@ -414,6 +512,16 @@
type="text" v-model.trim="clientCreateName" :placeholder="$t('name')" />
</p>
</div>
<div class="mt-2" v-show="enableExpireTime">
<p class="text-sm text-gray-500">
<label class="block text-gray-900 dark:text-neutral-200 text-sm font-bold mb-2" for="expireDate">
{{$t("ExpireDate")}}
</label>
<input
class="rounded p-2 border-2 dark:bg-neutral-700 dark:text-neutral-200 border-gray-100 dark:border-neutral-600 focus:border-gray-200 focus:dark:border-neutral-500 dark:placeholder:text-neutral-400 outline-none w-full"
type="date" v-model.trim="clientExpiredDate" :placeholder="$t('expireDate')" name="expireDate"/>
</p>
</div>
</div>
</div>
</div>
@ -521,9 +629,27 @@
</svg>
</div>
<input type="password" name="password" :placeholder="$t('password')" v-model="password"
<input type="password" name="password" :placeholder="$t('password')" v-model="password" autocomplete="current-password"
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" />
<!-- Remember me -->
<label v-if="rememberMeEnabled"
class="inline-block mb-5 cursor-pointer whitespace-nowrap" :title="$t('titleRememberMe')">
<input type="checkbox" class="sr-only" v-model="remember">
<div v-if="remember"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all">
<div class="rounded-full w-4 h-4 m-1 ml-5 bg-white"></div>
</div>
<div v-if="!remember"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 dark:bg-neutral-400 cursor-pointer hover:bg-gray-300 dark:hover:bg-neutral-500 transition-all">
<div class="rounded-full w-4 h-4 m-1 bg-white"></div>
</div>
<span class="text-sm">{{$t("rememberMe")}}</span>
</label>
<button v-if="authenticating"
class="bg-red-800 dark:bg-red-800 w-full rounded shadow py-2 text-sm text-white dark:text-white cursor-not-allowed">
<svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
@ -570,4 +696,4 @@
<script src="./js/app.js"></script>
</body>
</html>
</html>

View File

@ -29,13 +29,6 @@ class API {
return json;
}
async getCheckUpdate() {
return this.call({
method: 'get',
path: '/check-update',
});
}
async getRelease() {
return this.call({
method: 'get',
@ -50,6 +43,13 @@ class API {
});
}
async getRememberMeEnabled() {
return this.call({
method: 'get',
path: '/remember-me',
});
}
async getuiTrafficStats() {
return this.call({
method: 'get',
@ -64,6 +64,27 @@ class API {
});
}
async getWGEnableOneTimeLinks() {
return this.call({
method: 'get',
path: '/wg-enable-one-time-links',
});
}
async getWGEnableExpireTime() {
return this.call({
method: 'get',
path: '/wg-enable-expire-time',
});
}
async getAvatarSettings() {
return this.call({
method: 'get',
path: '/ui-avatar-settings',
});
}
async getSession() {
return this.call({
method: 'get',
@ -71,11 +92,11 @@ class API {
});
}
async createSession({ password }) {
async createSession({ password, remember }) {
return this.call({
method: 'post',
path: '/session',
body: { password },
body: { password, remember },
});
}
@ -94,17 +115,20 @@ class API {
...client,
createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt),
expiredAt: client.expiredAt !== null
? new Date(client.expiredAt)
: null,
latestHandshakeAt: client.latestHandshakeAt !== null
? new Date(client.latestHandshakeAt)
: null,
})));
}
async createClient({ name }) {
async createClient({ name, expiredDate }) {
return this.call({
method: 'post',
path: '/wireguard/client',
body: { name },
body: { name, expiredDate },
});
}
@ -115,6 +139,13 @@ class API {
});
}
async showOneTimeLink({ clientId }) {
return this.call({
method: 'post',
path: `/wireguard/client/${clientId}/generateOneTimeLink`,
});
}
async enableClient({ clientId }) {
return this.call({
method: 'post',
@ -145,4 +176,27 @@ class API {
});
}
async updateClientExpireDate({ clientId, expireDate }) {
return this.call({
method: 'put',
path: `/wireguard/client/${clientId}/expireDate/`,
body: { expireDate },
});
}
async restoreConfiguration(file) {
return this.call({
method: 'put',
path: '/wireguard/restore',
body: { file },
});
}
async getUiSortClients() {
return this.call({
method: 'get',
path: '/ui-sort-clients',
});
}
}

View File

@ -5,8 +5,6 @@
'use strict';
const CHANGELOG_URL = 'https://raw.githubusercontent.com/spcfox/amnezia-wg-easy/production/docs/changelog.json';
function bytes(bytes, decimals, kib, maxunit) {
kib = kib || false;
if (bytes === 0) return '0 B';
@ -25,6 +23,22 @@ function bytes(bytes, decimals, kib, maxunit) {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
/**
* Sorts an array of objects by a specified property in ascending or descending order.
*
* @param {Array} array - The array of objects to be sorted.
* @param {string} property - The property to sort the array by.
* @param {boolean} [sort=true] - Whether to sort the array in ascending (default) or descending order.
* @return {Array} - The sorted array of objects.
*/
function sortByProperty(array, property, sort = true) {
if (sort) {
return array.sort((a, b) => (typeof a[property] === 'string' ? a[property].localeCompare(b[property]) : a[property] - b[property]));
}
return array.sort((a, b) => (typeof a[property] === 'string' ? b[property].localeCompare(a[property]) : b[property] - a[property]));
}
const i18n = new VueI18n({
locale: localStorage.getItem('lang') || 'en',
fallbackLocale: 'en',
@ -55,16 +69,21 @@ new Vue({
authenticating: false,
password: null,
requiresPassword: null,
remember: false,
rememberMeEnabled: false,
clients: null,
clientsPersist: {},
clientDelete: null,
clientCreate: null,
clientCreateName: '',
clientExpiredDate: '',
clientEditName: null,
clientEditNameId: null,
clientEditAddress: null,
clientEditAddressId: null,
clientEditExpireDate: null,
clientEditExpireDateId: null,
qrcode: null,
currentRelease: null,
@ -73,6 +92,15 @@ new Vue({
uiTrafficStats: false,
uiChartType: 0,
avatarSettings: {
'dicebear': null,
'gravatar': false,
},
enableOneTimeLinks: false,
enableSortClient: false,
sortClient: true, // Sort clients by name, true = asc, false = desc
enableExpireTime: false,
uiShowCharts: localStorage.getItem('uiShowCharts') === '1',
uiTheme: localStorage.theme || 'auto',
prefersDarkScheme: window.matchMedia('(prefers-color-scheme: dark)'),
@ -157,6 +185,7 @@ new Vue({
},
},
},
},
methods: {
dateTime: (value) => {
@ -175,8 +204,10 @@ new Vue({
const clients = await this.api.getClients();
this.clients = clients.map((client) => {
if (client.name.includes('@') && client.name.includes('.')) {
if (client.name.includes('@') && client.name.includes('.') && this.avatarSettings.gravatar) {
client.avatar = `https://gravatar.com/avatar/${sha256(client.name.toLowerCase().trim())}.jpg`;
} else if (this.avatarSettings.dicebear) {
client.avatar = `https://api.dicebear.com/9.x/${this.avatarSettings.dicebear}/svg?seed=${sha256(client.name.toLowerCase().trim())}`
}
if (!this.clientsPersist[client.id]) {
@ -231,6 +262,10 @@ new Vue({
return client;
});
if (this.enableSortClient) {
this.clients = sortByProperty(this.clients, 'name', this.sortClient);
}
},
login(e) {
e.preventDefault();
@ -241,6 +276,7 @@ new Vue({
this.authenticating = true;
this.api.createSession({
password: this.password,
remember: this.remember,
})
.then(async () => {
const session = await this.api.getSession();
@ -270,9 +306,10 @@ new Vue({
},
createClient() {
const name = this.clientCreateName;
const expiredDate = this.clientExpiredDate;
if (!name) return;
this.api.createClient({ name })
this.api.createClient({ name, expiredDate })
.catch((err) => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
@ -281,6 +318,11 @@ new Vue({
.catch((err) => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
showOneTimeLink(client) {
this.api.showOneTimeLink({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
enableClient(client) {
this.api.enableClient({ clientId: client.id })
.catch((err) => alert(err.message || err.toString()))
@ -301,6 +343,27 @@ new Vue({
.catch((err) => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
updateClientExpireDate(client, expireDate) {
this.api.updateClientExpireDate({ clientId: client.id, expireDate })
.catch((err) => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
restoreConfig(e) {
e.preventDefault();
const file = e.currentTarget.files.item(0);
if (file) {
file.text()
.then((content) => {
this.api.restoreConfiguration(content)
.then((_result) => alert('The configuration was updated.'))
.catch((err) => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
})
.catch((err) => alert(err.message || err.toString()));
} else {
alert('Failed to load your file!');
}
},
toggleTheme() {
const themes = ['light', 'dark', 'auto'];
const currentIndex = themes.indexOf(this.uiTheme);
@ -328,6 +391,15 @@ new Vue({
timeago: (value) => {
return timeago.format(value, i18n.locale);
},
expiredDateFormat: (value) => {
if (value === null) return i18n.t('Permanent');
const dateTime = new Date(value);
const options = { year: 'numeric', month: 'long', day: 'numeric' };
return dateTime.toLocaleDateString(i18n.locale, options);
},
expiredDateEditFormat: (value) => {
if (value === null) return 'yyyy-MM-dd';
},
},
mounted() {
this.prefersDarkScheme.addListener(this.handlePrefersChange);
@ -348,6 +420,11 @@ new Vue({
alert(err.message || err.toString());
});
this.api.getRememberMeEnabled()
.then((rememberMeEnabled) => {
this.rememberMeEnabled = rememberMeEnabled;
});
setInterval(() => {
this.refresh({
updateCharts: this.updateCharts,
@ -370,6 +447,41 @@ new Vue({
this.uiChartType = 0;
});
this.api.getWGEnableOneTimeLinks()
.then((res) => {
this.enableOneTimeLinks = res;
})
.catch(() => {
this.enableOneTimeLinks = false;
});
this.api.getUiSortClients()
.then((res) => {
this.enableSortClient = res;
})
.catch(() => {
this.enableSortClient = false;
});
this.api.getWGEnableExpireTime()
.then((res) => {
this.enableExpireTime = res;
})
.catch(() => {
this.enableExpireTime = false;
});
this.api.getAvatarSettings()
.then((res) => {
this.avatarSettings = res;
})
.catch(() => {
this.avatarSettings = {
'dicebear': null,
'gravatar': false,
};
});
Promise.resolve().then(async () => {
const lang = await this.api.getLang();
if (lang !== localStorage.getItem('lang') && i18n.availableLocales.includes(lang)) {
@ -377,11 +489,8 @@ new Vue({
i18n.locale = lang;
}
const checkUpdate = await this.api.getCheckUpdate();
if (!checkUpdate) return;
const currentRelease = await this.api.getRelease();
const latestRelease = await fetch(CHANGELOG_URL)
const latestRelease = await fetch('https://wg-easy.github.io/wg-easy/changelog.json')
.then((res) => res.json())
.then((releases) => {
const releasesArray = Object.entries(releases).map(([version, changelog]) => ({

View File

@ -30,6 +30,16 @@ const messages = { // eslint-disable-line no-unused-vars
donate: 'Donate',
toggleCharts: 'Show/hide Charts',
theme: { dark: 'Dark theme', light: 'Light theme', auto: 'Auto theme' },
restore: 'Restore',
backup: 'Backup',
titleRestoreConfig: 'Restore your configuration',
titleBackupConfig: 'Backup your configuration',
rememberMe: 'Remember me',
titleRememberMe: 'Stay logged after closing the browser',
sort: 'Sort',
ExpireDate: 'Expire Date',
Permanent: 'Permanent',
OneTimeLink: 'Generate short one time link',
},
ua: {
name: 'Ім`я',
@ -53,10 +63,17 @@ const messages = { // eslint-disable-line no-unused-vars
disableClient: 'Вимкнути клієнта',
enableClient: 'Увімкнути клієнта',
noClients: 'Ще немає клієнтів.',
noPrivKey: 'У цього клієнта немає відомого приватного ключа. Неможливо створити конфігурацію.',
showQR: 'Показати QR-код',
downloadConfig: 'Завантажити конфігурацію',
madeBy: 'Зроблено',
donate: 'Пожертвувати',
toggleCharts: 'Показати/сховати діаграми',
theme: { dark: 'Темна тема', light: 'Світла тема', auto: 'Автоматична тема' },
restore: 'Відновити',
backup: 'Резервна копія',
titleRestoreConfig: 'Відновити конфігурацію',
titleBackupConfig: 'Створити резервну копію конфігурації',
},
ru: {
name: 'Имя',
@ -80,10 +97,23 @@ const messages = { // eslint-disable-line no-unused-vars
disableClient: 'Выключить клиента',
enableClient: 'Включить клиента',
noClients: 'Пока нет клиентов.',
noPrivKey: 'Невозможно создать конфигурацию: у клиента нет известного приватного ключа.',
showQR: 'Показать QR-код',
downloadConfig: 'Скачать конфигурацию',
madeBy: 'Автор',
donate: 'Поблагодарить',
toggleCharts: 'Показать/скрыть графики',
theme: { dark: 'Темная тема', light: 'Светлая тема', auto: 'Как в системе' },
restore: 'Восстановить',
backup: 'Резервная копия',
titleRestoreConfig: 'Восстановить конфигурацию',
titleBackupConfig: 'Создать резервную копию конфигурации',
rememberMe: 'Запомнить меня',
titleRememberMe: 'Оставаться в системе после закрытия браузера',
sort: 'Сортировка',
ExpireDate: 'Дата истечения срока',
Permanent: 'Бессрочно',
OneTimeLink: 'Создать короткую одноразовую ссылку',
},
tr: { // Müslüm Barış Korkmazer @babico
name: 'İsim',
@ -99,19 +129,25 @@ const messages = { // eslint-disable-line no-unused-vars
deleteDialog2: 'Bu işlem geri alınamaz.',
cancel: 'İptal',
create: 'Oluştur',
createdAt: 'Şu saatte oluşturuldu: ',
createdOn: 'Şu saatte oluşturuldu: ',
lastSeen: 'Son görülme tarihi: ',
totalDownload: 'Toplam İndirme: ',
totalUpload: 'Toplam Yükleme: ',
newClient: 'Yeni Kullanıcı',
disableClient: 'İstemciyi Devre Dışı Bırak',
enableClient: 'İstemciyi Etkinleştir',
disableClient: 'Kullanıcıyı Devre Dışı Bırak',
enableClient: 'Kullanıcıyı Etkinleştir',
noClients: 'Henüz kullanıcı yok.',
noPrivKey: 'Bu istemcinin bilinen bir özel anahtarı yok. Yapılandırma oluşturulamıyor.',
showQR: 'QR Kodunu Göster',
downloadConfig: 'Yapılandırmayı İndir',
madeBy: 'Yapan Kişi: ',
donate: 'Bağış Yap',
changeLang: 'Dil Değiştir',
toggleCharts: 'Grafiği göster/gizle',
theme: { dark: 'Karanlık tema', light: 'Açık tema', auto: 'Otomatik tema' },
restore: 'Geri yükle',
backup: 'Yedekle',
titleRestoreConfig: 'Yapılandırmanızı geri yükleyin',
titleBackupConfig: 'Yapılandırmanızı yedekleyin',
},
no: { // github.com/digvalley
name: 'Navn',
@ -193,6 +229,10 @@ const messages = { // eslint-disable-line no-unused-vars
downloadConfig: 'Télécharger la configuration',
madeBy: 'Développé par',
donate: 'Soutenir',
restore: 'Restaurer',
backup: 'Sauvegarder',
titleRestoreConfig: 'Restaurer votre configuration',
titleBackupConfig: 'Sauvegarder votre configuration',
},
de: { // github.com/florian-asche
name: 'Name',
@ -221,6 +261,10 @@ const messages = { // eslint-disable-line no-unused-vars
downloadConfig: 'Konfiguration herunterladen',
madeBy: 'Erstellt von',
donate: 'Spenden',
restore: 'Wiederherstellen',
backup: 'Sichern',
titleRestoreConfig: 'Stelle deine Konfiguration wieder her',
titleBackupConfig: 'Sichere deine Konfiguration',
},
ca: { // github.com/guillembonet
name: 'Nom',
@ -277,6 +321,10 @@ const messages = { // eslint-disable-line no-unused-vars
donate: 'Donar',
toggleCharts: 'Mostrar/Ocultar gráficos',
theme: { dark: 'Modo oscuro', light: 'Modo claro', auto: 'Modo automático' },
restore: 'Restaurar',
backup: 'Realizar copia de seguridad',
titleRestoreConfig: 'Restaurar su configuración',
titleBackupConfig: 'Realizar copia de seguridad de su configuración',
},
ko: {
name: '이름',
@ -304,8 +352,14 @@ const messages = { // eslint-disable-line no-unused-vars
downloadConfig: '구성 다운로드',
madeBy: '만든 사람',
donate: '기부',
toggleCharts: '차트 표시/숨기기',
theme: { dark: '어두운 테마', light: '밝은 테마', auto: '자동 테마' },
restore: '복원',
backup: '백업',
titleRestoreConfig: '구성 파일 복원',
titleBackupConfig: '구성 파일 백업',
},
vi: {
vi: { // https://github.com/hoangneeee
name: 'Tên',
password: 'Mật khẩu',
signIn: 'Đăng nhập',
@ -331,6 +385,13 @@ const messages = { // eslint-disable-line no-unused-vars
downloadConfig: 'Tải xuống cấu hình',
madeBy: 'Được tạo bởi',
donate: 'Ủng hộ',
toggleCharts: 'Mở/Ẩn Biểu đồ',
theme: { dark: 'Dark theme', light: 'Light theme', auto: 'Auto theme' },
restore: 'Khôi phục',
backup: 'Sao lưu',
titleRestoreConfig: 'Khôi phục cấu hình của bạn',
titleBackupConfig: 'Sao lưu cấu hình của bạn',
sort: 'Sắp xếp',
},
nl: {
name: 'Naam',
@ -435,37 +496,63 @@ const messages = { // eslint-disable-line no-unused-vars
disableClient: '禁用客户端',
enableClient: '启用客户端',
noClients: '目前没有客户端。',
noPrivKey: '此客户端没有已知的私钥。无法创建配置。',
showQR: '显示二维码',
downloadConfig: '下载配置',
madeBy: '由',
donate: '捐赠',
toggleCharts: '显示/隐藏图表',
theme: { dark: '暗黑主题', light: '明亮主题', auto: '自动主题' },
restore: '恢复',
backup: '备份',
titleRestoreConfig: '恢复您的配置',
titleBackupConfig: '备份您的配置',
rememberMe: '记住我',
titleRememberMe: '关闭浏览器后保持登录',
sort: '排序',
ExpireDate: '到期日期',
Permanent: '永久',
OneTimeLink: '生成一次性短链接',
},
cht: {
name: '名字',
password: '密碼',
signIn: '登入',
logout: '登出',
updateAvailable: '有新版本可用!',
updateAvailable: '有新版本可以使用!',
update: '更新',
clients: '客戶',
new: '新建',
deleteClient: '刪除客戶',
clients: '使用者',
new: '',
deleteClient: '刪除使用者',
deleteDialog1: '您確定要刪除',
deleteDialog2: '此操作無法撤銷。',
deleteDialog2: '此作業無法復原。',
cancel: '取消',
create: '建立',
createdOn: '建立於 ',
lastSeen: '最後訪問於 ',
lastSeen: '最後存取於 ',
totalDownload: '總下載: ',
totalUpload: '總上傳: ',
newClient: '新客戶',
disableClient: '禁用客戶',
enableClient: '啟用客戶',
noClients: '目前沒有客戶。',
showQR: '顯示二維碼',
downloadConfig: '下載配置',
newClient: '新用戶',
disableClient: '停用使用者',
enableClient: '啟用使用者',
noClients: '目前沒有使用者。',
noPrivKey: '此使用者沒有已知的私鑰。無法創建配置。',
showQR: '顯示 QR Code',
downloadConfig: '下載 Config 檔',
madeBy: '由',
donate: '捐贈',
donate: '抖內',
toggleCharts: '顯示/隱藏圖表',
theme: { dark: '暗黑主題', light: '明亮主題', auto: '自動主題' },
restore: '恢復',
backup: '備份',
titleRestoreConfig: '恢復您的配置',
titleBackupConfig: '備份您的配置',
rememberMe: '記住我',
titleRememberMe: '關閉瀏覽器後保持登錄',
sort: '排序',
ExpireDate: '到期日期',
Permanent: '永久',
OneTimeLink: '生成一次性短鏈接',
},
it: {
name: 'Nome',
@ -493,6 +580,10 @@ const messages = { // eslint-disable-line no-unused-vars
downloadConfig: 'Scarica configurazione',
madeBy: 'Realizzato da',
donate: 'Donazione',
restore: 'Ripristina',
backup: 'Backup',
titleRestoreConfig: 'Ripristina la tua configurazione',
titleBackupConfig: 'Esegui il backup della tua configurazione',
},
th: {
name: 'ชื่อ',

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.p-0 {
padding: 0;
}