Browse Source

Merge branch 'master' of https://github.com/wg-easy/wg-easy into wg-easy-master

pull/1605/head
Andreas 1 year ago
parent
commit
96ebd7f72f
  1. 1
      .dockerignore
  2. 3
      .github/CODEOWNERS
  3. 4
      .github/ISSUE_TEMPLATE/bug_report.md
  4. 38
      .github/workflows/deploy-pr.yml
  5. 5
      .github/workflows/lint.yml
  6. 7
      .github/workflows/npm-update-bot.yml
  7. 1
      .gitignore
  8. 28
      Dockerfile
  9. 56
      README.md
  10. 1
      docker-compose.dev.yml
  11. 8
      docker-compose.yml
  12. 4
      docs/changelog.json
  13. 2
      package.json
  14. 1
      src/.gitignore
  15. 19
      src/config.js
  16. 294
      src/lib/Server.js
  17. 32
      src/lib/WireGuard.js
  18. 913
      src/package-lock.json
  19. 14
      src/package.json
  20. 15
      src/server.js
  21. 24
      src/tailwind.config.js
  22. 502
      src/www/css/app.css
  23. 294
      src/www/index.html
  24. 14
      src/www/js/api.js
  25. 170
      src/www/js/app.js
  26. 88
      src/www/js/i18n.js
  27. 8
      src/www/js/vendor/apexcharts.min.js

1
.dockerignore

@ -0,0 +1 @@
/src/node_modules

3
.github/CODEOWNERS

@ -0,0 +1,3 @@
# Copyright (c) Emile Nijssen (WeeJeWel)
# Founder and Codeowner of WireGuard Easy (wg-easy)
* @WeeJeWel

4
.github/ISSUE_TEMPLATE/bug_report.md

@ -24,13 +24,13 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS: [e.g. iOS] - OS: [e.g. macOS 12.1]
- Browser [e.g. chrome, safari] - Browser [e.g. chrome, safari]
- Version [e.g. 22] - Version [e.g. 22]
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6] - Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1] - OS: [e.g. iOS 8.1]
- Browser [e.g. stock browser, safari] - Browser [e.g. stock browser, safari]
- Version [e.g. 22] - Version [e.g. 22]

38
.github/workflows/deploy-pr.yml

@ -0,0 +1,38 @@
name: Build Pull Request
on:
workflow_dispatch:
pull_request:
jobs:
deploy:
name: Build & Deploy
runs-on: ubuntu-latest
if: github.repository_owner == 'wg-easy'
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: production
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v5
with:
push: false
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
tags: ghcr.io/wg-easy/wg-easy:pr

5
.github/workflows/lint.yml

@ -17,12 +17,9 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20'
check-latest: true check-latest: true
cache: 'npm' cache: 'npm'
cache-dependency-path: |
package-lock.json
src/package-lock.json
- name: npm run lint - name: npm run lint
run: | run: |

7
.github/workflows/npm-update-bot.yml

@ -4,7 +4,7 @@ on:
push: push:
branches: [ "master" ] branches: [ "master" ]
schedule: schedule:
- cron: "0 0 * * *" - cron: "0 0 * * 1"
jobs: jobs:
npmupbot: npmupbot:
@ -19,12 +19,9 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20'
check-latest: true check-latest: true
cache: 'npm' cache: 'npm'
cache-dependency-path: |
package-lock.json
src/package-lock.json
- name: Bot 🤖 "Updating NPM Packages..." - name: Bot 🤖 "Updating NPM Packages..."
run: | run: |

1
.gitignore

@ -1,5 +1,6 @@
/config /config
/wg0.conf /wg0.conf
/wg0.json /wg0.json
/src/node_modules
.DS_Store .DS_Store
*.swp *.swp

28
Dockerfile

@ -1,16 +1,20 @@
# There's an issue with node:20-alpine. # As a workaround we have to build on nodejs 18
# Docker deployment is canceled after 25< minutes. # nodejs 20 hangs on build with armv6/armv7
FROM docker.io/library/node:18-alpine AS build_node_modules FROM docker.io/library/node:18-alpine AS build_node_modules
# Update npm to latest
RUN npm install -g npm@latest
# Copy Web UI # Copy Web UI
COPY src/ /app/ COPY src /app
WORKDIR /app WORKDIR /app
RUN npm ci --omit=dev RUN npm ci --omit=dev &&\
mv node_modules /node_modules
# Copy build result to a new image. # Copy build result to a new image.
# This saves a lot of disk space. # This saves a lot of disk space.
FROM docker.io/library/node:18-alpine FROM docker.io/library/node:20-alpine
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 COPY --from=build_node_modules /app /app
# Move node_modules one directory up, so during development # Move node_modules one directory up, so during development
@ -20,13 +24,7 @@ COPY --from=build_node_modules /app /app
# Also, some node_modules might be native, and # Also, some node_modules might be native, and
# the architecture & OS of your development machine might differ # the architecture & OS of your development machine might differ
# than what runs inside of docker. # than what runs inside of docker.
RUN mv /app/node_modules /node_modules COPY --from=build_node_modules /node_modules /node_modules
# Enable this to run `npm run serve`
RUN npm i -g nodemon
# Workaround CVE-2023-42282
RUN npm uninstall -g ip
# Install Linux packages # Install Linux packages
RUN apk add --no-cache \ RUN apk add --no-cache \
@ -39,10 +37,6 @@ RUN apk add --no-cache \
# Use iptables-legacy # 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 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
# Expose Ports
EXPOSE 51820/udp
EXPOSE 51821/tcp
# Set Environment # Set Environment
ENV DEBUG=Server,WireGuard ENV DEBUG=Server,WireGuard

56
README.md

@ -13,7 +13,6 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
</p> </p>
## Features ## Features
* All-in-one: WireGuard + Web UI. * All-in-one: WireGuard + Web UI.
* Easy installation, simple to use. * Easy installation, simple to use.
* List, create, edit, delete, enable & disable clients. * List, create, edit, delete, enable & disable clients.
@ -23,12 +22,25 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
* Tx/Rx charts for each connected client. * Tx/Rx charts for each connected client.
* Gravatar support. * Gravatar support.
* Automatic Light / Dark Mode * Automatic Light / Dark Mode
* Multilanguage Support
* UI_TRAFFIC_STATS (default off)
## Requirements ## Requirements
* A host with a kernel that supports WireGuard (all modern kernels). * A host with a kernel that supports WireGuard (all modern kernels).
* A host with Docker installed. * 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 ## Installation
### 1. Install Docker ### 1. Install Docker
@ -36,9 +48,9 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
If you haven't installed Docker yet, install it by running: If you haven't installed Docker yet, install it by running:
```bash ```bash
$ curl -sSL https://get.docker.com | sh curl -sSL https://get.docker.com | sh
$ sudo usermod -aG docker $(whoami) sudo usermod -aG docker $(whoami)
$ exit exit
``` ```
And log in again. And log in again.
@ -47,12 +59,14 @@ And log in again.
To automatically install & run wg-easy, simply run: To automatically install & run wg-easy, simply run:
<pre> ```
$ docker run -d \ docker run -d \
--name=wg-easy \ --name=wg-easy \
-e LANG=de \ -e LANG=de \
-e WG_HOST=<b>🚨YOUR_SERVER_IP</b> \ -e WG_HOST=<🚨YOUR_SERVER_IP> \
-e PASSWORD=<b>🚨YOUR_ADMIN_PASSWORD</b> \ -e PASSWORD_HASH=<🚨YOUR_ADMIN_PASSWORD_HASH> \
-e PORT=51821 \
-e WG_PORT=51820 \
-v ~/.wg-easy:/etc/wireguard \ -v ~/.wg-easy:/etc/wireguard \
-p 51820:51820/udp \ -p 51820:51820/udp \
-p 51821:51821/tcp \ -p 51821:51821/tcp \
@ -62,16 +76,20 @@ $ docker run -d \
--sysctl="net.ipv4.ip_forward=1" \ --sysctl="net.ipv4.ip_forward=1" \
--restart unless-stopped \ --restart unless-stopped \
ghcr.io/wg-easy/wg-easy ghcr.io/wg-easy/wg-easy
</pre> ```
> 💡 Replace `YOUR_SERVER_IP` with your WAN IP, or a Dynamic DNS hostname. > 💡 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.
The Web UI will now be available on `http://0.0.0.0:51821`. The Web UI will now be available on `http://0.0.0.0:51821`.
> 💡 Your configuration files will be saved in `~/.wg-easy` > 💡 Your configuration files will be saved in `~/.wg-easy`
WireGuard 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`.
### 3. Sponsor ### 3. Sponsor
Are you enjoying this project? [Buy Emile a beer!](https://github.com/sponsors/WeeJeWel) 🍻 Are you enjoying this project? [Buy Emile a beer!](https://github.com/sponsors/WeeJeWel) 🍻
@ -84,10 +102,12 @@ These options can be configured by setting environment variables using `-e KEY="
| - | - | - | - | | - | - | - | - |
| `PORT` | `51821` | `6789` | TCP port for Web UI. | | `PORT` | `51821` | `6789` | TCP port for Web UI. |
| `WEBUI_HOST` | `0.0.0.0` | `localhost` | IP address web UI binds to. | | `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. | | `PASSWORD_HASH` | - | `$2y$05$Ci...` | When set, requires a password when logging in to the Web UI. |
| `PASSWORD` (deprecated) | - | `foobar123` | When set, requires a password when logging in to the Web UI. *(Not used if `PASSWORD_HASH` is set)* |
| `WG_HOST` | - | `vpn.myserver.com` | The public hostname of your VPN server. | | `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_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 always listen on 51820 inside the Docker container. | | `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 Assistent 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_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_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_ADDRESS` | `10.8.0.x` | `10.6.0.x` | Clients IP address range. |
@ -97,7 +117,9 @@ These options can be configured by setting environment variables using `-e KEY="
| `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_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_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_POST_DOWN` | `...` | `iptables ...` | See [config.js](https://github.com/wg-easy/wg-easy/blob/master/src/config.js#L28) for the default value. |
| `LANG` | `en` | `de` | Web UI language (Supports: en, ru, tr, no, pl, fr, de, ca, es, vi, nl, is, chs, cht,). | | `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 |
> If you change `WG_PORT`, make sure to also change the exposed port. > If you change `WG_PORT`, make sure to also change the exposed port.
@ -113,6 +135,14 @@ docker pull ghcr.io/wg-easy/wg-easy
And then run the `docker run -d \ ...` command above again. And then run the `docker run -d \ ...` command above again.
With Docker Compose WireGuard 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.
## Common Use Cases ## 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 Pi-Hole](https://github.com/wg-easy/wg-easy/wiki/Using-WireGuard-Easy-with-Pi-Hole)

1
docker-compose.dev.yml

@ -1,4 +1,3 @@
version: "3.8"
services: services:
wg-easy: wg-easy:
image: wg-easy image: wg-easy

8
docker-compose.yml

@ -1,4 +1,3 @@
version: "3.8"
volumes: volumes:
etc_wireguard: etc_wireguard:
@ -6,7 +5,7 @@ services:
wg-easy: wg-easy:
environment: environment:
# Change Language: # Change Language:
# (Supports: en, ua, ru, tr, no, pl, fr, de, ca, es, pt, chs, cht) # (Supports: en, ua, ru, tr, no, pl, fr, de, ca, es, ko, vi, nl, is, pt, chs, cht, it, th, hi)
- LANG=de - LANG=de
# ⚠️ Required: # ⚠️ Required:
# Change this to your host's public address # Change this to your host's public address
@ -14,7 +13,9 @@ services:
# Optional: # Optional:
# - PASSWORD=foobar123 # - PASSWORD=foobar123
# - PORT=51821
# - WG_PORT=51820 # - WG_PORT=51820
# - WG_CONFIG_PORT=92820
# - WG_DEFAULT_ADDRESS=10.8.0.x # - WG_DEFAULT_ADDRESS=10.8.0.x
# - WG_DEFAULT_DNS=1.1.1.1 # - WG_DEFAULT_DNS=1.1.1.1
# - WG_MTU=1420 # - WG_MTU=1420
@ -24,6 +25,8 @@ services:
# - WG_POST_UP=echo "Post Up" > /etc/wireguard/post-up.txt # - WG_POST_UP=echo "Post Up" > /etc/wireguard/post-up.txt
# - WG_PRE_DOWN=echo "Pre Down" > /etc/wireguard/pre-down.txt # - WG_PRE_DOWN=echo "Pre Down" > /etc/wireguard/pre-down.txt
# - WG_POST_DOWN=echo "Post Down" > /etc/wireguard/post-down.txt # - WG_POST_DOWN=echo "Post Down" > /etc/wireguard/post-down.txt
# - UI_TRAFFIC_STATS=true
# - UI_CHART_TYPE=0 # (0 Charts disabled, 1 # Line chart, 2 # Area chart, 3 # Bar chart)
image: ghcr.io/wg-easy/wg-easy image: ghcr.io/wg-easy/wg-easy
container_name: wg-easy container_name: wg-easy
@ -36,6 +39,7 @@ services:
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_MODULE - SYS_MODULE
# - NET_RAW # ⚠️ Uncomment if using Podman
sysctls: sysctls:
- net.ipv4.ip_forward=1 - net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1 - net.ipv4.conf.all.src_valid_mark=1

4
docs/changelog.json

@ -9,5 +9,7 @@
"8": "Updated to Node.js v18.", "8": "Updated to Node.js v18.",
"9": "Fixed issue running on devices with older kernels.", "9": "Fixed issue running on devices with older kernels.",
"10": "Added sessionless HTTP API auth & automatic dark mode.", "10": "Added sessionless HTTP API auth & automatic dark mode.",
"11": "Multilanguage Support & various bugfixes" "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 and more."
} }

2
package.json

@ -2,7 +2,7 @@
"version": "1.0.1", "version": "1.0.1",
"scripts": { "scripts": {
"build": "DOCKER_BUILDKIT=1 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", "serve": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up",
"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" "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"
} }
} }

1
src/.gitignore

@ -1 +0,0 @@
/node_modules

19
src/config.js

@ -3,15 +3,17 @@
const { release } = require('./package.json'); const { release } = require('./package.json');
module.exports.RELEASE = release; module.exports.RELEASE = release;
module.exports.PORT = process.env.PORT || 51821; module.exports.PORT = process.env.PORT || '51821';
module.exports.WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0'; module.exports.WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0';
module.exports.PASSWORD = process.env.PASSWORD; module.exports.PASSWORD = process.env.PASSWORD;
module.exports.PASSWORD_HASH = process.env.PASSWORD_HASH;
module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/'; module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/';
module.exports.WG_DEVICE = process.env.WG_DEVICE || 'eth0'; module.exports.WG_DEVICE = process.env.WG_DEVICE || 'eth0';
module.exports.WG_HOST = process.env.WG_HOST; module.exports.WG_HOST = process.env.WG_HOST;
module.exports.WG_PORT = process.env.WG_PORT || 51820; 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_MTU = process.env.WG_MTU || null;
module.exports.WG_PERSISTENT_KEEPALIVE = process.env.WG_PERSISTENT_KEEPALIVE || 0; 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'; module.exports.WG_DEFAULT_ADDRESS = process.env.WG_DEFAULT_ADDRESS || '10.8.0.x';
module.exports.WG_DEFAULT_DNS = typeof process.env.WG_DEFAULT_DNS === 'string' module.exports.WG_DEFAULT_DNS = typeof process.env.WG_DEFAULT_DNS === 'string'
? process.env.WG_DEFAULT_DNS ? process.env.WG_DEFAULT_DNS
@ -22,11 +24,18 @@ module.exports.WG_HOMESERVER_ALLOWED_IPS = process.env.WG_HOMESERVER_ALLOWED_IPS
module.exports.WG_PRE_UP = process.env.WG_PRE_UP || ''; module.exports.WG_PRE_UP = process.env.WG_PRE_UP || '';
module.exports.WG_POST_UP = process.env.WG_POST_UP || ` module.exports.WG_POST_UP = process.env.WG_POST_UP || `
iptables -t nat -A POSTROUTING -s ${module.exports.WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${module.exports.WG_DEVICE} -j MASQUERADE; iptables -t nat -A POSTROUTING -s ${module.exports.WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${module.exports.WG_DEVICE} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT; iptables -A INPUT -p udp -m udp --dport ${module.exports.WG_PORT} -j ACCEPT;
iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT;
iptables -A FORWARD -o wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT;
`.split('\n').join(' '); `.split('\n').join(' ');
module.exports.WG_PRE_DOWN = process.env.WG_PRE_DOWN || ''; module.exports.WG_PRE_DOWN = process.env.WG_PRE_DOWN || '';
module.exports.WG_POST_DOWN = process.env.WG_POST_DOWN || ''; module.exports.WG_POST_DOWN = process.env.WG_POST_DOWN || `
iptables -t nat -D POSTROUTING -s ${module.exports.WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o ${module.exports.WG_DEVICE} -j MASQUERADE;
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.LANG || '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;

294
src/lib/Server.js

@ -1,15 +1,27 @@
'use strict'; 'use strict';
const path = require('path');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { createServer } = require('node:http');
const { stat, readFile } = require('node:fs/promises');
const { resolve, sep } = require('node:path');
const express = require('express');
const expressSession = require('express-session'); const expressSession = require('express-session');
const debug = require('debug')('Server'); const debug = require('debug')('Server');
const Util = require('./Util'); const {
const ServerError = require('./ServerError'); createApp,
createError,
createRouter,
defineEventHandler,
fromNodeMiddleware,
getRouterParam,
toNodeListener,
readBody,
setHeader,
serveStatic,
} = require('h3');
const WireGuard = require('../services/WireGuard'); const WireGuard = require('../services/WireGuard');
const { const {
@ -17,39 +29,80 @@ const {
WEBUI_HOST, WEBUI_HOST,
RELEASE, RELEASE,
PASSWORD, PASSWORD,
PASSWORD_HASH,
LANG, LANG,
UI_TRAFFIC_STATS,
UI_CHART_TYPE,
} = require('../config'); } = require('../config');
const requiresPassword = !!PASSWORD || !!PASSWORD_HASH;
/**
* Checks if `password` matches the PASSWORD_HASH.
*
* For backward compatibility it also allows `password` to match the clear text PASSWORD,
* but only if no PASSWORD_HASH is provided.
*
* If both enviornment variables are not set, the password is always invalid.
*
* @param {string} password String to test
* @returns {boolean} true if matching environment, otherwise false
*/
const isPasswordValid = (password) => {
if (typeof password !== 'string') {
return false;
}
if (PASSWORD_HASH) {
return bcrypt.compareSync(password, PASSWORD_HASH);
}
if (PASSWORD) {
return password === PASSWORD;
}
return false;
};
module.exports = class Server { module.exports = class Server {
constructor() { constructor() {
// Express const app = createApp();
this.app = express() this.app = app;
.disable('etag')
.use('/', express.static(path.join(__dirname, '..', 'www'))) app.use(fromNodeMiddleware(expressSession({
.use(express.json()) secret: crypto.randomBytes(256).toString('hex'),
.use(expressSession({ resave: true,
secret: crypto.randomBytes(256).toString('hex'), saveUninitialized: true,
resave: true, })));
saveUninitialized: true,
cookie: {
httpOnly: true,
},
}))
.get('/api/release', (Util.promisify(async () => { const router = createRouter();
app.use(router);
router
.get('/api/release', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return RELEASE; return RELEASE;
}))) }))
.get('/api/lang', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `"${LANG}"`;
}))
.get('/api/ui-traffic-stats', defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'application/json');
return `"${UI_TRAFFIC_STATS}"`;
}))
.get('/api/lang', (Util.promisify(async () => { .get('/api/ui-chart-type', defineEventHandler((event) => {
return LANG; setHeader(event, 'Content-Type', 'application/json');
}))) return `"${UI_CHART_TYPE}"`;
}))
// Authentication // Authentication
.get('/api/session', Util.promisify(async (req) => { .get('/api/session', defineEventHandler((event) => {
const requiresPassword = !!process.env.PASSWORD;
const authenticated = requiresPassword const authenticated = requiresPassword
? !!(req.session && req.session.authenticated) ? !!(event.node.req.session && event.node.req.session.authenticated)
: true; : true;
return { return {
@ -57,28 +110,37 @@ module.exports = class Server {
authenticated, authenticated,
}; };
})) }))
.post('/api/session', Util.promisify(async (req) => { .post('/api/session', defineEventHandler(async (event) => {
const { const { password } = await readBody(event);
password,
} = req.body;
if (typeof password !== 'string') { if (!requiresPassword) {
throw new ServerError('Missing: Password', 401); // if no password is required, the API should never be called.
// Do not automatically authenticate the user.
throw createError({
status: 401,
message: 'Invalid state',
});
} }
if (password !== PASSWORD) { if (!isPasswordValid(password)) {
throw new ServerError('Incorrect Password', 401); throw createError({
status: 401,
message: 'Incorrect Password',
});
} }
req.session.authenticated = true; event.node.req.session.authenticated = true;
req.session.save(); event.node.req.session.save();
debug(`New Session: ${req.session.id}`); debug(`New Session: ${event.node.req.session.id}`);
}))
return { success: true };
}));
// WireGuard // WireGuard
.use((req, res, next) => { app.use(
if (!PASSWORD) { fromNodeMiddleware((req, res, next) => {
if (!requiresPassword || !req.url.startsWith('/api/')) {
return next(); return next();
} }
@ -86,8 +148,8 @@ module.exports = class Server {
return next(); return next();
} }
if (req.path.startsWith('/api/') && req.headers['authorization']) { if (req.url.startsWith('/api/') && req.headers['authorization']) {
if (bcrypt.compareSync(req.headers['authorization'], bcrypt.hashSync(PASSWORD, 10))) { if (isPasswordValid(req.headers['authorization'])) {
return next(); return next();
} }
return res.status(401).json({ return res.status(401).json({
@ -98,25 +160,32 @@ module.exports = class Server {
return res.status(401).json({ return res.status(401).json({
error: 'Not Logged In', error: 'Not Logged In',
}); });
}) }),
.delete('/api/session', Util.promisify(async (req) => { );
const sessionId = req.session.id;
req.session.destroy(); const router2 = createRouter();
app.use(router2);
router2
.delete('/api/session', defineEventHandler((event) => {
const sessionId = event.node.req.session.id;
event.node.req.session.destroy();
debug(`Deleted Session: ${sessionId}`); debug(`Deleted Session: ${sessionId}`);
return { success: true };
})) }))
.get('/api/wireguard/client', Util.promisify(async (req) => { .get('/api/wireguard/client', defineEventHandler(() => {
return WireGuard.getClients(); return WireGuard.getClients();
})) }))
.get('/api/wireguard/client/:clientId/qrcode.svg', Util.promisify(async (req, res) => { .get('/api/wireguard/client/:clientId/qrcode.svg', defineEventHandler(async (event) => {
const { clientId } = req.params; const clientId = getRouterParam(event, 'clientId');
const svg = await WireGuard.getClientQRCodeSVG({ clientId }); const svg = await WireGuard.getClientQRCodeSVG({ clientId });
res.header('Content-Type', 'image/svg+xml'); setHeader(event, 'Content-Type', 'image/svg+xml');
res.send(svg); return svg;
})) }))
.get('/api/wireguard/client/:clientId/configuration', Util.promisify(async (req, res) => { .get('/api/wireguard/client/:clientId/configuration', defineEventHandler(async (event) => {
const { clientId } = req.params; const clientId = getRouterParam(event, 'clientId');
const client = await WireGuard.getClient({ clientId }); const client = await WireGuard.getClient({ clientId });
const config = await WireGuard.getClientConfiguration({ clientId }); const config = await WireGuard.getClientConfiguration({ clientId });
const configName = client.name const configName = client.name
@ -124,52 +193,111 @@ module.exports = class Server {
.replace(/(-{2,}|-$)/g, '-') .replace(/(-{2,}|-$)/g, '-')
.replace(/-$/, '') .replace(/-$/, '')
.substring(0, 32); .substring(0, 32);
res.header('Content-Disposition', `attachment; filename="${configName || clientId}.conf"`); setHeader(event, 'Content-Disposition', `attachment; filename="${configName || clientId}.conf"`);
res.header('Content-Type', 'text/plain'); setHeader(event, 'Content-Type', 'text/plain');
res.send(config); return config;
})) }))
.post('/api/wireguard/client', Util.promisify(async (req) => { .post('/api/wireguard/client', defineEventHandler(async (event) => {
const { name } = req.body; const { name } = await readBody(event);
return WireGuard.createClient({ name }); await WireGuard.createClient({ name });
return { success: true };
})) }))
.delete('/api/wireguard/client/:clientId', Util.promisify(async (req) => { .delete('/api/wireguard/client/:clientId', defineEventHandler(async (event) => {
const { clientId } = req.params; const clientId = getRouterParam(event, 'clientId');
return WireGuard.deleteClient({ clientId }); await WireGuard.deleteClient({ clientId });
return { success: true };
})) }))
.post('/api/wireguard/client/:clientId/enable', Util.promisify(async (req, res) => { .post('/api/wireguard/client/:clientId/enable', defineEventHandler(async (event) => {
const { clientId } = req.params; const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') { if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
res.end(403); throw createError({ status: 403 });
} }
return WireGuard.enableClient({ clientId }); await WireGuard.enableClient({ clientId });
return { success: true };
})) }))
.post('/api/wireguard/client/:clientId/disable', Util.promisify(async (req, res) => { .post('/api/wireguard/client/:clientId/disable', defineEventHandler(async (event) => {
const { clientId } = req.params; const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') { if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
res.end(403); throw createError({ status: 403 });
} }
return WireGuard.disableClient({ clientId }); await WireGuard.disableClient({ clientId });
return { success: true };
})) }))
.put('/api/wireguard/client/:clientId/name', Util.promisify(async (req, res) => { .put('/api/wireguard/client/:clientId/name', defineEventHandler(async (event) => {
const { clientId } = req.params; const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') { if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
res.end(403); throw createError({ status: 403 });
} }
const { name } = req.body; const { name } = await readBody(event);
return WireGuard.updateClientName({ clientId, name }); await WireGuard.updateClientName({ clientId, name });
return { success: true };
})) }))
.put('/api/wireguard/client/:clientId/address', Util.promisify(async (req, res) => { .put('/api/wireguard/client/:clientId/address', defineEventHandler(async (event) => {
const { clientId } = req.params; const clientId = getRouterParam(event, 'clientId');
if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') { if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
res.end(403); throw createError({ status: 403 });
} }
const { address } = req.body; const { address } = await readBody(event);
return WireGuard.updateClientAddress({ clientId, address }); await WireGuard.updateClientAddress({ clientId, address });
})) return { success: true };
}));
const safePathJoin = (base, target) => {
// Manage web root (edge case)
if (target === '/') {
return `${base}${sep}`;
}
.listen(PORT, WEBUI_HOST, () => { // Prepend './' to prevent absolute paths
debug(`Listening on http://${WEBUI_HOST}:${PORT}`); const targetPath = `.${sep}${target}`;
// Resolve the absolute path
const resolvedPath = resolve(base, targetPath);
// Check if resolvedPath is a subpath of base
if (resolvedPath.startsWith(`${base}${sep}`)) {
return resolvedPath;
}
throw createError({
status: 400,
message: 'Bad Request',
}); });
};
// Static assets
const publicDir = '/app/www';
app.use(
defineEventHandler((event) => {
return serveStatic(event, {
getContents: (id) => {
return readFile(safePathJoin(publicDir, id));
},
getMeta: async (id) => {
const filePath = safePathJoin(publicDir, id);
const stats = await stat(filePath).catch(() => {});
if (!stats || !stats.isFile()) {
return;
}
if (id.endsWith('.html')) setHeader(event, 'Content-Type', 'text/html');
if (id.endsWith('.js')) setHeader(event, 'Content-Type', 'application/javascript');
if (id.endsWith('.json')) setHeader(event, 'Content-Type', 'application/json');
if (id.endsWith('.css')) setHeader(event, 'Content-Type', 'text/css');
if (id.endsWith('.png')) setHeader(event, 'Content-Type', 'image/png');
return {
size: stats.size,
mtime: stats.mtimeMs,
};
},
});
}),
);
createServer(toNodeListener(app)).listen(PORT, WEBUI_HOST);
debug(`Listening on http://${WEBUI_HOST}:${PORT}`);
} }
}; };

32
src/lib/WireGuard.js

@ -1,10 +1,9 @@
'use strict'; 'use strict';
const fs = require('fs').promises; const fs = require('node:fs/promises');
const path = require('path'); const path = require('path');
const debug = require('debug')('WireGuard'); const debug = require('debug')('WireGuard');
const uuid = require('uuid'); const crypto = require('node:crypto');
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const Util = require('./Util'); const Util = require('./Util');
@ -14,6 +13,7 @@ const {
WG_PATH, WG_PATH,
WG_HOST, WG_HOST,
WG_PORT, WG_PORT,
WG_CONFIG_PORT,
WG_MTU, WG_MTU,
WG_DEFAULT_DNS, WG_DEFAULT_DNS,
WG_DEFAULT_ADDRESS, WG_DEFAULT_ADDRESS,
@ -96,7 +96,7 @@ module.exports = class WireGuard {
[Interface] [Interface]
PrivateKey = ${config.server.privateKey} PrivateKey = ${config.server.privateKey}
Address = ${config.server.address}/24 Address = ${config.server.address}/24
ListenPort = 51820 ListenPort = ${WG_PORT}
PreUp = ${WG_PRE_UP} PreUp = ${WG_PRE_UP}
PostUp = ${WG_POST_UP} PostUp = ${WG_POST_UP}
PreDown = ${WG_PRE_DOWN} PreDown = ${WG_PRE_DOWN}
@ -111,8 +111,8 @@ PostDown = ${WG_POST_DOWN}
# Client: ${client.name} (${clientId}) # Client: ${client.name} (${clientId})
[Peer] [Peer]
PublicKey = ${client.publicKey} PublicKey = ${client.publicKey}
PresharedKey = ${client.preSharedKey} ${client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
AllowedIPs = ${client.address}/32`; }AllowedIPs = ${client.address}/32`;
debug(client.name); debug(client.name);
debug(client.name === 'Homeserver'); debug(client.name === 'Homeserver');
if (client.name === 'Homeserver' && WG_HOMESERVER_ALLOWED_IPS) { if (client.name === 'Homeserver' && WG_HOMESERVER_ALLOWED_IPS) {
@ -147,7 +147,7 @@ AllowedIPs = ${client.address}/32`;
createdAt: new Date(client.createdAt), createdAt: new Date(client.createdAt),
updatedAt: new Date(client.updatedAt), updatedAt: new Date(client.updatedAt),
allowedIPs: client.allowedIPs, allowedIPs: client.allowedIPs,
downloadableConfig: 'privateKey' in client,
persistentKeepalive: null, persistentKeepalive: null,
latestHandshakeAt: null, latestHandshakeAt: null,
transferRx: null, transferRx: null,
@ -202,18 +202,19 @@ AllowedIPs = ${client.address}/32`;
const config = await this.getConfig(); const config = await this.getConfig();
const client = await this.getClient({ clientId }); const client = await this.getClient({ clientId });
return `[Interface] return `
PrivateKey = ${client.privateKey} [Interface]
PrivateKey = ${client.privateKey ? `${client.privateKey}` : 'REPLACE_ME'}
Address = ${client.address}/24 Address = ${client.address}/24
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\ ${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}\n` : ''}\
${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\ ${WG_MTU ? `MTU = ${WG_MTU}\n` : ''}\
[Peer] [Peer]
PublicKey = ${config.server.publicKey} PublicKey = ${config.server.publicKey}
PresharedKey = ${client.preSharedKey} ${client.preSharedKey ? `PresharedKey = ${client.preSharedKey}\n` : ''
AllowedIPs = ${WG_ALLOWED_IPS} }AllowedIPs = ${WG_ALLOWED_IPS}
PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE} PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE}
Endpoint = ${WG_HOST}:${WG_PORT}`; Endpoint = ${WG_HOST}:${WG_CONFIG_PORT}`;
} }
async getClientQRCodeSVG({ clientId }) { async getClientQRCodeSVG({ clientId }) {
@ -253,7 +254,7 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
} }
// Create Client // Create Client
const id = uuid.v4(); const id = crypto.randomUUID();
const client = { const client = {
id, id,
name, name,
@ -324,4 +325,9 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
await this.saveConfig(); await this.saveConfig();
} }
// Shutdown wireguard
async Shutdown() {
await Util.exec('wg-quick down wg0').catch(() => { });
}
}; };

913
src/package-lock.json

File diff suppressed because it is too large

14
src/package.json

@ -1,27 +1,27 @@
{ {
"release": "11", "release": "13",
"name": "wg-easy", "name": "wg-easy",
"version": "1.0.1", "version": "1.0.1",
"description": "The easiest way to run WireGuard VPN + Web-based Admin UI.", "description": "The easiest way to run WireGuard VPN + Web-based Admin UI.",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"serve": "DEBUG=Server,WireGuard nodemon server.js", "serve": "DEBUG=Server,WireGuard npx nodemon server.js",
"serve-with-password": "PASSWORD=wg npm run serve", "serve-with-password": "PASSWORD=wg npm run serve",
"lint": "eslint .", "lint": "eslint .",
"buildcss": "npx tailwindcss -i ./www/src/css/app.css -o ./www/css/app.css" "buildcss": "npx tailwindcss -i ./www/src/css/app.css -o ./www/css/app.css"
}, },
"author": "Emile Nijssen", "author": "Emile Nijssen",
"license": "GPL", "license": "CC BY-NC-SA 4.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"debug": "^4.3.5", "debug": "^4.3.5",
"express": "^4.19.2",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"qrcode": "^1.5.3", "h3": "^1.11.1",
"uuid": "^10.0.0" "qrcode": "^1.5.3"
}, },
"devDependencies": { "devDependencies": {
"eslint-config-athom": "^3.1.3", "eslint-config-athom": "^3.1.3",
"nodemon": "^3.1.3",
"tailwindcss": "^3.4.4" "tailwindcss": "^3.4.4"
}, },
"nodemonConfig": { "nodemonConfig": {
@ -30,6 +30,6 @@
] ]
}, },
"engines": { "engines": {
"node": "18" "node": ">=18"
} }
} }

15
src/server.js

@ -12,3 +12,18 @@ WireGuard.getConfig()
// eslint-disable-next-line no-process-exit // eslint-disable-next-line no-process-exit
process.exit(1); process.exit(1);
}); });
// Handle terminate signal
process.on('SIGTERM', async () => {
// eslint-disable-next-line no-console
console.log('SIGTERM signal received.');
await WireGuard.Shutdown();
// eslint-disable-next-line no-process-exit
process.exit(0);
});
// Handle interrupt signal
process.on('SIGINT', () => {
// eslint-disable-next-line no-console
console.log('SIGINT signal received.');
});

24
src/tailwind.config.js

@ -3,6 +3,28 @@
'use strict'; 'use strict';
module.exports = { module.exports = {
darkMode: 'media', darkMode: 'selector',
content: ['./www/**/*.{html,js}'], content: ['./www/**/*.{html,js}'],
theme: {
screens: {
xxs: '450px',
xs: '576px',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
},
plugins: [
function addDisabledClass({ addUtilities }) {
const newUtilities = {
'.is-disabled': {
opacity: '0.25',
cursor: 'default',
},
};
addUtilities(newUtilities);
},
],
}; };

502
src/www/css/app.css

@ -558,6 +558,18 @@ video {
width: 100%; width: 100%;
} }
@media (min-width: 450px) {
.container {
max-width: 450px;
}
}
@media (min-width: 576px) {
.container {
max-width: 576px;
}
}
@media (min-width: 640px) { @media (min-width: 640px) {
.container { .container {
max-width: 640px; max-width: 640px;
@ -588,6 +600,18 @@ video {
} }
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.visible { .visible {
visibility: visible; visibility: visible;
} }
@ -690,8 +714,8 @@ video {
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
.mb-2 { .mb-4 {
margin-bottom: 0.5rem; margin-bottom: 1rem;
} }
.mb-5 { .mb-5 {
@ -710,8 +734,12 @@ video {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mr-5 { .mt-0 {
margin-right: 1.25rem; margin-top: 0px;
}
.mt-0\.5 {
margin-top: 0.125rem;
} }
.mt-10 { .mt-10 {
@ -726,10 +754,18 @@ video {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.mt-4 {
margin-top: 1rem;
}
.mt-5 { .mt-5 {
margin-top: 1.25rem; margin-top: 1.25rem;
} }
.mt-px {
margin-top: 1px;
}
.block { .block {
display: block; display: block;
} }
@ -794,14 +830,26 @@ video {
height: 1rem; height: 1rem;
} }
.h-5 {
height: 1.25rem;
}
.h-6 { .h-6 {
height: 1.5rem; height: 1.5rem;
} }
.h-8 {
height: 2rem;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
.w-1 {
width: 0.25rem;
}
.w-10 { .w-10 {
width: 2.5rem; width: 2.5rem;
} }
@ -842,6 +890,10 @@ video {
width: 100%; width: 100%;
} }
.min-w-20 {
min-width: 5rem;
}
.max-w-3xl { .max-w-3xl {
max-width: 48rem; max-width: 48rem;
} }
@ -854,10 +906,18 @@ video {
flex-shrink: 0; flex-shrink: 0;
} }
.shrink-0 {
flex-shrink: 0;
}
.flex-grow { .flex-grow {
flex-grow: 1; flex-grow: 1;
} }
.grow-0 {
flex-grow: 0;
}
.transform { .transform {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
} }
@ -903,10 +963,18 @@ video {
flex-direction: column; flex-direction: column;
} }
.flex-col-reverse {
flex-direction: column-reverse;
}
.flex-wrap { .flex-wrap {
flex-wrap: wrap; flex-wrap: wrap;
} }
.items-end {
align-items: flex-end;
}
.items-center { .items-center {
align-items: center; align-items: center;
} }
@ -927,6 +995,22 @@ video {
gap: 0.25rem; gap: 0.25rem;
} }
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.self-start {
align-self: flex-start;
}
.self-end {
align-self: flex-end;
}
.overflow-hidden { .overflow-hidden {
overflow: hidden; overflow: hidden;
} }
@ -941,6 +1025,10 @@ video {
white-space: nowrap; white-space: nowrap;
} }
.whitespace-nowrap {
white-space: nowrap;
}
.rounded { .rounded {
border-radius: 0.25rem; border-radius: 0.25rem;
} }
@ -1053,6 +1141,14 @@ video {
--tw-bg-opacity: 0.5; --tw-bg-opacity: 0.5;
} }
.fill-gray-400 {
fill: #9ca3af;
}
.fill-gray-600 {
fill: #4b5563;
}
.p-1 { .p-1 {
padding: 0.25rem; padding: 0.25rem;
} }
@ -1115,10 +1211,6 @@ video {
padding-bottom: 3rem; padding-bottom: 3rem;
} }
.pb-2 {
padding-bottom: 0.5rem;
}
.pb-20 { .pb-20 {
padding-bottom: 5rem; padding-bottom: 5rem;
} }
@ -1241,6 +1333,11 @@ video {
color: rgb(17 24 39 / var(--tw-text-opacity)); color: rgb(17 24 39 / var(--tw-text-opacity));
} }
.text-neutral-400 {
--tw-text-opacity: 1;
color: rgb(163 163 163 / var(--tw-text-opacity));
}
.text-red-600 { .text-red-600 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity)); color: rgb(220 38 38 / var(--tw-text-opacity));
@ -1352,6 +1449,11 @@ video {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1); transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
} }
.is-disabled {
opacity: 0.25;
cursor: default;
}
.last\:border-b-0:last-child { .last\:border-b-0:last-child {
border-bottom-width: 0px; border-bottom-width: 0px;
} }
@ -1423,6 +1525,26 @@ video {
opacity: 1; opacity: 1;
} }
.peer:checked ~ .peer-checked\:fill-gray-600 {
fill: #4b5563;
}
@media (min-width: 450px) {
.xxs\:flex-row {
flex-direction: row;
}
.xxs\:self-center {
align-self: center;
}
}
@media (min-width: 576px) {
.xs\:mt-6 {
margin-top: 1.5rem;
}
}
@media (min-width: 640px) { @media (min-width: 640px) {
.sm\:mx-0 { .sm\:mx-0 {
margin-left: 0px; margin-left: 0px;
@ -1490,6 +1612,10 @@ video {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
} }
.sm\:flex-row {
flex-direction: row;
}
.sm\:flex-row-reverse { .sm\:flex-row-reverse {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
@ -1534,8 +1660,12 @@ video {
display: inline-block; display: inline-block;
} }
.md\:flex-row { .md\:min-w-24 {
flex-direction: row; min-width: 6rem;
}
.md\:gap-4 {
gap: 1rem;
} }
.md\:px-0 { .md\:px-0 {
@ -1543,213 +1673,237 @@ video {
padding-right: 0px; padding-right: 0px;
} }
.md\:py-5 {
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.md\:pb-0 { .md\:pb-0 {
padding-bottom: 0px; padding-bottom: 0px;
} }
}
@media (prefers-color-scheme: dark) { .md\:text-base {
.dark\:border-neutral-500 { font-size: 1rem;
--tw-border-opacity: 1; line-height: 1.5rem;
border-color: rgb(115 115 115 / var(--tw-border-opacity));
} }
}
.dark\:border-neutral-600 { .dark\:border-neutral-500:where(.dark, .dark *) {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(82 82 82 / var(--tw-border-opacity)); border-color: rgb(115 115 115 / var(--tw-border-opacity));
} }
.dark\:border-neutral-800 { .dark\:border-neutral-600:where(.dark, .dark *) {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(38 38 38 / var(--tw-border-opacity)); border-color: rgb(82 82 82 / var(--tw-border-opacity));
} }
.dark\:border-red-600 { .dark\:border-neutral-800:where(.dark, .dark *) {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(220 38 38 / var(--tw-border-opacity)); border-color: rgb(38 38 38 / var(--tw-border-opacity));
} }
.dark\:bg-black { .dark\:border-red-600:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-border-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity)); border-color: rgb(220 38 38 / var(--tw-border-opacity));
} }
.dark\:bg-neutral-400 { .dark\:bg-black:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(163 163 163 / var(--tw-bg-opacity)); background-color: rgb(0 0 0 / var(--tw-bg-opacity));
} }
.dark\:bg-neutral-500 { .dark\:bg-neutral-400:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(115 115 115 / var(--tw-bg-opacity)); background-color: rgb(163 163 163 / var(--tw-bg-opacity));
} }
.dark\:bg-neutral-600 { .dark\:bg-neutral-500:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(82 82 82 / var(--tw-bg-opacity)); background-color: rgb(115 115 115 / var(--tw-bg-opacity));
} }
.dark\:bg-neutral-700 { .dark\:bg-neutral-600:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(64 64 64 / var(--tw-bg-opacity)); background-color: rgb(82 82 82 / var(--tw-bg-opacity));
} }
.dark\:bg-neutral-800 { .dark\:bg-neutral-700:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)); background-color: rgb(64 64 64 / var(--tw-bg-opacity));
} }
.dark\:bg-red-100 { .dark\:bg-neutral-800:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity)); background-color: rgb(38 38 38 / var(--tw-bg-opacity));
} }
.dark\:bg-red-600 { .dark\:bg-red-100:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity)); background-color: rgb(254 226 226 / var(--tw-bg-opacity));
} }
.dark\:bg-red-800 { .dark\:bg-red-600:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(153 27 27 / var(--tw-bg-opacity)); background-color: rgb(220 38 38 / var(--tw-bg-opacity));
} }
.dark\:text-gray-500 { .dark\:bg-red-800:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-bg-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity)); background-color: rgb(153 27 27 / var(--tw-bg-opacity));
} }
.dark\:text-neutral-200 { .dark\:fill-neutral-400:where(.dark, .dark *) {
--tw-text-opacity: 1; fill: #a3a3a3;
color: rgb(229 229 229 / var(--tw-text-opacity)); }
}
.dark\:text-neutral-300 { .dark\:fill-neutral-600:where(.dark, .dark *) {
--tw-text-opacity: 1; fill: #525252;
color: rgb(212 212 212 / var(--tw-text-opacity)); }
}
.dark\:text-neutral-400 { .dark\:text-gray-500:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(163 163 163 / var(--tw-text-opacity)); color: rgb(107 114 128 / var(--tw-text-opacity));
} }
.dark\:text-neutral-50 { .dark\:text-neutral-200:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(250 250 250 / var(--tw-text-opacity)); color: rgb(229 229 229 / var(--tw-text-opacity));
} }
.dark\:text-neutral-500 { .dark\:text-neutral-300:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(115 115 115 / var(--tw-text-opacity)); color: rgb(212 212 212 / var(--tw-text-opacity));
} }
.dark\:text-neutral-600 { .dark\:text-neutral-400:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(82 82 82 / var(--tw-text-opacity)); color: rgb(163 163 163 / var(--tw-text-opacity));
} }
.dark\:text-red-300 { .dark\:text-neutral-50:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(252 165 165 / var(--tw-text-opacity)); color: rgb(250 250 250 / var(--tw-text-opacity));
} }
.dark\:text-red-600 { .dark\:text-neutral-500:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity)); color: rgb(115 115 115 / var(--tw-text-opacity));
} }
.dark\:text-white { .dark\:text-neutral-600:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(82 82 82 / var(--tw-text-opacity));
} }
.dark\:opacity-50 { .dark\:text-red-300:where(.dark, .dark *) {
opacity: 0.5; --tw-text-opacity: 1;
} color: rgb(252 165 165 / var(--tw-text-opacity));
}
.dark\:placeholder\:text-neutral-400::-moz-placeholder { .dark\:text-red-600:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(163 163 163 / var(--tw-text-opacity)); color: rgb(220 38 38 / var(--tw-text-opacity));
} }
.dark\:placeholder\:text-neutral-400::placeholder { .dark\:text-white:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(163 163 163 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
.dark\:placeholder\:text-neutral-500::-moz-placeholder { .dark\:opacity-50:where(.dark, .dark *) {
--tw-text-opacity: 1; opacity: 0.5;
color: rgb(115 115 115 / var(--tw-text-opacity)); }
}
.dark\:placeholder\:text-neutral-500::placeholder { .dark\:placeholder\:text-neutral-400:where(.dark, .dark *)::-moz-placeholder {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(115 115 115 / var(--tw-text-opacity)); color: rgb(163 163 163 / var(--tw-text-opacity));
} }
.dark\:hover\:border-neutral-600:hover { .dark\:placeholder\:text-neutral-400:where(.dark, .dark *)::placeholder {
--tw-border-opacity: 1; --tw-text-opacity: 1;
border-color: rgb(82 82 82 / var(--tw-border-opacity)); color: rgb(163 163 163 / var(--tw-text-opacity));
} }
.dark\:hover\:border-red-600:hover { .dark\:placeholder\:text-neutral-500:where(.dark, .dark *)::-moz-placeholder {
--tw-border-opacity: 1; --tw-text-opacity: 1;
border-color: rgb(220 38 38 / var(--tw-border-opacity)); color: rgb(115 115 115 / var(--tw-text-opacity));
} }
.dark\:hover\:bg-neutral-500:hover { .dark\:placeholder\:text-neutral-500:where(.dark, .dark *)::placeholder {
--tw-bg-opacity: 1; --tw-text-opacity: 1;
background-color: rgb(115 115 115 / var(--tw-bg-opacity)); color: rgb(115 115 115 / var(--tw-text-opacity));
} }
.dark\:hover\:bg-neutral-600:hover { .dark\:hover\:border-neutral-600:hover:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-border-opacity: 1;
background-color: rgb(82 82 82 / var(--tw-bg-opacity)); border-color: rgb(82 82 82 / var(--tw-border-opacity));
} }
.dark\:hover\:bg-red-600:hover { .dark\:hover\:border-red-600:hover:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-border-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity)); border-color: rgb(220 38 38 / var(--tw-border-opacity));
} }
.dark\:hover\:bg-red-700:hover { .dark\:hover\:bg-neutral-500:hover:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity)); background-color: rgb(115 115 115 / var(--tw-bg-opacity));
} }
.dark\:hover\:bg-red-800:hover { .dark\:hover\:bg-neutral-600:hover:where(.dark, .dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(153 27 27 / var(--tw-bg-opacity)); background-color: rgb(82 82 82 / var(--tw-bg-opacity));
} }
.dark\:hover\:text-neutral-700:hover { .dark\:hover\:bg-red-600:hover:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-bg-opacity: 1;
color: rgb(64 64 64 / var(--tw-text-opacity)); background-color: rgb(220 38 38 / var(--tw-bg-opacity));
} }
.dark\:hover\:text-red-100:hover { .dark\:hover\:bg-red-700:hover:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-bg-opacity: 1;
color: rgb(254 226 226 / var(--tw-text-opacity)); background-color: rgb(185 28 28 / var(--tw-bg-opacity));
} }
.dark\:hover\:text-white:hover { .dark\:hover\:bg-red-800:hover:where(.dark, .dark *) {
--tw-text-opacity: 1; --tw-bg-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); background-color: rgb(153 27 27 / var(--tw-bg-opacity));
} }
.dark\:focus\:border-neutral-500:focus { .dark\:hover\:text-neutral-700:hover:where(.dark, .dark *) {
--tw-border-opacity: 1; --tw-text-opacity: 1;
border-color: rgb(115 115 115 / var(--tw-border-opacity)); color: rgb(64 64 64 / var(--tw-text-opacity));
} }
.dark\:focus\:border-red-800:focus { .dark\:hover\:text-red-100:hover:where(.dark, .dark *) {
--tw-border-opacity: 1; --tw-text-opacity: 1;
border-color: rgb(153 27 27 / var(--tw-border-opacity)); color: rgb(254 226 226 / var(--tw-text-opacity));
} }
.focus\:dark\:border-neutral-500:focus { .dark\:hover\:text-white:hover:where(.dark, .dark *) {
--tw-border-opacity: 1; --tw-text-opacity: 1;
border-color: rgb(115 115 115 / var(--tw-border-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
.dark\:focus\:border-neutral-500:focus:where(.dark, .dark *) {
--tw-border-opacity: 1;
border-color: rgb(115 115 115 / var(--tw-border-opacity));
}
.dark\:focus\:border-red-800:focus:where(.dark, .dark *) {
--tw-border-opacity: 1;
border-color: rgb(153 27 27 / var(--tw-border-opacity));
}
.focus\:dark\:border-neutral-500:where(.dark, .dark *):focus {
--tw-border-opacity: 1;
border-color: rgb(115 115 115 / var(--tw-border-opacity));
}
.group:hover .group-hover\:dark\:fill-neutral-500:where(.dark, .dark *) {
fill: #737373;
}
.peer:checked ~ .peer-checked\:dark\:fill-neutral-400:where(.dark, .dark *) {
fill: #a3a3a3;
} }

294
src/www/index.html

@ -3,6 +3,7 @@
<head> <head>
<title>WireGuard</title> <title>WireGuard</title>
<meta charset="utf-8"/>
<link href="./css/app.css" rel="stylesheet"> <link href="./css/app.css" rel="stylesheet">
<link rel="manifest" href="./manifest.json"> <link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/png" href="./img/favicon.png"> <link rel="icon" type="image/png" href="./img/favicon.png">
@ -17,29 +18,57 @@
</style> </style>
<body class="bg-gray-50 dark:bg-neutral-800"> <body class="bg-gray-50 dark:bg-neutral-800">
<div id="app"> <div id="app">
<div v-cloak class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6">
<div v-cloak class="container mx-auto max-w-3xl px-5 md:px-0">
<div v-if="authenticated === true"> <div v-if="authenticated === true">
<span v-if="requiresPassword" <div class="flex flex-col-reverse xxs:flex-row flex-auto items-center items-end gap-3">
class="text-sm text-gray-400 dark:text-neutral-400 mb-10 mr-2 mt-3 cursor-pointer hover:underline float-right" <h1 class="text-4xl dark:text-neutral-200 font-medium flex-grow self-start mb-4">
@click="logout"> <img src="./img/logo.png" width="32" class="inline align-middle dark:bg mr-2" /><span class="align-middle">WireGuard</span>
{{$t("logout")}} </h1>
<div class="flex items-center grow-0 gap-3 items-end self-end xxs:self-center">
<svg class="h-3 inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <!-- Dark / light theme -->
stroke="currentColor"> <button @click="toggleTheme"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 transition" :title="$t(`theme.${uiTheme}`)">
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> <svg v-if="uiTheme === 'light'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
</svg> class="w-5 h-5">
</span> <path stroke-linecap="round" stroke-linejoin="round"
<h1 class="text-4xl dark:text-neutral-200 font-medium mt-2 mb-2"> d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
<img src="./img/logo.png" width="32" class="inline align-middle dark:bg" /> </svg>
<span class="align-middle">WireGuard</span> <svg v-else-if="uiTheme === 'dark'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
</h1> class="w-5 h-5 text-neutral-400">
<h2 class="text-sm text-gray-400 dark:text-neutral-400 mb-10"></h2> <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"
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" />
</svg>
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25" />
</svg>
</button>
<!-- Show / hide charts -->
<label v-if="uiChartType > 0" class="inline-flex items-center justify-center cursor-pointer w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-neutral-700 dark:hover:bg-neutral-600 whitespace-nowrap transition group" :title="$t('toggleCharts')">
<input type="checkbox" value="" class="sr-only peer" v-model="uiShowCharts" @change="toggleCharts">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" fill="currentColor"
class="w-5 h-5 peer fill-gray-400 peer-checked:fill-gray-600 dark:fill-neutral-600 peer-checked:dark:fill-neutral-400 group-hover:dark:fill-neutral-500 transition">
<path
d="M18.375 2.25c-1.035 0-1.875.84-1.875 1.875v15.75c0 1.035.84 1.875 1.875 1.875h.75c1.035 0 1.875-.84 1.875-1.875V4.125c0-1.036-.84-1.875-1.875-1.875h-.75ZM9.75 8.625c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v11.25c0 1.035-.84 1.875-1.875 1.875h-.75a1.875 1.875 0 0 1-1.875-1.875V8.625ZM3 13.125c0-1.036.84-1.875 1.875-1.875h.75c1.036 0 1.875.84 1.875 1.875v6.75c0 1.035-.84 1.875-1.875 1.875h-.75A1.875 1.875 0 0 1 3 19.875v-6.75Z" />
</svg>
</label>
<span v-if="requiresPassword"
class="text-sm text-gray-400 dark:text-neutral-400 cursor-pointer hover:underline"
@click="logout">
{{$t("logout")}}
<svg class="h-3 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</span>
</div>
</div>
<div class="text-sm text-gray-400 dark:text-neutral-400 mb-5"></div>
<div v-if="latestRelease" <div v-if="latestRelease"
class="bg-red-800 dark:bg-red-100 p-4 text-white dark:text-red-600 text-sm font-small mb-10 rounded-md shadow-lg" class="bg-red-800 dark:bg-red-100 p-4 text-white dark:text-red-600 text-sm font-small mb-10 rounded-md shadow-lg"
:title="`v${currentRelease} → v${latestRelease.version}`"> :title="`v${currentRelease} → v${latestRelease.version}`">
@ -80,20 +109,23 @@
class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid"> class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid">
<!-- Chart --> <!-- Chart -->
<div class="absolute z-0 bottom-0 left-0 right-0" style="top: 60%;"> <div v-if="uiChartType" class="absolute z-0 bottom-0 left-0 right-0 h-6" >
<apexchart width="100%" height="100%" :options="client.chartOptions" :series="client.transferTxSeries"> <apexchart width="100%" height="100%" :options="chartOptionsTX" :series="client.transferTxSeries">
</apexchart> </apexchart>
</div> </div>
<div class="absolute z-0 top-0 left-0 right-0" style="bottom: 60%;"> <div v-if="uiChartType" class="absolute z-0 top-0 left-0 right-0 h-6" >
<apexchart width="100%" height="100%" :options="client.chartOptions" :series="client.transferRxSeries" <apexchart width="100%" height="100%" :options="chartOptionsRX" :series="client.transferRxSeries"
style="transform: scaleY(-1);"> style="transform: scaleY(-1);">
</apexchart> </apexchart>
</div> </div>
<div class="relative p-5 z-10 flex flex-col md:flex-row justify-between">
<div class="flex items-center pb-2 md:pb-0"> <div class="relative py-3 md:py-5 px-3 z-10 flex flex-col sm:flex-row justify-between gap-3">
<div class="h-10 w-10 mr-5 rounded-full bg-gray-50 relative"> <div class="flex gap-3 md:gap-4 w-full items-center ">
<svg class="w-6 m-2 text-gray-300" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor"> <!-- Avatar -->
<div class="h-10 w-10 mt-2 self-start rounded-full bg-gray-50 relative">
<svg class="w-6 m-2 text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd" /> clip-rule="evenodd" />
</svg> </svg>
@ -108,52 +140,26 @@
</div> </div>
</div> </div>
<div class="flex-grow"> <!-- Name & Info -->
<div class="flex flex-col xxs:flex-row w-full gap-2">
<!-- Name --> <!-- Name -->
<div class="text-gray-700 dark:text-neutral-200 group" <div class="flex flex-col flex-grow gap-1">
:title="$t('createdOn') + dateTime(new Date(client.createdAt))"> <div class="text-gray-700 dark:text-neutral-200 group text-sm md:text-base"
:title="$t('createdOn') + dateTime(new Date(client.createdAt))">
<!-- Show -->
<input v-show="clientEditNameId === client.id" v-model="clientEditName"
v-on:keyup.enter="updateClientName(client, clientEditName); clientEditName = null; clientEditNameId = null;"
v-on:keyup.escape="clientEditName = null; clientEditNameId = null;"
:ref="'client-' + client.id + '-name'"
class="rounded px-1 border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 dark:placeholder:text-neutral-500 outline-none w-30" />
<span v-show="clientEditNameId !== client.id"
class="inline-block border-t-2 border-b-2 border-transparent">{{client.name}}</span>
<!-- Edit -->
<span v-show="clientEditNameId !== client.id"
@click="clientEditName = client.name; clientEditNameId = client.id; setTimeout(() => $refs['client-' + client.id + '-name'][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>
</div>
<!-- Info -->
<div class="text-gray-400 dark:text-neutral-400 text-xs">
<!-- Address -->
<span class="group block md:inline-block pb-1 md:pb-0">
<!-- Show --> <!-- Show -->
<input v-show="clientEditAddressId === client.id" v-model="clientEditAddress" <input v-show="clientEditNameId === client.id" v-model="clientEditName"
v-on:keyup.enter="updateClientAddress(client, clientEditAddress); clientEditAddress = null; clientEditAddressId = null;" v-on:keyup.enter="updateClientName(client, clientEditName); clientEditName = null; clientEditNameId = null;"
v-on:keyup.escape="clientEditAddress = null; clientEditAddressId = null;" v-on:keyup.escape="clientEditName = null; clientEditNameId = null;"
:ref="'client-' + client.id + '-address'" :ref="'client-' + client.id + '-name'"
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-20 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500" /> class="rounded px-1 border-2 dark:bg-neutral-700 border-gray-100 dark:border-neutral-600 focus:border-gray-200 dark:focus:border-neutral-500 dark:placeholder:text-neutral-500 outline-none w-30" />
<span v-show="clientEditAddressId !== client.id" <span v-show="clientEditNameId !== client.id"
class="inline-block border-t-2 border-b-2 border-transparent">{{client.address}}</span> class="border-t-2 border-b-2 border-transparent">{{client.name}}</span>
<!-- Edit --> <!-- Edit -->
<span v-show="clientEditAddressId !== client.id" <span v-show="clientEditNameId !== client.id"
@click="clientEditAddress = client.address; clientEditAddressId = client.id; setTimeout(() => $refs['client-' + client.id + '-address'][0].select(), 1);" @click="clientEditName = client.name; clientEditNameId = client.id; setTimeout(() => $refs['client-' + client.id + '-name'][0].select(), 1);"
class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"> class="cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none" class="h-4 w-4 inline align-middle opacity-25 hover:opacity-100" fill="none"
@ -162,39 +168,105 @@
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" /> 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> </svg>
</span> </span>
</span> </div>
<!-- Address -->
<div 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="clientEditAddressId === client.id" v-model="clientEditAddress"
v-on:keyup.enter="updateClientAddress(client, clientEditAddress); clientEditAddress = null; clientEditAddressId = null;"
v-on:keyup.escape="clientEditAddress = null; clientEditAddressId = null;"
:ref="'client-' + client.id + '-address'"
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-20 text-black dark:text-neutral-300 dark:placeholder:text-neutral-500" />
<span v-show="clientEditAddressId !== client.id"
class="inline-block ">{{client.address}}</span>
<!-- Edit -->
<span v-show="clientEditAddressId !== client.id"
@click="clientEditAddress = client.address; clientEditAddressId = client.id; setTimeout(() => $refs['client-' + client.id + '-address'][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>
<!-- Inline Transfer TX -->
<span v-if="!uiTrafficStats && client.transferTx" class="whitespace-nowrap" :title="$t('totalDownload') + bytes(client.transferTx)">
·
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
{{client.transferTxCurrent | bytes}}/s
</span>
<!-- Inline Transfer RX -->
<span v-if="!uiTrafficStats && client.transferRx" class="whitespace-nowrap" :title="$t('totalUpload') + bytes(client.transferRx)">
·
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
{{client.transferRxCurrent | bytes}}/s
</span>
<!-- Last seen -->
<span class="text-gray-400 dark:text-neutral-500 whitespace-nowrap" v-if="client.latestHandshakeAt"
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))">
{{!uiTrafficStats ? " · " : ""}}{{new Date(client.latestHandshakeAt) | timeago}}
</span>
</div>
</div>
<!-- Info -->
<div v-if="uiTrafficStats"
class="flex gap-2 items-center shrink-0 text-gray-400 dark:text-neutral-400 text-xs mt-px justify-end">
<!-- Transfer TX --> <!-- Transfer TX -->
<span v-if="client.transferTx" :title="$t('totalDownload') + bytes(client.transferTx)"> <div class="min-w-20 md:min-w-24" v-if="client.transferTx">
· <span class="flex gap-1" :title="$t('totalDownload') + bytes(client.transferTx)">
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" <svg class="align-middle h-3 inline mt-0.5" xmlns="http://www.w3.org/2000/svg"
fill="currentColor"> viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z" d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z"
clip-rule="evenodd" /> clip-rule="evenodd" />
</svg> </svg>
{{client.transferTxCurrent | bytes}}/s <div>
</span> <span class="text-gray-700 dark:text-neutral-200">{{client.transferTxCurrent |
bytes}}/s</span>
<!-- Total TX -->
<br><span class="font-regular" style="font-size:0.85em">{{bytes(client.transferTx)}}</span>
</div>
</span>
</div>
<!-- Transfer RX --> <!-- Transfer RX -->
<span v-if="client.transferRx" :title="$t('totalUpload') + bytes(client.transferRx)"> <div class="min-w-20 md:min-w-24" v-if="client.transferRx">
· <span class="flex gap-1" :title="$t('totalUpload') + bytes(client.transferRx)">
<svg class="align-middle h-3 inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor"> <svg class="align-middle h-3 inline mt-0.5" xmlns="http://www.w3.org/2000/svg"
<path fill-rule="evenodd" viewBox="0 0 20 20" fill="currentColor">
d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z" <path fill-rule="evenodd"
clip-rule="evenodd" /> d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
</svg> clip-rule="evenodd" />
{{client.transferRxCurrent | bytes}}/s </svg>
</span> <div>
<span class="text-gray-700 dark:text-neutral-200">{{client.transferRxCurrent |
<!-- Last seen --> bytes}}/s</span>
<span v-if="client.latestHandshakeAt" <!-- Total RX -->
:title="$t('lastSeen') + dateTime(new Date(client.latestHandshakeAt))"> <br><span class="font-regular" style="font-size:0.85em">{{bytes(client.transferRx)}}</span>
· {{new Date(client.latestHandshakeAt) | timeago}} </div>
</span> </span>
</div>
</div> </div>
</div> </div>
<!-- </div> --> <!-- <div class="flex flex-grow items-center"> -->
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
@ -214,9 +286,14 @@
<!-- Show QR--> <!-- Show QR-->
<button <button :disabled="!client.downloadableConfig"
class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition" class="align-middle bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 p-2 rounded transition"
:title="$t('showQR')" @click="qrcode = `./api/wireguard/client/${client.id}/qrcode.svg`"> :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" <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@ -225,9 +302,16 @@
</button> </button>
<!-- Download Config --> <!-- Download Config -->
<a :href="'./api/wireguard/client/' + client.id + '/configuration'" download <a :disabled="!client.downloadableConfig"
class="align-middle inline-block bg-gray-100 dark:bg-neutral-600 dark:text-neutral-300 hover:bg-red-800 dark:hover:bg-red-800 hover:text-white dark:hover:text-white p-2 rounded transition" :href="'./api/wireguard/client/' + client.id + '/configuration'"
:title="$t('downloadConfig')"> :download="client.downloadableConfig ? 'configuration' : null"
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('downloadConfig')"
@click="if(!client.downloadableConfig) { $event.preventDefault(); }">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@ -490,7 +574,7 @@
</div> </div>
<p v-cloak class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs"> <a class="hover:underline" target="_blank" <p v-cloak class="text-center m-10 text-gray-300 dark:text-neutral-600 text-xs"> <a class="hover:underline" target="_blank"
href="https://github.com/wg-easy/wg-easy">WireGuard Easy</a> © 2021-2024 by <a class="hover:underline" target="_blank" href="https://github.com/wg-easy/wg-easy">WireGuard Easy</a> © 2021-2024 by <a class="hover:underline" target="_blank"
href="https://emilenijssen.nl/?ref=wg-easy">Emile Nijssen</a> is licensed under <a class="hover:underline" target="_blank" href="https://emilenijssen.nl/?ref=wg-easy">Emile Nijssen</a> is licensed under <a class="hover:underline" target="_blank"
href="http://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a> · <a class="hover:underline" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a> · <a class="hover:underline"
@ -509,4 +593,4 @@
<script src="./js/app.js"></script> <script src="./js/app.js"></script>
</body> </body>
</html> </html>

14
src/www/js/api.js

@ -43,6 +43,20 @@ class API {
}); });
} }
async getuiTrafficStats() {
return this.call({
method: 'get',
path: '/ui-traffic-stats',
});
}
async getChartType() {
return this.call({
method: 'get',
path: '/ui-chart-type',
});
}
async getSession() { async getSession() {
return this.call({ return this.call({
method: 'get', method: 'get',

170
src/www/js/app.js

@ -29,8 +29,24 @@ const i18n = new VueI18n({
messages, messages,
}); });
const UI_CHART_TYPES = [
{ type: false, strokeWidth: 0 },
{ type: 'line', strokeWidth: 3 },
{ type: 'area', strokeWidth: 0 },
{ type: 'bar', strokeWidth: 0 },
];
const CHART_COLORS = {
rx: { light: 'rgba(128,128,128,0.3)', dark: 'rgba(255,255,255,0.3)' },
tx: { light: 'rgba(128,128,128,0.4)', dark: 'rgba(255,255,255,0.3)' },
gradient: { light: ['rgba(0,0,0,1.0)', 'rgba(0,0,0,1.0)'], dark: ['rgba(128,128,128,0)', 'rgba(128,128,128,0)'] },
};
new Vue({ new Vue({
el: '#app', el: '#app',
components: {
apexchart: VueApexCharts,
},
i18n, i18n,
data: { data: {
authenticated: null, authenticated: null,
@ -52,12 +68,16 @@ new Vue({
currentRelease: null, currentRelease: null,
latestRelease: null, latestRelease: null,
isDark: null, uiTrafficStats: false,
uiChartType: 0,
uiShowCharts: localStorage.getItem('uiShowCharts') === '1',
uiTheme: localStorage.theme || 'auto',
prefersDarkScheme: window.matchMedia('(prefers-color-scheme: dark)'),
chartOptions: { chartOptions: {
chart: { chart: {
background: 'transparent', background: 'transparent',
type: 'bar',
stacked: false, stacked: false,
toolbar: { toolbar: {
show: false, show: false,
@ -65,11 +85,27 @@ new Vue({
animations: { animations: {
enabled: false, enabled: false,
}, },
parentHeightOffset: 0,
sparkline: {
enabled: true,
},
},
colors: [],
stroke: {
curve: 'smooth',
},
fill: {
type: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: CHART_COLORS.gradient[this.theme],
inverseColors: false,
opacityTo: 0,
stops: [0, 100],
},
}, },
colors: [
'#DDDDDD', // rx
'#EEEEEE', // tx
],
dataLabels: { dataLabels: {
enabled: false, enabled: false,
}, },
@ -83,10 +119,10 @@ new Vue({
show: false, show: false,
}, },
axisTicks: { axisTicks: {
show: true, show: false,
}, },
axisBorder: { axisBorder: {
show: true, show: false,
}, },
}, },
yaxis: { yaxis: {
@ -152,27 +188,42 @@ new Vue({
// Debug // Debug
// client.transferRx = this.clientsPersist[client.id].transferRxPrevious + Math.random() * 1000; // client.transferRx = this.clientsPersist[client.id].transferRxPrevious + Math.random() * 1000;
// client.transferTx = this.clientsPersist[client.id].transferTxPrevious + Math.random() * 1000; // client.transferTx = this.clientsPersist[client.id].transferTxPrevious + Math.random() * 1000;
// client.latestHandshakeAt = new Date();
// this.requiresPassword = true;
if (updateCharts) { this.clientsPersist[client.id].transferRxCurrent = client.transferRx - this.clientsPersist[client.id].transferRxPrevious;
this.clientsPersist[client.id].transferRxCurrent = client.transferRx - this.clientsPersist[client.id].transferRxPrevious; this.clientsPersist[client.id].transferRxPrevious = client.transferRx;
this.clientsPersist[client.id].transferRxPrevious = client.transferRx; this.clientsPersist[client.id].transferTxCurrent = client.transferTx - this.clientsPersist[client.id].transferTxPrevious;
this.clientsPersist[client.id].transferTxCurrent = client.transferTx - this.clientsPersist[client.id].transferTxPrevious; this.clientsPersist[client.id].transferTxPrevious = client.transferTx;
this.clientsPersist[client.id].transferTxPrevious = client.transferTx;
if (updateCharts) {
this.clientsPersist[client.id].transferRxHistory.push(this.clientsPersist[client.id].transferRxCurrent); this.clientsPersist[client.id].transferRxHistory.push(this.clientsPersist[client.id].transferRxCurrent);
this.clientsPersist[client.id].transferRxHistory.shift(); this.clientsPersist[client.id].transferRxHistory.shift();
this.clientsPersist[client.id].transferTxHistory.push(this.clientsPersist[client.id].transferTxCurrent); this.clientsPersist[client.id].transferTxHistory.push(this.clientsPersist[client.id].transferTxCurrent);
this.clientsPersist[client.id].transferTxHistory.shift(); this.clientsPersist[client.id].transferTxHistory.shift();
this.clientsPersist[client.id].transferTxSeries = [{
name: 'Tx',
data: this.clientsPersist[client.id].transferTxHistory,
}];
this.clientsPersist[client.id].transferRxSeries = [{
name: 'Rx',
data: this.clientsPersist[client.id].transferRxHistory,
}];
client.transferTxHistory = this.clientsPersist[client.id].transferTxHistory;
client.transferRxHistory = this.clientsPersist[client.id].transferRxHistory;
client.transferMax = Math.max(...client.transferTxHistory, ...client.transferRxHistory);
client.transferTxSeries = this.clientsPersist[client.id].transferTxSeries;
client.transferRxSeries = this.clientsPersist[client.id].transferRxSeries;
} }
client.transferTxCurrent = this.clientsPersist[client.id].transferTxCurrent; client.transferTxCurrent = this.clientsPersist[client.id].transferTxCurrent;
client.transferRxCurrent = this.clientsPersist[client.id].transferRxCurrent; client.transferRxCurrent = this.clientsPersist[client.id].transferRxCurrent;
client.transferTxHistory = this.clientsPersist[client.id].transferTxHistory;
client.transferRxHistory = this.clientsPersist[client.id].transferRxHistory;
client.transferMax = Math.max(...client.transferTxHistory, ...client.transferRxHistory);
client.hoverTx = this.clientsPersist[client.id].hoverTx; client.hoverTx = this.clientsPersist[client.id].hoverTx;
client.hoverRx = this.clientsPersist[client.id].hoverRx; client.hoverRx = this.clientsPersist[client.id].hoverRx;
@ -249,14 +300,25 @@ new Vue({
.finally(() => this.refresh().catch(console.error)); .finally(() => this.refresh().catch(console.error));
}, },
toggleTheme() { toggleTheme() {
if (this.isDark) { const themes = ['light', 'dark', 'auto'];
localStorage.theme = 'light'; const currentIndex = themes.indexOf(this.uiTheme);
document.documentElement.classList.remove('dark'); const newIndex = (currentIndex + 1) % themes.length;
} else { this.uiTheme = themes[newIndex];
localStorage.theme = 'dark'; localStorage.theme = this.uiTheme;
document.documentElement.classList.add('dark'); this.setTheme(this.uiTheme);
},
setTheme(theme) {
const { classList } = document.documentElement;
const shouldAddDarkClass = theme === 'dark' || (theme === 'auto' && this.prefersDarkScheme.matches);
classList.toggle('dark', shouldAddDarkClass);
},
handlePrefersChange(e) {
if (localStorage.theme === 'auto') {
this.setTheme(e.matches ? 'dark' : 'light');
} }
this.isDark = !this.isDark; },
toggleCharts() {
localStorage.setItem('uiShowCharts', this.uiShowCharts ? 1 : 0);
}, },
}, },
filters: { filters: {
@ -266,10 +328,8 @@ new Vue({
}, },
}, },
mounted() { mounted() {
this.isDark = false; this.prefersDarkScheme.addListener(this.handlePrefersChange);
if (localStorage.theme === 'dark') { this.setTheme(this.uiTheme);
this.isDark = true;
}
this.api = new API(); this.api = new API();
this.api.getSession() this.api.getSession()
@ -277,7 +337,7 @@ new Vue({
this.authenticated = session.authenticated; this.authenticated = session.authenticated;
this.requiresPassword = session.requiresPassword; this.requiresPassword = session.requiresPassword;
this.refresh({ this.refresh({
updateCharts: true, updateCharts: this.updateCharts,
}).catch((err) => { }).catch((err) => {
alert(err.message || err.toString()); alert(err.message || err.toString());
}); });
@ -288,10 +348,26 @@ new Vue({
setInterval(() => { setInterval(() => {
this.refresh({ this.refresh({
updateCharts: true, updateCharts: this.updateCharts,
}).catch(console.error); }).catch(console.error);
}, 1000); }, 1000);
this.api.getuiTrafficStats()
.then((res) => {
this.uiTrafficStats = res;
})
.catch(() => {
this.uiTrafficStats = false;
});
this.api.getChartType()
.then((res) => {
this.uiChartType = parseInt(res, 10);
})
.catch(() => {
this.uiChartType = 0;
});
Promise.resolve().then(async () => { Promise.resolve().then(async () => {
const lang = await this.api.getLang(); const lang = await this.api.getLang();
if (lang !== localStorage.getItem('lang') && i18n.availableLocales.includes(lang)) { if (lang !== localStorage.getItem('lang') && i18n.availableLocales.includes(lang)) {
@ -314,13 +390,39 @@ new Vue({
return releasesArray[0]; return releasesArray[0];
}); });
console.log(`Current Release: ${currentRelease}`);
console.log(`Latest Release: ${latestRelease.version}`);
if (currentRelease >= latestRelease.version) return; if (currentRelease >= latestRelease.version) return;
this.currentRelease = currentRelease; this.currentRelease = currentRelease;
this.latestRelease = latestRelease; this.latestRelease = latestRelease;
}).catch(console.error); }).catch((err) => console.error(err));
},
computed: {
chartOptionsTX() {
const opts = {
...this.chartOptions,
colors: [CHART_COLORS.tx[this.theme]],
};
opts.chart.type = UI_CHART_TYPES[this.uiChartType].type || false;
opts.stroke.width = UI_CHART_TYPES[this.uiChartType].strokeWidth;
return opts;
},
chartOptionsRX() {
const opts = {
...this.chartOptions,
colors: [CHART_COLORS.rx[this.theme]],
};
opts.chart.type = UI_CHART_TYPES[this.uiChartType].type || false;
opts.stroke.width = UI_CHART_TYPES[this.uiChartType].strokeWidth;
return opts;
},
updateCharts() {
return this.uiChartType > 0 && this.uiShowCharts;
},
theme() {
if (this.uiTheme === 'auto') {
return this.prefersDarkScheme.matches ? 'dark' : 'light';
}
return this.uiTheme;
},
}, },
}); });

88
src/www/js/i18n.js

@ -23,10 +23,13 @@ const messages = { // eslint-disable-line no-unused-vars
disableClient: 'Disable Client', disableClient: 'Disable Client',
enableClient: 'Enable Client', enableClient: 'Enable Client',
noClients: 'There are no clients yet.', noClients: 'There are no clients yet.',
noPrivKey: 'This client has no known private key. Cannot create Configuration.',
showQR: 'Show QR Code', showQR: 'Show QR Code',
downloadConfig: 'Download Configuration', downloadConfig: 'Download Configuration',
madeBy: 'Made by', madeBy: 'Made by',
donate: 'Donate', donate: 'Donate',
toggleCharts: 'Show/hide Charts',
theme: { dark: 'Dark theme', light: 'Light theme', auto: 'Auto theme' },
}, },
ua: { ua: {
name: 'Ім`я', name: 'Ім`я',
@ -213,6 +216,7 @@ const messages = { // eslint-disable-line no-unused-vars
disableClient: 'Client deaktivieren', disableClient: 'Client deaktivieren',
enableClient: 'Client aktivieren', enableClient: 'Client aktivieren',
noClients: 'Es wurden noch keine Clients konfiguriert.', noClients: 'Es wurden noch keine Clients konfiguriert.',
noPrivKey: 'Es ist kein Private Key für diesen Client bekannt. Eine Konfiguration kann nicht erstellt werden.',
showQR: 'Zeige den QR Code', showQR: 'Zeige den QR Code',
downloadConfig: 'Konfiguration herunterladen', downloadConfig: 'Konfiguration herunterladen',
madeBy: 'Erstellt von', madeBy: 'Erstellt von',
@ -271,6 +275,8 @@ const messages = { // eslint-disable-line no-unused-vars
downloadConfig: 'Descargar configuración', downloadConfig: 'Descargar configuración',
madeBy: 'Hecho por', madeBy: 'Hecho por',
donate: 'Donar', donate: 'Donar',
toggleCharts: 'Mostrar/Ocultar gráficos',
theme: { dark: 'Modo oscuro', light: 'Modo claro', auto: 'Modo automático' },
}, },
ko: { ko: {
name: '이름', name: '이름',
@ -461,4 +467,86 @@ const messages = { // eslint-disable-line no-unused-vars
madeBy: '由', madeBy: '由',
donate: '捐贈', donate: '捐贈',
}, },
it: {
name: 'Nome',
password: 'Password',
signIn: 'Accedi',
logout: 'Esci',
updateAvailable: 'È disponibile un aggiornamento!',
update: 'Aggiorna',
clients: 'Client',
new: 'Nuovo',
deleteClient: 'Elimina Client',
deleteDialog1: 'Sei sicuro di voler eliminare',
deleteDialog2: 'Questa azione non può essere annullata.',
cancel: 'Annulla',
create: 'Crea',
createdOn: 'Creato il ',
lastSeen: 'Visto l\'ultima volta il ',
totalDownload: 'Totale Download: ',
totalUpload: 'Totale Upload: ',
newClient: 'Nuovo Client',
disableClient: 'Disabilita Client',
enableClient: 'Abilita Client',
noClients: 'Non ci sono ancora client.',
showQR: 'Mostra codice QR',
downloadConfig: 'Scarica configurazione',
madeBy: 'Realizzato da',
donate: 'Donazione',
},
th: {
name: 'ชื่อ',
password: 'รหัสผ่าน',
signIn: 'ลงชื่อเข้าใช้',
logout: 'ออกจากระบบ',
updateAvailable: 'มีอัปเดตพร้อมใช้งาน!',
update: 'อัปเดต',
clients: 'Clients',
new: 'ใหม่',
deleteClient: 'ลบ Client',
deleteDialog1: 'คุณแน่ใจหรือไม่ว่าต้องการลบ',
deleteDialog2: 'การกระทำนี้;ไม่สามารถยกเลิกได้',
cancel: 'ยกเลิก',
create: 'สร้าง',
createdOn: 'สร้างเมื่อ ',
lastSeen: 'เห็นครั้งสุดท้ายเมื่อ ',
totalDownload: 'ดาวน์โหลดทั้งหมด: ',
totalUpload: 'อัพโหลดทั้งหมด: ',
newClient: 'Client ใหม่',
disableClient: 'ปิดการใช้งาน Client',
enableClient: 'เปิดการใช้งาน Client',
noClients: 'ยังไม่มี Clients เลย',
showQR: 'แสดงรหัส QR',
downloadConfig: 'ดาวน์โหลดการตั้งค่า',
madeBy: 'สร้างโดย',
donate: 'บริจาค',
},
hi: { // github.com/rahilarious
name: 'नाम',
password: 'पासवर्ड',
signIn: 'लॉगिन',
logout: 'लॉगआउट',
updateAvailable: 'अपडेट उपलब्ध है!',
update: 'अपडेट',
clients: 'उपयोगकर्ताये',
new: 'नया',
deleteClient: 'उपयोगकर्ता हटाएँ',
deleteDialog1: 'क्या आपको पक्का हटाना है',
deleteDialog2: 'यह निर्णय पलट नहीं सकता।',
cancel: 'कुछ ना करें',
create: 'बनाएं',
createdOn: 'सर्जन तारीख ',
lastSeen: 'पिछली बार देखे गए थे ',
totalDownload: 'कुल डाउनलोड: ',
totalUpload: 'कुल अपलोड: ',
newClient: 'नया उपयोगकर्ता',
disableClient: 'उपयोगकर्ता स्थगित कीजिये',
enableClient: 'उपयोगकर्ता शुरू कीजिये',
noClients: 'अभी तक कोई भी उपयोगकर्ता नहीं है।',
noPrivKey: 'ये उपयोगकर्ता की कोई भी गुप्त चाबी नहीं हे। बना नहीं सकते।',
showQR: 'क्यू आर कोड देखिये',
downloadConfig: 'डाउनलोड कॉन्फीग्यूरेशन',
madeBy: 'सर्जक',
donate: 'दान करें',
},
}; };

8
src/www/js/vendor/apexcharts.min.js

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save