forked from mirrors/amnezia-wg-easy
		
	Version 13: new framework, UI_CHART_TYPE, some bugfixes and more (#967)
Thanks to all contributors to get us able to release v13!
This commit is contained in:
		
						commit
						519f4efa20
					
				
					 25 changed files with 1301 additions and 1226 deletions
				
			
		
							
								
								
									
										1
									
								
								.dockerignore
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.dockerignore
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
/src/node_modules
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/CODEOWNERS
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.github/CODEOWNERS
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
# Copyright (c) Emile Nijssen
 | 
			
		||||
# Founder and Codeowner of WireGuard Easy (wg-easy)
 | 
			
		||||
							
								
								
									
										38
									
								
								.github/workflows/deploy-pr.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								.github/workflows/deploy-pr.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/lint.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/lint.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -18,7 +18,7 @@ jobs:
 | 
			
		|||
      - name: Setup Node
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '18'
 | 
			
		||||
          node-version: '20'
 | 
			
		||||
          check-latest: true
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
          cache-dependency-path: |
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								.github/workflows/npm-update-bot.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/npm-update-bot.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -20,7 +20,7 @@ jobs:
 | 
			
		|||
      - name: Setup Node
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '18'
 | 
			
		||||
          node-version: '20'
 | 
			
		||||
          check-latest: true
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
          cache-dependency-path: |
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
/config
 | 
			
		||||
/wg0.conf
 | 
			
		||||
/wg0.json
 | 
			
		||||
/src/node_modules
 | 
			
		||||
.DS_Store
 | 
			
		||||
*.swp
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										27
									
								
								Dockerfile
									
										
									
									
									
								
							
							
						
						
									
										27
									
								
								Dockerfile
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,17 +1,20 @@
 | 
			
		|||
# There's an issue with node:20-alpine.
 | 
			
		||||
# Docker deployment is canceled after 25< minutes.
 | 
			
		||||
 | 
			
		||||
# As a workaround we have to build on nodejs 18
 | 
			
		||||
# nodejs 20 hangs on build with armv6/armv7
 | 
			
		||||
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 src/ /app/
 | 
			
		||||
COPY src /app
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
RUN npm ci --omit=dev &&\
 | 
			
		||||
    mv node_modules /node_modules
 | 
			
		||||
 | 
			
		||||
# Copy build result to a new image.
 | 
			
		||||
# 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
 | 
			
		||||
 | 
			
		||||
# Move node_modules one directory up, so during development
 | 
			
		||||
| 
						 | 
				
			
			@ -23,14 +26,6 @@ COPY --from=build_node_modules /app /app
 | 
			
		|||
# than what runs inside of docker.
 | 
			
		||||
COPY --from=build_node_modules /node_modules /node_modules
 | 
			
		||||
 | 
			
		||||
RUN \
 | 
			
		||||
    # Enable this to run `npm run serve`
 | 
			
		||||
    npm i -g nodemon &&\
 | 
			
		||||
    # Workaround CVE-2023-42282
 | 
			
		||||
    npm uninstall -g ip &&\
 | 
			
		||||
    # Delete unnecessary files 
 | 
			
		||||
    npm cache clean --force && rm -rf ~/.npm
 | 
			
		||||
 | 
			
		||||
# Install Linux packages
 | 
			
		||||
RUN apk add --no-cache \
 | 
			
		||||
    dpkg \
 | 
			
		||||
| 
						 | 
				
			
			@ -42,9 +37,9 @@ RUN apk add --no-cache \
 | 
			
		|||
# 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
 | 
			
		||||
 | 
			
		||||
# Expose Ports
 | 
			
		||||
EXPOSE 51820/udp
 | 
			
		||||
EXPOSE 51821/tcp
 | 
			
		||||
# Expose Ports (If needed on buildtime)
 | 
			
		||||
#EXPOSE 51820/udp
 | 
			
		||||
#EXPOSE 51821/tcp
 | 
			
		||||
 | 
			
		||||
# Set Environment
 | 
			
		||||
ENV DEBUG=Server,WireGuard
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										32
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										32
									
								
								README.md
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -37,9 +37,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:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
$ curl -sSL https://get.docker.com | sh
 | 
			
		||||
$ sudo usermod -aG docker $(whoami)
 | 
			
		||||
$ exit
 | 
			
		||||
curl -sSL https://get.docker.com | sh
 | 
			
		||||
sudo usermod -aG docker $(whoami)
 | 
			
		||||
exit
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
And log in again.
 | 
			
		||||
| 
						 | 
				
			
			@ -48,12 +48,14 @@ And log in again.
 | 
			
		|||
 | 
			
		||||
To automatically install & run wg-easy, simply run:
 | 
			
		||||
 | 
			
		||||
<pre>
 | 
			
		||||
$ docker run -d \
 | 
			
		||||
```
 | 
			
		||||
  docker run -d \
 | 
			
		||||
  --name=wg-easy \
 | 
			
		||||
  -e LANG=de \
 | 
			
		||||
  -e WG_HOST=<b>🚨YOUR_SERVER_IP</b> \
 | 
			
		||||
  -e PASSWORD=<b>🚨YOUR_ADMIN_PASSWORD</b> \
 | 
			
		||||
  -e WG_HOST=<🚨YOUR_SERVER_IP> \
 | 
			
		||||
  -e PASSWORD=<🚨YOUR_ADMIN_PASSWORD> \
 | 
			
		||||
  -e PORT=51821 \
 | 
			
		||||
  -e WG_PORT=51820 \
 | 
			
		||||
  -v ~/.wg-easy:/etc/wireguard \
 | 
			
		||||
  -p 51820:51820/udp \
 | 
			
		||||
  -p 51821:51821/tcp \
 | 
			
		||||
| 
						 | 
				
			
			@ -63,7 +65,7 @@ $ docker run -d \
 | 
			
		|||
  --sysctl="net.ipv4.ip_forward=1" \
 | 
			
		||||
  --restart unless-stopped \
 | 
			
		||||
  ghcr.io/wg-easy/wg-easy
 | 
			
		||||
</pre>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
> 💡 Replace `YOUR_SERVER_IP` with your WAN IP, or a Dynamic DNS hostname.
 | 
			
		||||
>
 | 
			
		||||
| 
						 | 
				
			
			@ -73,6 +75,10 @@ The Web UI will now be available on `http://0.0.0.0:51821`.
 | 
			
		|||
 | 
			
		||||
> 💡 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
 | 
			
		||||
 | 
			
		||||
Are you enjoying this project? [Buy Emile a beer!](https://github.com/sponsors/WeeJeWel) 🍻
 | 
			
		||||
| 
						 | 
				
			
			@ -98,7 +104,7 @@ 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_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. |
 | 
			
		||||
| `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). |
 | 
			
		||||
| `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 |
 | 
			
		||||
 | 
			
		||||
> If you change `WG_PORT`, make sure to also change the exposed port.
 | 
			
		||||
| 
						 | 
				
			
			@ -115,6 +121,14 @@ docker pull ghcr.io/wg-easy/wg-easy
 | 
			
		|||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
* [Using WireGuard-Easy with Pi-Hole](https://github.com/wg-easy/wg-easy/wiki/Using-WireGuard-Easy-with-Pi-Hole)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
version: "3.8"
 | 
			
		||||
services:
 | 
			
		||||
  wg-easy:
 | 
			
		||||
    image: wg-easy
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,3 @@
 | 
			
		|||
version: "3.8"
 | 
			
		||||
volumes:
 | 
			
		||||
  etc_wireguard:
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -6,7 +5,7 @@ services:
 | 
			
		|||
  wg-easy:
 | 
			
		||||
    environment:
 | 
			
		||||
      # Change Language:
 | 
			
		||||
      # (Supports: en, ua, ru, tr, no, pl, fr, de, ca, es, ko, vi, nl, is, pt, chs, cht, it, th)
 | 
			
		||||
      # (Supports: en, ua, ru, tr, no, pl, fr, de, ca, es, ko, vi, nl, is, pt, chs, cht, it, th, hi)
 | 
			
		||||
      - LANG=de
 | 
			
		||||
      # ⚠️ Required:
 | 
			
		||||
      # Change this to your host's public address
 | 
			
		||||
| 
						 | 
				
			
			@ -14,6 +13,7 @@ services:
 | 
			
		|||
 | 
			
		||||
      # Optional:
 | 
			
		||||
      # - PASSWORD=foobar123
 | 
			
		||||
      # - PORT=51821
 | 
			
		||||
      # - WG_PORT=51820
 | 
			
		||||
      # - WG_DEFAULT_ADDRESS=10.8.0.x
 | 
			
		||||
      # - WG_DEFAULT_DNS=1.1.1.1
 | 
			
		||||
| 
						 | 
				
			
			@ -25,6 +25,7 @@ services:
 | 
			
		|||
      # - WG_PRE_DOWN=echo "Pre Down" > /etc/wireguard/pre-down.txt
 | 
			
		||||
      # - WG_POST_DOWN=echo "Post Down" > /etc/wireguard/post-down.txt
 | 
			
		||||
      # - UI_TRAFFIC_STATS=true
 | 
			
		||||
      # - UI_CHART_TYPE=0 # (0 Charts disabled, 1 # Line chart, 2 # Area chart, 3 # Bar chart)
 | 
			
		||||
 | 
			
		||||
    image: ghcr.io/wg-easy/wg-easy
 | 
			
		||||
    container_name: wg-easy
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,7 @@
 | 
			
		|||
  "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."
 | 
			
		||||
  "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,7 +2,7 @@
 | 
			
		|||
  "version": "1.0.1",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								src/.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								src/.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -1 +0,0 @@
 | 
			
		|||
/node_modules
 | 
			
		||||
| 
						 | 
				
			
			@ -35,3 +35,4 @@ iptables -D FORWARD -o wg0 -j ACCEPT;
 | 
			
		|||
`.split('\n').join(' ');
 | 
			
		||||
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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,15 +1,27 @@
 | 
			
		|||
'use strict';
 | 
			
		||||
 | 
			
		||||
const path = require('path');
 | 
			
		||||
const bcrypt = require('bcryptjs');
 | 
			
		||||
const crypto = require('node:crypto');
 | 
			
		||||
const { createServer } = require('node:http');
 | 
			
		||||
const { stat, readFile } = require('node:fs/promises');
 | 
			
		||||
const { join } = require('node:path');
 | 
			
		||||
 | 
			
		||||
const express = require('express');
 | 
			
		||||
const expressSession = require('express-session');
 | 
			
		||||
const debug = require('debug')('Server');
 | 
			
		||||
 | 
			
		||||
const Util = require('./Util');
 | 
			
		||||
const ServerError = require('./ServerError');
 | 
			
		||||
const {
 | 
			
		||||
  createApp,
 | 
			
		||||
  createError,
 | 
			
		||||
  createRouter,
 | 
			
		||||
  defineEventHandler,
 | 
			
		||||
  fromNodeMiddleware,
 | 
			
		||||
  getRouterParam,
 | 
			
		||||
  toNodeListener,
 | 
			
		||||
  readBody,
 | 
			
		||||
  setHeader,
 | 
			
		||||
  serveStatic,
 | 
			
		||||
} = require('h3');
 | 
			
		||||
 | 
			
		||||
const WireGuard = require('../services/WireGuard');
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
| 
						 | 
				
			
			@ -19,41 +31,50 @@ const {
 | 
			
		|||
  PASSWORD,
 | 
			
		||||
  LANG,
 | 
			
		||||
  UI_TRAFFIC_STATS,
 | 
			
		||||
  UI_CHART_TYPE,
 | 
			
		||||
} = require('../config');
 | 
			
		||||
 | 
			
		||||
module.exports = class Server {
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    // Express
 | 
			
		||||
    this.app = express()
 | 
			
		||||
      .disable('etag')
 | 
			
		||||
      .use('/', express.static(path.join(__dirname, '..', 'www')))
 | 
			
		||||
      .use(express.json())
 | 
			
		||||
      .use(expressSession({
 | 
			
		||||
    const app = createApp();
 | 
			
		||||
    this.app = app;
 | 
			
		||||
 | 
			
		||||
    app.use(fromNodeMiddleware(expressSession({
 | 
			
		||||
      secret: crypto.randomBytes(256).toString('hex'),
 | 
			
		||||
      resave: true,
 | 
			
		||||
      saveUninitialized: true,
 | 
			
		||||
        cookie: {
 | 
			
		||||
          httpOnly: true,
 | 
			
		||||
        },
 | 
			
		||||
    })));
 | 
			
		||||
 | 
			
		||||
    const router = createRouter();
 | 
			
		||||
    app.use(router);
 | 
			
		||||
 | 
			
		||||
    router
 | 
			
		||||
      .get('/api/release', defineEventHandler((event) => {
 | 
			
		||||
        setHeader(event, 'Content-Type', 'application/json');
 | 
			
		||||
        return RELEASE;
 | 
			
		||||
      }))
 | 
			
		||||
 | 
			
		||||
      .get('/api/release', (Util.promisify(async () => {
 | 
			
		||||
        return RELEASE;
 | 
			
		||||
      })))
 | 
			
		||||
      .get('/api/lang', defineEventHandler((event) => {
 | 
			
		||||
        setHeader(event, 'Content-Type', 'application/json');
 | 
			
		||||
        return `"${LANG}"`;
 | 
			
		||||
      }))
 | 
			
		||||
 | 
			
		||||
      .get('/api/lang', (Util.promisify(async () => {
 | 
			
		||||
        return LANG;
 | 
			
		||||
      })))
 | 
			
		||||
      .get('/api/ui-traffic-stats', (Util.promisify(async () => {
 | 
			
		||||
        return UI_TRAFFIC_STATS === 'true';
 | 
			
		||||
      })))
 | 
			
		||||
      .get('/api/ui-traffic-stats', defineEventHandler((event) => {
 | 
			
		||||
        setHeader(event, 'Content-Type', 'application/json');
 | 
			
		||||
        return `"${UI_TRAFFIC_STATS}"`;
 | 
			
		||||
      }))
 | 
			
		||||
 | 
			
		||||
      .get('/api/ui-chart-type', defineEventHandler((event) => {
 | 
			
		||||
        setHeader(event, 'Content-Type', 'application/json');
 | 
			
		||||
        return `"${UI_CHART_TYPE}"`;
 | 
			
		||||
      }))
 | 
			
		||||
 | 
			
		||||
      // Authentication
 | 
			
		||||
      .get('/api/session', Util.promisify(async (req) => {
 | 
			
		||||
      .get('/api/session', defineEventHandler((event) => {
 | 
			
		||||
        const requiresPassword = !!process.env.PASSWORD;
 | 
			
		||||
        const authenticated = requiresPassword
 | 
			
		||||
          ? !!(req.session && req.session.authenticated)
 | 
			
		||||
          ? !!(event.node.req.session && event.node.req.session.authenticated)
 | 
			
		||||
          : true;
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
| 
						 | 
				
			
			@ -61,28 +82,35 @@ module.exports = class Server {
 | 
			
		|||
          authenticated,
 | 
			
		||||
        };
 | 
			
		||||
      }))
 | 
			
		||||
      .post('/api/session', Util.promisify(async (req) => {
 | 
			
		||||
        const {
 | 
			
		||||
          password,
 | 
			
		||||
        } = req.body;
 | 
			
		||||
      .post('/api/session', defineEventHandler(async (event) => {
 | 
			
		||||
        const { password } = await readBody(event);
 | 
			
		||||
 | 
			
		||||
        if (typeof password !== 'string') {
 | 
			
		||||
          throw new ServerError('Missing: Password', 401);
 | 
			
		||||
          throw createError({
 | 
			
		||||
            status: 401,
 | 
			
		||||
            message: 'Missing: Password',
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (password !== PASSWORD) {
 | 
			
		||||
          throw new ServerError('Incorrect Password', 401);
 | 
			
		||||
          throw createError({
 | 
			
		||||
            status: 401,
 | 
			
		||||
            message: 'Incorrect Password',
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        req.session.authenticated = true;
 | 
			
		||||
        req.session.save();
 | 
			
		||||
        event.node.req.session.authenticated = true;
 | 
			
		||||
        event.node.req.session.save();
 | 
			
		||||
 | 
			
		||||
        debug(`New Session: ${req.session.id}`);
 | 
			
		||||
      }))
 | 
			
		||||
        debug(`New Session: ${event.node.req.session.id}`);
 | 
			
		||||
 | 
			
		||||
        return { succcess: true };
 | 
			
		||||
      }));
 | 
			
		||||
 | 
			
		||||
    // WireGuard
 | 
			
		||||
      .use((req, res, next) => {
 | 
			
		||||
        if (!PASSWORD) {
 | 
			
		||||
    app.use(
 | 
			
		||||
      fromNodeMiddleware((req, res, next) => {
 | 
			
		||||
        if (!PASSWORD || !req.url.startsWith('/api/')) {
 | 
			
		||||
          return next();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +118,7 @@ module.exports = class Server {
 | 
			
		|||
          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))) {
 | 
			
		||||
            return next();
 | 
			
		||||
          }
 | 
			
		||||
| 
						 | 
				
			
			@ -102,25 +130,32 @@ module.exports = class Server {
 | 
			
		|||
        return res.status(401).json({
 | 
			
		||||
          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}`);
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      }))
 | 
			
		||||
      .get('/api/wireguard/client', Util.promisify(async (req) => {
 | 
			
		||||
      .get('/api/wireguard/client', defineEventHandler(() => {
 | 
			
		||||
        return WireGuard.getClients();
 | 
			
		||||
      }))
 | 
			
		||||
      .get('/api/wireguard/client/:clientId/qrcode.svg', Util.promisify(async (req, res) => {
 | 
			
		||||
        const { clientId } = req.params;
 | 
			
		||||
      .get('/api/wireguard/client/:clientId/qrcode.svg', defineEventHandler(async (event) => {
 | 
			
		||||
        const clientId = getRouterParam(event, 'clientId');
 | 
			
		||||
        const svg = await WireGuard.getClientQRCodeSVG({ clientId });
 | 
			
		||||
        res.header('Content-Type', 'image/svg+xml');
 | 
			
		||||
        res.send(svg);
 | 
			
		||||
        setHeader(event, 'Content-Type', 'image/svg+xml');
 | 
			
		||||
        return svg;
 | 
			
		||||
      }))
 | 
			
		||||
      .get('/api/wireguard/client/:clientId/configuration', Util.promisify(async (req, res) => {
 | 
			
		||||
        const { clientId } = req.params;
 | 
			
		||||
      .get('/api/wireguard/client/:clientId/configuration', defineEventHandler(async (event) => {
 | 
			
		||||
        const clientId = getRouterParam(event, 'clientId');
 | 
			
		||||
        const client = await WireGuard.getClient({ clientId });
 | 
			
		||||
        const config = await WireGuard.getClientConfiguration({ clientId });
 | 
			
		||||
        const configName = client.name
 | 
			
		||||
| 
						 | 
				
			
			@ -128,52 +163,85 @@ module.exports = class Server {
 | 
			
		|||
          .replace(/(-{2,}|-$)/g, '-')
 | 
			
		||||
          .replace(/-$/, '')
 | 
			
		||||
          .substring(0, 32);
 | 
			
		||||
        res.header('Content-Disposition', `attachment; filename="${configName || clientId}.conf"`);
 | 
			
		||||
        res.header('Content-Type', 'text/plain');
 | 
			
		||||
        res.send(config);
 | 
			
		||||
        setHeader(event, 'Content-Disposition', `attachment; filename="${configName || clientId}.conf"`);
 | 
			
		||||
        setHeader(event, 'Content-Type', 'text/plain');
 | 
			
		||||
        return config;
 | 
			
		||||
      }))
 | 
			
		||||
      .post('/api/wireguard/client', Util.promisify(async (req) => {
 | 
			
		||||
        const { name } = req.body;
 | 
			
		||||
        return WireGuard.createClient({ name });
 | 
			
		||||
      .post('/api/wireguard/client', defineEventHandler(async (event) => {
 | 
			
		||||
        const { name } = await readBody(event);
 | 
			
		||||
        await WireGuard.createClient({ name });
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      }))
 | 
			
		||||
      .delete('/api/wireguard/client/:clientId', Util.promisify(async (req) => {
 | 
			
		||||
        const { clientId } = req.params;
 | 
			
		||||
        return WireGuard.deleteClient({ clientId });
 | 
			
		||||
      .delete('/api/wireguard/client/:clientId', defineEventHandler(async (event) => {
 | 
			
		||||
        const clientId = getRouterParam(event, 'clientId');
 | 
			
		||||
        await WireGuard.deleteClient({ clientId });
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      }))
 | 
			
		||||
      .post('/api/wireguard/client/:clientId/enable', Util.promisify(async (req, res) => {
 | 
			
		||||
        const { clientId } = req.params;
 | 
			
		||||
      .post('/api/wireguard/client/:clientId/enable', defineEventHandler(async (event) => {
 | 
			
		||||
        const clientId = getRouterParam(event, 'clientId');
 | 
			
		||||
        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) => {
 | 
			
		||||
        const { clientId } = req.params;
 | 
			
		||||
      .post('/api/wireguard/client/:clientId/disable', defineEventHandler(async (event) => {
 | 
			
		||||
        const clientId = getRouterParam(event, 'clientId');
 | 
			
		||||
        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) => {
 | 
			
		||||
        const { clientId } = req.params;
 | 
			
		||||
      .put('/api/wireguard/client/:clientId/name', defineEventHandler(async (event) => {
 | 
			
		||||
        const clientId = getRouterParam(event, 'clientId');
 | 
			
		||||
        if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
 | 
			
		||||
          res.end(403);
 | 
			
		||||
          throw createError({ status: 403 });
 | 
			
		||||
        }
 | 
			
		||||
        const { name } = req.body;
 | 
			
		||||
        return WireGuard.updateClientName({ clientId, name });
 | 
			
		||||
        const { name } = await readBody(event);
 | 
			
		||||
        await WireGuard.updateClientName({ clientId, name });
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      }))
 | 
			
		||||
      .put('/api/wireguard/client/:clientId/address', Util.promisify(async (req, res) => {
 | 
			
		||||
        const { clientId } = req.params;
 | 
			
		||||
      .put('/api/wireguard/client/:clientId/address', defineEventHandler(async (event) => {
 | 
			
		||||
        const clientId = getRouterParam(event, 'clientId');
 | 
			
		||||
        if (clientId === '__proto__' || clientId === 'constructor' || clientId === 'prototype') {
 | 
			
		||||
          res.end(403);
 | 
			
		||||
          throw createError({ status: 403 });
 | 
			
		||||
        }
 | 
			
		||||
        const { address } = req.body;
 | 
			
		||||
        return WireGuard.updateClientAddress({ clientId, address });
 | 
			
		||||
      }))
 | 
			
		||||
        const { address } = await readBody(event);
 | 
			
		||||
        await WireGuard.updateClientAddress({ clientId, address });
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      }));
 | 
			
		||||
 | 
			
		||||
      .listen(PORT, WEBUI_HOST, () => {
 | 
			
		||||
        debug(`Listening on http://${WEBUI_HOST}:${PORT}`);
 | 
			
		||||
    // Static assets
 | 
			
		||||
    const publicDir = 'www';
 | 
			
		||||
    app.use(
 | 
			
		||||
      defineEventHandler((event) => {
 | 
			
		||||
        return serveStatic(event, {
 | 
			
		||||
          getContents: (id) => readFile(join(publicDir, id)),
 | 
			
		||||
          getMeta: async (id) => {
 | 
			
		||||
            const stats = await stat(join(publicDir, id)).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}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -95,7 +95,7 @@ module.exports = class WireGuard {
 | 
			
		|||
[Interface]
 | 
			
		||||
PrivateKey = ${config.server.privateKey}
 | 
			
		||||
Address = ${config.server.address}/24
 | 
			
		||||
ListenPort = 51820
 | 
			
		||||
ListenPort = ${WG_PORT}
 | 
			
		||||
PreUp = ${WG_PRE_UP}
 | 
			
		||||
PostUp = ${WG_POST_UP}
 | 
			
		||||
PreDown = ${WG_PRE_DOWN}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1359
									
								
								src/package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										1359
									
								
								src/package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							| 
						 | 
				
			
			@ -1,11 +1,11 @@
 | 
			
		|||
{
 | 
			
		||||
  "release": "12",
 | 
			
		||||
  "release": "13",
 | 
			
		||||
  "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 nodemon server.js",
 | 
			
		||||
    "serve": "DEBUG=Server,WireGuard npx nodemon server.js",
 | 
			
		||||
    "serve-with-password": "PASSWORD=wg npm run serve",
 | 
			
		||||
    "lint": "eslint .",
 | 
			
		||||
    "buildcss": "npx tailwindcss -i ./www/src/css/app.css -o ./www/css/app.css"
 | 
			
		||||
| 
						 | 
				
			
			@ -15,14 +15,15 @@
 | 
			
		|||
  "dependencies": {
 | 
			
		||||
    "bcryptjs": "^2.4.3",
 | 
			
		||||
    "debug": "^4.3.4",
 | 
			
		||||
    "express": "^4.18.3",
 | 
			
		||||
    "express-session": "^1.18.0",
 | 
			
		||||
    "h3": "^1.11.1",
 | 
			
		||||
    "qrcode": "^1.5.3",
 | 
			
		||||
    "uuid": "^9.0.1"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "eslint-config-athom": "^3.1.3",
 | 
			
		||||
    "tailwindcss": "^3.4.1"
 | 
			
		||||
    "nodemon": "^3.1.0",
 | 
			
		||||
    "tailwindcss": "^3.4.3"
 | 
			
		||||
  },
 | 
			
		||||
  "nodemonConfig": {
 | 
			
		||||
    "ignore": [
 | 
			
		||||
| 
						 | 
				
			
			@ -30,6 +31,6 @@
 | 
			
		|||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": "18"
 | 
			
		||||
    "node": ">=18"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,7 +22,7 @@ process.on('SIGTERM', async () => {
 | 
			
		|||
  process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Handle interupt signal
 | 
			
		||||
// Handle interrupt signal
 | 
			
		||||
process.on('SIGINT', () => {
 | 
			
		||||
  // eslint-disable-next-line no-console
 | 
			
		||||
  console.log('SIGINT signal received.');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@
 | 
			
		|||
'use strict';
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  darkMode: 'media',
 | 
			
		||||
  darkMode: 'selector',
 | 
			
		||||
  content: ['./www/**/*.{html,js}'],
 | 
			
		||||
  theme: {
 | 
			
		||||
    screens: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
/*
 | 
			
		||||
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
 | 
			
		||||
! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
| 
						 | 
				
			
			@ -211,6 +211,8 @@ textarea {
 | 
			
		|||
  /* 1 */
 | 
			
		||||
  line-height: inherit;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  letter-spacing: inherit;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  color: inherit;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  margin: 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -234,9 +236,9 @@ select {
 | 
			
		|||
*/
 | 
			
		||||
 | 
			
		||||
button,
 | 
			
		||||
[type='button'],
 | 
			
		||||
[type='reset'],
 | 
			
		||||
[type='submit'] {
 | 
			
		||||
input:where([type='button']),
 | 
			
		||||
input:where([type='reset']),
 | 
			
		||||
input:where([type='submit']) {
 | 
			
		||||
  -webkit-appearance: button;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
| 
						 | 
				
			
			@ -492,6 +494,10 @@ video {
 | 
			
		|||
  --tw-backdrop-opacity:  ;
 | 
			
		||||
  --tw-backdrop-saturate:  ;
 | 
			
		||||
  --tw-backdrop-sepia:  ;
 | 
			
		||||
  --tw-contain-size:  ;
 | 
			
		||||
  --tw-contain-layout:  ;
 | 
			
		||||
  --tw-contain-paint:  ;
 | 
			
		||||
  --tw-contain-style:  ;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
::backdrop {
 | 
			
		||||
| 
						 | 
				
			
			@ -542,6 +548,10 @@ video {
 | 
			
		|||
  --tw-backdrop-opacity:  ;
 | 
			
		||||
  --tw-backdrop-saturate:  ;
 | 
			
		||||
  --tw-backdrop-sepia:  ;
 | 
			
		||||
  --tw-contain-size:  ;
 | 
			
		||||
  --tw-contain-layout:  ;
 | 
			
		||||
  --tw-contain-paint:  ;
 | 
			
		||||
  --tw-contain-style:  ;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container {
 | 
			
		||||
| 
						 | 
				
			
			@ -590,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 {
 | 
			
		||||
  visibility: visible;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -692,8 +714,8 @@ video {
 | 
			
		|||
  margin-bottom: 2.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mb-2 {
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
.mb-4 {
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mb-5 {
 | 
			
		||||
| 
						 | 
				
			
			@ -732,6 +754,10 @@ video {
 | 
			
		|||
  margin-top: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-4 {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-5 {
 | 
			
		||||
  margin-top: 1.25rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -804,10 +830,18 @@ video {
 | 
			
		|||
  height: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-5 {
 | 
			
		||||
  height: 1.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-6 {
 | 
			
		||||
  height: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-8 {
 | 
			
		||||
  height: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.min-h-screen {
 | 
			
		||||
  min-height: 100vh;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -876,6 +910,10 @@ video {
 | 
			
		|||
  flex-grow: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.grow-0 {
 | 
			
		||||
  flex-grow: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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));
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -921,10 +959,18 @@ video {
 | 
			
		|||
  flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex-col-reverse {
 | 
			
		||||
  flex-direction: column-reverse;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex-wrap {
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.items-end {
 | 
			
		||||
  align-items: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.items-center {
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -957,6 +1003,10 @@ video {
 | 
			
		|||
  align-self: flex-start;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.self-end {
 | 
			
		||||
  align-self: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.overflow-hidden {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1087,6 +1137,14 @@ video {
 | 
			
		|||
  --tw-bg-opacity: 0.5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fill-gray-400 {
 | 
			
		||||
  fill: #9ca3af;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fill-gray-600 {
 | 
			
		||||
  fill: #4b5563;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.p-1 {
 | 
			
		||||
  padding: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1141,11 +1199,6 @@ video {
 | 
			
		|||
  padding-bottom: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.py-5 {
 | 
			
		||||
  padding-top: 1.25rem;
 | 
			
		||||
  padding-bottom: 1.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pb-1 {
 | 
			
		||||
  padding-bottom: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1276,6 +1329,11 @@ video {
 | 
			
		|||
  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 {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(220 38 38 / var(--tw-text-opacity));
 | 
			
		||||
| 
						 | 
				
			
			@ -1463,10 +1521,24 @@ video {
 | 
			
		|||
  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) {
 | 
			
		||||
| 
						 | 
				
			
			@ -1597,6 +1669,11 @@ video {
 | 
			
		|||
    padding-right: 0px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .md\:py-5 {
 | 
			
		||||
    padding-top: 1.25rem;
 | 
			
		||||
    padding-bottom: 1.25rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .md\:pb-0 {
 | 
			
		||||
    padding-bottom: 0px;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -1607,208 +1684,222 @@ video {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (prefers-color-scheme: dark) {
 | 
			
		||||
  .dark\:border-neutral-500 {
 | 
			
		||||
.dark\:border-neutral-500:where(.dark, .dark *) {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(115 115 115 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:border-neutral-600 {
 | 
			
		||||
.dark\:border-neutral-600:where(.dark, .dark *) {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(82 82 82 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:border-neutral-800 {
 | 
			
		||||
.dark\:border-neutral-800:where(.dark, .dark *) {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(38 38 38 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:border-red-600 {
 | 
			
		||||
.dark\:border-red-600:where(.dark, .dark *) {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(220 38 38 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-black {
 | 
			
		||||
.dark\:bg-black:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(0 0 0 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-neutral-400 {
 | 
			
		||||
.dark\:bg-neutral-400:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(163 163 163 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-neutral-500 {
 | 
			
		||||
.dark\:bg-neutral-500:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(115 115 115 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-neutral-600 {
 | 
			
		||||
.dark\:bg-neutral-600:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(82 82 82 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-neutral-700 {
 | 
			
		||||
.dark\:bg-neutral-700:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(64 64 64 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-neutral-800 {
 | 
			
		||||
.dark\:bg-neutral-800:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(38 38 38 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-red-100 {
 | 
			
		||||
.dark\:bg-red-100:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(254 226 226 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-red-600 {
 | 
			
		||||
.dark\:bg-red-600:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(220 38 38 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:bg-red-800 {
 | 
			
		||||
.dark\:bg-red-800:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(153 27 27 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-gray-500 {
 | 
			
		||||
.dark\:fill-neutral-400:where(.dark, .dark *) {
 | 
			
		||||
  fill: #a3a3a3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark\:fill-neutral-600:where(.dark, .dark *) {
 | 
			
		||||
  fill: #525252;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark\:text-gray-500:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(107 114 128 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-neutral-200 {
 | 
			
		||||
.dark\:text-neutral-200:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(229 229 229 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-neutral-300 {
 | 
			
		||||
.dark\:text-neutral-300:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(212 212 212 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-neutral-400 {
 | 
			
		||||
.dark\:text-neutral-400:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(163 163 163 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-neutral-50 {
 | 
			
		||||
.dark\:text-neutral-50:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(250 250 250 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-neutral-500 {
 | 
			
		||||
.dark\:text-neutral-500:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(115 115 115 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-neutral-600 {
 | 
			
		||||
.dark\:text-neutral-600:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(82 82 82 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-red-300 {
 | 
			
		||||
.dark\:text-red-300:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(252 165 165 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-red-600 {
 | 
			
		||||
.dark\:text-red-600:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(220 38 38 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:text-white {
 | 
			
		||||
.dark\:text-white:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:opacity-50 {
 | 
			
		||||
.dark\:opacity-50:where(.dark, .dark *) {
 | 
			
		||||
  opacity: 0.5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:placeholder\:text-neutral-400::-moz-placeholder {
 | 
			
		||||
.dark\:placeholder\:text-neutral-400:where(.dark, .dark *)::-moz-placeholder {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(163 163 163 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:placeholder\:text-neutral-400::placeholder {
 | 
			
		||||
.dark\:placeholder\:text-neutral-400:where(.dark, .dark *)::placeholder {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(163 163 163 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:placeholder\:text-neutral-500::-moz-placeholder {
 | 
			
		||||
.dark\:placeholder\:text-neutral-500:where(.dark, .dark *)::-moz-placeholder {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(115 115 115 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:placeholder\:text-neutral-500::placeholder {
 | 
			
		||||
.dark\:placeholder\:text-neutral-500:where(.dark, .dark *)::placeholder {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(115 115 115 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:border-neutral-600:hover {
 | 
			
		||||
.dark\:hover\:border-neutral-600:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(82 82 82 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:border-red-600:hover {
 | 
			
		||||
.dark\:hover\:border-red-600:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(220 38 38 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:bg-neutral-500:hover {
 | 
			
		||||
.dark\:hover\:bg-neutral-500:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(115 115 115 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:bg-neutral-600:hover {
 | 
			
		||||
.dark\:hover\:bg-neutral-600:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(82 82 82 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:bg-red-600:hover {
 | 
			
		||||
.dark\:hover\:bg-red-600:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(220 38 38 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:bg-red-700:hover {
 | 
			
		||||
.dark\:hover\:bg-red-700:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(185 28 28 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:bg-red-800:hover {
 | 
			
		||||
.dark\:hover\:bg-red-800:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(153 27 27 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:text-neutral-700:hover {
 | 
			
		||||
.dark\:hover\:text-neutral-700:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(64 64 64 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:text-red-100:hover {
 | 
			
		||||
.dark\:hover\:text-red-100:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(254 226 226 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:hover\:text-white:hover {
 | 
			
		||||
.dark\:hover\:text-white:hover:where(.dark, .dark *) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .dark\:focus\:border-neutral-500:focus {
 | 
			
		||||
.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 {
 | 
			
		||||
.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:focus {
 | 
			
		||||
.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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@
 | 
			
		|||
 | 
			
		||||
<head>
 | 
			
		||||
  <title>WireGuard</title>
 | 
			
		||||
  <meta charset="utf-8"/>
 | 
			
		||||
  <link href="./css/app.css" rel="stylesheet">
 | 
			
		||||
  <link rel="manifest" href="./manifest.json">
 | 
			
		||||
  <link rel="icon" type="image/png" href="./img/favicon.png">
 | 
			
		||||
| 
						 | 
				
			
			@ -18,25 +19,56 @@
 | 
			
		|||
 | 
			
		||||
<body class="bg-gray-50 dark:bg-neutral-800">
 | 
			
		||||
  <div id="app">
 | 
			
		||||
    <div v-cloak class="container mx-auto max-w-3xl px-3 md:px-0">
 | 
			
		||||
    <div v-cloak class="container mx-auto max-w-3xl px-3 md:px-0 mt-4 xs:mt-6">
 | 
			
		||||
      <div v-if="authenticated === true">
 | 
			
		||||
        <div class="flex flex-col-reverse xxs:flex-row flex-auto items-center items-end gap-3">
 | 
			
		||||
          <h1 class="text-4xl dark:text-neutral-200 font-medium flex-grow self-start mb-4">
 | 
			
		||||
            <img src="./img/logo.png" width="32" class="inline align-middle dark:bg mr-2" /><span class="align-middle">WireGuard</span>
 | 
			
		||||
          </h1>
 | 
			
		||||
          <div class="flex items-center grow-0 gap-3 items-end self-end xxs:self-center">
 | 
			
		||||
            <!-- Dark / light theme -->
 | 
			
		||||
            <button @click="toggleTheme"
 | 
			
		||||
              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}`)">
 | 
			
		||||
              <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"
 | 
			
		||||
                class="w-5 h-5">
 | 
			
		||||
                <path stroke-linecap="round" stroke-linejoin="round"
 | 
			
		||||
                  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" />
 | 
			
		||||
              </svg>
 | 
			
		||||
              <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"
 | 
			
		||||
                class="w-5 h-5 text-neutral-400">
 | 
			
		||||
                <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 mb-10 mr-2 mt-3 cursor-pointer hover:underline float-right"
 | 
			
		||||
              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">
 | 
			
		||||
              <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>
 | 
			
		||||
        <h1 class="text-4xl dark:text-neutral-200 font-medium mt-2 mb-2">
 | 
			
		||||
          <img src="./img/logo.png" width="32" class="inline align-middle dark:bg" />
 | 
			
		||||
          <span class="align-middle">WireGuard</span>
 | 
			
		||||
        </h1>
 | 
			
		||||
        <h2 class="text-sm text-gray-400 dark:text-neutral-400 mb-10"></h2>
 | 
			
		||||
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="text-sm text-gray-400 dark:text-neutral-400 mb-5"></div>
 | 
			
		||||
        <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"
 | 
			
		||||
          :title="`v${currentRelease} → v${latestRelease.version}`">
 | 
			
		||||
| 
						 | 
				
			
			@ -77,17 +109,17 @@
 | 
			
		|||
              class="relative overflow-hidden border-b last:border-b-0 border-gray-100 dark:border-neutral-600 border-solid">
 | 
			
		||||
 | 
			
		||||
              <!-- Chart -->
 | 
			
		||||
              <div class="absolute z-0 bottom-0 left-0 right-0" style="top: 60%;">
 | 
			
		||||
                <apexchart width="100%" height="100%" :options="client.chartOptions" :series="client.transferTxSeries">
 | 
			
		||||
              <div v-if="uiChartType" class="absolute z-0 bottom-0 left-0 right-0 h-6" >
 | 
			
		||||
                <apexchart width="100%" height="100%" :options="chartOptionsTX" :series="client.transferTxSeries">
 | 
			
		||||
                </apexchart>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="absolute z-0 top-0 left-0 right-0" style="bottom: 60%;">
 | 
			
		||||
                <apexchart width="100%" height="100%" :options="client.chartOptions" :series="client.transferRxSeries"
 | 
			
		||||
              <div v-if="uiChartType" class="absolute z-0 top-0 left-0 right-0 h-6" >
 | 
			
		||||
                <apexchart width="100%" height="100%" :options="chartOptionsRX" :series="client.transferRxSeries"
 | 
			
		||||
                  style="transform: scaleY(-1);">
 | 
			
		||||
                </apexchart>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div class="relative py-5 px-3 z-10 flex flex-col sm:flex-row justify-between gap-3">
 | 
			
		||||
              <div class="relative py-3 md:py-5 px-3 z-10 flex flex-col sm:flex-row justify-between gap-3">
 | 
			
		||||
                <div class="flex gap-3 md:gap-4 w-full items-center ">
 | 
			
		||||
 | 
			
		||||
                  <!-- Avatar -->
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -50,6 +50,13 @@ class API {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getChartType() {
 | 
			
		||||
    return this.call({
 | 
			
		||||
      method: 'get',
 | 
			
		||||
      path: '/ui-chart-type',
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getSession() {
 | 
			
		||||
    return this.call({
 | 
			
		||||
      method: 'get',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,8 +29,24 @@ const i18n = new VueI18n({
 | 
			
		|||
  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({
 | 
			
		||||
  el: '#app',
 | 
			
		||||
  components: {
 | 
			
		||||
    apexchart: VueApexCharts,
 | 
			
		||||
  },
 | 
			
		||||
  i18n,
 | 
			
		||||
  data: {
 | 
			
		||||
    authenticated: null,
 | 
			
		||||
| 
						 | 
				
			
			@ -52,13 +68,16 @@ new Vue({
 | 
			
		|||
    currentRelease: 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: {
 | 
			
		||||
      chart: {
 | 
			
		||||
        background: 'transparent',
 | 
			
		||||
        type: 'bar',
 | 
			
		||||
        stacked: false,
 | 
			
		||||
        toolbar: {
 | 
			
		||||
          show: false,
 | 
			
		||||
| 
						 | 
				
			
			@ -66,11 +85,27 @@ new Vue({
 | 
			
		|||
        animations: {
 | 
			
		||||
          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: {
 | 
			
		||||
        enabled: false,
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			@ -84,10 +119,10 @@ new Vue({
 | 
			
		|||
          show: false,
 | 
			
		||||
        },
 | 
			
		||||
        axisTicks: {
 | 
			
		||||
          show: true,
 | 
			
		||||
          show: false,
 | 
			
		||||
        },
 | 
			
		||||
        axisBorder: {
 | 
			
		||||
          show: true,
 | 
			
		||||
          show: false,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      yaxis: {
 | 
			
		||||
| 
						 | 
				
			
			@ -153,27 +188,42 @@ new Vue({
 | 
			
		|||
        // Debug
 | 
			
		||||
        // client.transferRx = this.clientsPersist[client.id].transferRxPrevious + 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].transferRxPrevious = client.transferRx;
 | 
			
		||||
        this.clientsPersist[client.id].transferTxCurrent = client.transferTx - this.clientsPersist[client.id].transferTxPrevious;
 | 
			
		||||
        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.shift();
 | 
			
		||||
 | 
			
		||||
          this.clientsPersist[client.id].transferTxHistory.push(this.clientsPersist[client.id].transferTxCurrent);
 | 
			
		||||
          this.clientsPersist[client.id].transferTxHistory.shift();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        client.transferTxCurrent = this.clientsPersist[client.id].transferTxCurrent;
 | 
			
		||||
        client.transferRxCurrent = this.clientsPersist[client.id].transferRxCurrent;
 | 
			
		||||
          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.transferRxCurrent = this.clientsPersist[client.id].transferRxCurrent;
 | 
			
		||||
 | 
			
		||||
        client.hoverTx = this.clientsPersist[client.id].hoverTx;
 | 
			
		||||
        client.hoverRx = this.clientsPersist[client.id].hoverRx;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -250,14 +300,25 @@ new Vue({
 | 
			
		|||
        .finally(() => this.refresh().catch(console.error));
 | 
			
		||||
    },
 | 
			
		||||
    toggleTheme() {
 | 
			
		||||
      if (this.isDark) {
 | 
			
		||||
        localStorage.theme = 'light';
 | 
			
		||||
        document.documentElement.classList.remove('dark');
 | 
			
		||||
      } else {
 | 
			
		||||
        localStorage.theme = 'dark';
 | 
			
		||||
        document.documentElement.classList.add('dark');
 | 
			
		||||
      const themes = ['light', 'dark', 'auto'];
 | 
			
		||||
      const currentIndex = themes.indexOf(this.uiTheme);
 | 
			
		||||
      const newIndex = (currentIndex + 1) % themes.length;
 | 
			
		||||
      this.uiTheme = themes[newIndex];
 | 
			
		||||
      localStorage.theme = this.uiTheme;
 | 
			
		||||
      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: {
 | 
			
		||||
| 
						 | 
				
			
			@ -267,10 +328,8 @@ new Vue({
 | 
			
		|||
    },
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    this.isDark = false;
 | 
			
		||||
    if (localStorage.theme === 'dark') {
 | 
			
		||||
      this.isDark = true;
 | 
			
		||||
    }
 | 
			
		||||
    this.prefersDarkScheme.addListener(this.handlePrefersChange);
 | 
			
		||||
    this.setTheme(this.uiTheme);
 | 
			
		||||
 | 
			
		||||
    this.api = new API();
 | 
			
		||||
    this.api.getSession()
 | 
			
		||||
| 
						 | 
				
			
			@ -278,7 +337,7 @@ new Vue({
 | 
			
		|||
        this.authenticated = session.authenticated;
 | 
			
		||||
        this.requiresPassword = session.requiresPassword;
 | 
			
		||||
        this.refresh({
 | 
			
		||||
          updateCharts: true,
 | 
			
		||||
          updateCharts: this.updateCharts,
 | 
			
		||||
        }).catch((err) => {
 | 
			
		||||
          alert(err.message || err.toString());
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -289,7 +348,7 @@ new Vue({
 | 
			
		|||
 | 
			
		||||
    setInterval(() => {
 | 
			
		||||
      this.refresh({
 | 
			
		||||
        updateCharts: true,
 | 
			
		||||
        updateCharts: this.updateCharts,
 | 
			
		||||
      }).catch(console.error);
 | 
			
		||||
    }, 1000);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -298,10 +357,17 @@ new Vue({
 | 
			
		|||
        this.uiTrafficStats = res;
 | 
			
		||||
      })
 | 
			
		||||
      .catch(() => {
 | 
			
		||||
        console.log('Failed to get ui-traffic-stats');
 | 
			
		||||
        this.uiTrafficStats = false;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
    this.api.getChartType()
 | 
			
		||||
      .then((res) => {
 | 
			
		||||
        this.uiChartType = parseInt(res, 10);
 | 
			
		||||
      })
 | 
			
		||||
      .catch(() => {
 | 
			
		||||
        this.uiChartType = 0;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
    Promise.resolve().then(async () => {
 | 
			
		||||
      const lang = await this.api.getLang();
 | 
			
		||||
      if (lang !== localStorage.getItem('lang') && i18n.availableLocales.includes(lang)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -333,4 +399,33 @@ new Vue({
 | 
			
		|||
      this.latestRelease = latestRelease;
 | 
			
		||||
    }).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;
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,6 +28,8 @@ const messages = { // eslint-disable-line no-unused-vars
 | 
			
		|||
    downloadConfig: 'Download Configuration',
 | 
			
		||||
    madeBy: 'Made by',
 | 
			
		||||
    donate: 'Donate',
 | 
			
		||||
    toggleCharts: 'Show/hide Charts',
 | 
			
		||||
    theme: { dark: 'Dark theme', light: 'Light theme', auto: 'Auto theme' },
 | 
			
		||||
  },
 | 
			
		||||
  ua: {
 | 
			
		||||
    name: 'Ім`я',
 | 
			
		||||
| 
						 | 
				
			
			@ -273,6 +275,8 @@ const messages = { // eslint-disable-line no-unused-vars
 | 
			
		|||
    downloadConfig: 'Descargar configuración',
 | 
			
		||||
    madeBy: 'Hecho por',
 | 
			
		||||
    donate: 'Donar',
 | 
			
		||||
    toggleCharts: 'Mostrar/Ocultar gráficos',
 | 
			
		||||
    theme: { dark: 'Modo oscuro', light: 'Modo claro', auto: 'Modo automático' },
 | 
			
		||||
  },
 | 
			
		||||
  ko: {
 | 
			
		||||
    name: '이름',
 | 
			
		||||
| 
						 | 
				
			
			@ -517,4 +521,32 @@ const messages = { // eslint-disable-line no-unused-vars
 | 
			
		|||
    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: 'दान करें',
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue