Merge branch 'master' into add-WG_MTU

This commit is contained in:
DerDanilo 2021-12-26 14:22:23 +01:00 committed by GitHub
commit 8e93ae76a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 830 additions and 1292 deletions

View File

@ -19,8 +19,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: benjlevesque/short-sha@v1.2
with:
ref: production
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
@ -35,10 +35,7 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
# Set environment variables
- run: echo GH_BRANCH=${GITHUB_REF#refs/heads/} >> $GITHUB_ENV
- run: echo VERSION=$(cat package.json | jq -r .version) >> $GITHUB_ENV
- run: echo DOCKER_TAGS=$(cat package.json | jq -r .docker[\"$GH_BRANCH\"].tags) >> $GITHUB_ENV
- run: echo Branch ${{ env.GH_BRANCH }}, Tags ${{ env.DOCKER_TAGS }}
- run: echo RELEASE=$(cat ./src/package.json | jq -r .release) >> $GITHUB_ENV
# Build & Publish
- name: Build & Publish Docker Image
@ -46,4 +43,4 @@ jobs:
with:
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
tags: weejewel/wg-easy:v${{ env.VERSION }}, weejewel/wg-easy:${{ env.DOCKER_TAGS }}
tags: weejewel/wg-easy:latest, weejewel/wg-easy:${{ env.RELEASE }}

4
.gitignore vendored
View File

@ -1 +1,3 @@
/config
/config
/wg0.conf
/wg0.json

View File

@ -1,12 +1,14 @@
FROM node:14-alpine
FROM docker.io/library/node:14-alpine@sha256:dc92f36e7cd917816fa2df041d4e9081453366381a00f40398d99e9392e78664
# Install Linux packages
RUN apk add -U wireguard-tools
RUN apk add -U --no-cache wireguard-tools dumb-init
# Copy Web UI
COPY src/ /app/
WORKDIR /app
RUN npm ci --production
RUN npm i -g nodemon
RUN mv /app/node_modules/ /node_modules/
# Expose Ports
EXPOSE 51820/udp
@ -16,4 +18,4 @@ EXPOSE 51821/tcp
ENV DEBUG=Server,WireGuard
# Run Web UI
CMD ["node", "server.js"]
CMD ["/usr/bin/dumb-init", "node", "server.js"]

11
LICENSE.md Normal file
View File

@ -0,0 +1,11 @@
**You may:**
* Use this software for yourself;
* Use this software for a company;
* Modify this software, as long as you:
* Publish the changes on GitHub as an open-source & linked fork;
* Don't remove any links to the original project or donation pages;
**You may not:**
* Use this software in a commercial product without a license from the original author;

View File

@ -9,19 +9,25 @@
You have found the easiest way to install & manage WireGuard on any Linux host!
<p align="center">
<img src="./assets/screenshot.png" width="702" />
<img src="./assets/screenshot.png" width="802" />
</p>
## Features
* All-in-one: WireGuard + Web UI.
* Easy installation, simple to use.
* List, create, delete, enable & disable clients.
* List, create, edit, delete, enable & disable clients.
* Show a client's QR code.
* Download a client's configuration file.
* Statistics for which clients are connected.
* Tx/Rx charts for each connected client.
* Gravatar support.
## Requirements
* A host with a kernel that supports WireGuard (all modern kernels).
* A host with Docker installed.
## Installation
### 1. Install Docker
@ -30,49 +36,69 @@ If you haven't installed Docker yet, install it by running:
```bash
$ curl -sSL https://get.docker.com | sh
$ sudo sh get-docker.sh
$ sudo usermod -aG docker $(whoami)
$ bash
$ exit
```
### 2. Configure WireGuard
And log in again.
Run these commands to prepare and configure WireGuard.
### 2. Run WireGuard Easy
```bash
$ mkdir ~/.wg-easy
$ cd ~/.wg-easy
$ wget https://raw.githubusercontent.com/WeeJeWel/wg-easy/master/docker-compose.yml
$ vim docker-compose.yml
```
To automatically install & run wg-easy, simply run:
Change `WG_HOST=raspberrypi.local` to your server's public address, e.g. `WG_HOST=vpn.mydomain.com`.
<pre>
$ docker run -d \
--name=wg-easy \
-e WG_HOST=<b>🚨YOUR_SERVER_IP</b> \
-e PASSWORD=<b>🚨YOUR_ADMIN_PASSWORD</b> \
-v ~/.wg-easy:/etc/wireguard \
-p 51820:51820/udp \
-p 51821:51821/tcp \
--cap-add=NET_ADMIN \
--cap-add=SYS_MODULE \
--sysctl="net.ipv4.conf.all.src_valid_mark=1" \
--sysctl="net.ipv4.ip_forward=1" \
--restart unless-stopped \
weejewel/wg-easy
</pre>
Optionally, set a Web UI password by uncommenting `PASSWORD=foobar123` and change the password.
> 💡 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.
> By default, any WireGuard client will have access to the Web UI, unless you set a password.
The Web UI will now be available on `http://0.0.0.0:51821`.
### 3. Run WireGuard
> 💡 Your configuration files will be saved in `~/.wg-easy`
Finally, run WireGuard. It will automatically start after a reboot.
### 3. Sponsor
```bash
$ docker-compose up --detach
```
The Web UI will be available on `http://0.0.0.0:51821`. You can create new clients there.
Are you enjoying this project? [Buy me a beer!](https://github.com/sponsors/WeeJeWel) 🍻
## Options
These options can be configured in `docker-compose.yml` under `environment`.
These options can be configured by setting environment variables using `-e KEY="VALUE"` in the `docker run` command.
| Env | Default | Example | Description |
| - | - | - | - |
| `PASSWORD` | - | `foobar123` | When set, requires a password when logging in to the Web UI. |
| `WG_HOST` | - | `vpn.myserver.com` | The public hostname of your VPN server |
| `WG_PORT` | `51820` | `51820` | The public UDP port of your VPN server |
| `WG_DEFAULT_ADDRESS` | `10.8.0.x` | `10.6.0.x` | Clients IP address range |
| `WG_DEFAULT_DNS` | `1.1.1.1` | `8.8.8.8, 8.8.4.4` | DNS server clients will use |
| `WG_MTU` | `null` | `1420` | The MTU the clients will use. (Server uses default WG MTU) |
| `WG_HOST` | - | `vpn.myserver.com` | The public hostname of your VPN server. |
| `WG_PORT` | `51820` | `12345` | The public UDP port of your VPN server. WireGuard will always listen on `51820` inside the Docker container. |
| `WG_MTU` | `null` | `1420` | The MTU the clients will use. Server uses default WG MTU. |
| `WG_PERSISTENT_KEEPALIVE` | `0` | `25` | Value in seconds to keep the "connection" open. |
| `WG_DEFAULT_ADDRESS` | `10.8.0.x` | `10.6.0.x` | Clients IP address range. |
| `WG_DEFAULT_DNS` | `1.1.1.1` | `8.8.8.8, 8.8.4.4` | DNS server clients will use. |
| `WG_ALLOWED_IPS` | `0.0.0.0/0, ::/0` | `192.168.15.0/24, 10.0.1.0/24` | Allowed IPs clients will use. |
> If you change `WG_PORT`, make sure to also change the exposed port.
# Updating
To update to the latest version, simply run:
```bash
docker stop wg-easy
docker rm wg-easy
docker pull weejewel/wg-easy
```
And then run the `docker run -d \ ...` command above again.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 105 KiB

9
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,9 @@
version: "3.8"
services:
wg-easy:
image: wg-easy
command: npm run serve
volumes:
- ./src/:/app/
# environment:
# - PASSWORD=p

View File

@ -12,6 +12,7 @@ services:
# - WG_DEFAULT_ADDRESS=10.8.0.x
# - WG_DEFAULT_DNS=1.1.1.1
# - WG_MTU=1420
# - WG_ALLOWED_IPS=192.168.15.0/24, 10.0.1.0/24
image: weejewel/wg-easy
container_name: wg-easy

6
docs/changelog.json Normal file
View File

@ -0,0 +1,6 @@
{
"1": "Initial version. Enjoy!",
"2": "You can now rename a client, and update the address. Enjoy!",
"3": "Many improvements and small changes. Enjoy!",
"4": "Now with pretty charts for client's network speed. Enjoy!"
}

4
package-lock.json generated Normal file
View File

@ -0,0 +1,4 @@
{
"version": "1.0.0",
"lockfileVersion": 1
}

View File

@ -2,14 +2,7 @@
"version": "1.0.0",
"scripts": {
"build": "docker build --tag wg-easy .",
"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"
},
"docker": {
"production": {
"tags": "latest"
},
"staging": {
"tags": "beta"
}
}
}
}

View File

@ -5,6 +5,7 @@
],
"rules": {
"consistent-return": "off",
"no-shadow": "off"
"no-shadow": "off",
"max-len": "off"
}
}

View File

@ -1,10 +1,17 @@
'use strict';
const { release } = require('./package.json');
module.exports.RELEASE = release;
module.exports.PORT = process.env.PORT || 51821;
module.exports.PASSWORD = process.env.PASSWORD;
module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/';
module.exports.WG_HOST = process.env.WG_HOST;
module.exports.WG_PORT = process.env.WG_PORT || 51820;
module.exports.WG_MTU = process.env.WG_MTU || null;
module.exports.WG_PERSISTENT_KEEPALIVE = process.env.WG_PERSISTENT_KEEPALIVE || 0;
module.exports.WG_DEFAULT_ADDRESS = process.env.WG_DEFAULT_ADDRESS || '10.8.0.x';
module.exports.WG_DEFAULT_DNS = process.env.WG_DEFAULT_DNS || '1.1.1.1';
module.exports.WG_DEFAULT_DNS = typeof process.env.WG_DEFAULT_DNS === 'string'
? process.env.WG_DEFAULT_DNS
: '1.1.1.1';
module.exports.WG_ALLOWED_IPS = process.env.WG_ALLOWED_IPS || '0.0.0.0/0, ::/0';

View File

@ -12,6 +12,7 @@ const WireGuard = require('../services/WireGuard');
const {
PORT,
RELEASE,
PASSWORD,
} = require('../config');
@ -29,6 +30,10 @@ module.exports = class Server {
saveUninitialized: true,
}))
.get('/api/release', (Util.promisify(async () => {
return RELEASE;
})))
// Authentication
.get('/api/session', Util.promisify(async req => {
const requiresPassword = !!process.env.PASSWORD;
@ -94,7 +99,8 @@ module.exports = class Server {
const { clientId } = req.params;
const client = await WireGuard.getClient({ clientId });
const config = await WireGuard.getClientConfiguration({ clientId });
res.header('Content-Disposition', `attachment; filename="${client.name}.conf"`);
const configName = client.name.replace(/[^a-zA-Z0-9_=+.-]/g, '-').replace(/(-{2,}|-$)/g, '-').replace(/-$/, '').substring(0, 32);
res.header('Content-Disposition', `attachment; filename="${configName}.conf"`);
res.header('Content-Type', 'text/plain');
res.send(config);
}))
@ -114,6 +120,16 @@ module.exports = class Server {
const { clientId } = req.params;
return WireGuard.disableClient({ clientId });
}))
.put('/api/wireguard/client/:clientId/name', Util.promisify(async req => {
const { clientId } = req.params;
const { name } = req.body;
return WireGuard.updateClientName({ clientId, name });
}))
.put('/api/wireguard/client/:clientId/address', Util.promisify(async req => {
const { clientId } = req.params;
const { address } = req.body;
return WireGuard.updateClientAddress({ clientId, address });
}))
.listen(PORT, () => {
debug(`Listening on http://0.0.0.0:${PORT}`);

View File

@ -4,6 +4,19 @@ const childProcess = require('child_process');
module.exports = class Util {
static isValidIPv4(str) {
const blocks = str.split('.');
if (blocks.length !== 4) return false;
for (let value of blocks) {
value = parseInt(value, 10);
if (Number.isNaN(value)) return false;
if (value < 0 || value > 255) return false;
}
return true;
}
static promisify(fn) {
// eslint-disable-next-line func-names
return function(req, res) {
@ -39,9 +52,16 @@ module.exports = class Util {
};
}
static async exec(cmd) {
// eslint-disable-next-line no-console
console.log(`$ ${cmd}`);
static async exec(cmd, {
log = true,
} = {}) {
if (typeof log === 'string') {
// eslint-disable-next-line no-console
console.log(`$ ${log}`);
} else if (log === true) {
// eslint-disable-next-line no-console
console.log(`$ ${cmd}`);
}
if (process.platform !== 'linux') {
return '';

View File

@ -17,6 +17,8 @@ const {
WG_MTU,
WG_DEFAULT_DNS,
WG_DEFAULT_ADDRESS,
WG_PERSISTENT_KEEPALIVE,
WG_ALLOWED_IPS,
} = require('../config');
module.exports = class WireGuard {
@ -36,7 +38,9 @@ module.exports = class WireGuard {
debug('Configuration loaded.');
} catch (err) {
const privateKey = await Util.exec('wg genkey');
const publicKey = await Util.exec(`echo ${privateKey} | wg pubkey`);
const publicKey = await Util.exec(`echo ${privateKey} | wg pubkey`, {
log: 'echo ***hidden*** | wg pubkey',
});
const address = WG_DEFAULT_ADDRESS.replace('x', '1');
config = {
@ -51,6 +55,7 @@ module.exports = class WireGuard {
}
await this.__saveConfig(config);
await Util.exec('wg-quick down wg0').catch(() => { });
await Util.exec('wg-quick up wg0');
await Util.exec(`iptables -t nat -A POSTROUTING -s ${WG_DEFAULT_ADDRESS.replace('x', '0')}/24 -o eth0 -j MASQUERADE`);
await Util.exec('iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT');
@ -125,7 +130,9 @@ AllowedIPs = ${client.address}/32`;
}));
// Loop WireGuard status
const dump = await Util.exec('wg show wg0 dump');
const dump = await Util.exec('wg show wg0 dump', {
log: false,
});
dump
.trim()
.split('\n')
@ -174,7 +181,7 @@ AllowedIPs = ${client.address}/32`;
[Interface]
PrivateKey = ${client.privateKey}
Address = ${client.address}/24
DNS = ${WG_DEFAULT_DNS}
${WG_DEFAULT_DNS ? `DNS = ${WG_DEFAULT_DNS}` : ''}
if (typeof ${WG_MTU} !== 'undefined' || ${WG_MTU} !== null) {
MTU = ${WG_MTU}
}
@ -182,7 +189,8 @@ MTU = ${WG_MTU}
[Peer]
PublicKey = ${config.server.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = 0.0.0.0/0, ::/0
AllowedIPs = ${WG_ALLOWED_IPS}
PersistentKeepalive = ${WG_PERSISTENT_KEEPALIVE}
Endpoint = ${WG_HOST}:${WG_PORT}`;
}
@ -269,4 +277,26 @@ Endpoint = ${WG_HOST}:${WG_PORT}`;
await this.saveConfig();
}
async updateClientName({ clientId, name }) {
const client = await this.getClient({ clientId });
client.name = name;
client.updatedAt = new Date();
await this.saveConfig();
}
async updateClientAddress({ clientId, address }) {
const client = await this.getClient({ clientId });
if (!Util.isValidIPv4(address)) {
throw new ServerError(`Invalid Address: ${address}`, 400);
}
client.address = address;
client.updatedAt = new Date();
await this.saveConfig();
}
};

882
src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
{
"release": 4,
"name": "wg-easy",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"serve": "DEBUG=Server,WireGuard WG_HOST=0.0.0.0 WG_PATH=../config/ nodemon server.js",
"serve": "DEBUG=Server,WireGuard nodemon server.js",
"serve-with-password": "PASSWORD=wg npm run serve",
"lint": "eslint ."
},
@ -19,8 +20,7 @@
},
"devDependencies": {
"eslint": "^7.27.0",
"eslint-config-athom": "^2.1.0",
"nodemon": "^2.0.7"
"eslint-config-athom": "^2.1.0"
},
"nodemonConfig": {
"ignore": [

View File

@ -3,7 +3,7 @@
<head>
<title>WireGuard</title>
<link href="css/vendor/tailwind.min.css" rel="stylesheet">
<link href="/css/vendor/tailwind.min.css" rel="stylesheet">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="img/favicon.png">
<link rel="apple-touch-icon" href="img/apple-touch-icon.png">
@ -12,149 +12,429 @@
</head>
<body class="bg-gray-50">
<div id="app" class="container mx-auto">
<div v-if="authenticated === true">
<span v-if="requiresPassword"
class="text-sm text-gray-400 mb-10 mr-2 mt-3 cursor-pointer hover:underline float-right" @click="logout">
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>
<h1 class="text-4xl font-medium mt-10 mb-2">
<img src="./img/logo.png" width="32" class="inline align-middle" />
<span class="align-middle">WireGuard</span>
</h1>
<h2 class="text-sm text-gray-400 mb-10"></h2>
<div class="shadow-md rounded-lg bg-white overflow-hidden">
<div class="flex flex-row flex-auto items-center p-3 px-5 border border-b-2 border-gray-100">
<div class="flex-grow">
<p class="text-2xl font-medium">Clients</p>
</div>
<div class="flex-shrink-0">
<button @click="clientCreate = true; clientCreateName = '';"
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 border-2 border-gray-100 py-2 px-4 rounded inline-flex items-center transition">
<svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span class="text-sm">New</span>
</button>
<div id="app">
<div class="container mx-auto max-w-3xl">
<div v-if="authenticated === true">
<span v-if="requiresPassword"
class="text-sm text-gray-400 mb-10 mr-2 mt-3 cursor-pointer hover:underline float-right" @click="logout">
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>
<h1 class="text-4xl font-medium mt-10 mb-2">
<img src="./img/logo.png" width="32" class="inline align-middle" />
<span class="align-middle">WireGuard</span>
</h1>
<h2 class="text-sm text-gray-400 mb-10"></h2>
<div v-if="latestRelease" class="bg-red-800 p-4 text-white text-sm font-small mb-10 rounded-md shadow-lg"
:title="`v${currentRelease} → v${latestRelease.version}`">
<div class="container mx-auto flex flex-row flex-auto items-center">
<div class="flex-grow">
<p class="font-bold">There is an update available!</p>
<p>{{latestRelease.changelog}}</p>
</div>
<a href="https://github.com/WeeJeWel/wg-easy#updating" target="_blank"
class="p-3 rounded-md bg-white float-right font-sm font-semibold text-red-800 flex-shrink-0 border-2 border-red-800 hover:border-white hover:text-white hover:bg-red-800 transition-all">
Update →
</a>
</div>
</div>
<div>
<div v-if="clients && clients.length > 0" v-for="client in clients" :key="client.id"
class="p-5 flex flex-row">
<div class="h-10 w-10 mr-5 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" clip-rule="evenodd" />
</svg>
<img v-if="client.avatar" :src="client.avatar" class="w-10 rounded-full absolute top-0 left-0" />
<div
v-if="client.latestHandshakeAt && ((new Date() - new Date(client.latestHandshakeAt) < 1000 * 60 * 10))">
<div class="animate-ping w-4 h-4 p-1 bg-red-100 rounded-full absolute -bottom-1 -right-1"></div>
<div class="w-2 h-2 bg-red-800 rounded-full absolute bottom-0 right-0"></div>
</div>
</div>
<div class="shadow-md rounded-lg bg-white overflow-hidden">
<div class="flex flex-row flex-auto items-center p-3 px-5 border border-b-2 border-gray-100">
<div class="flex-grow">
<div class="text-gray-700" :title="'Created at ' + dateTime(new Date(client.createdAt))">{{client.name}}
</div>
<div class="text-gray-300 text-xs">{{client.address}}
<span v-if="client.transferTx" title="Download">
·
<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.transferTx | bytes}}
</span>
<span v-if="client.transferRx" title="Upload">
·
<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.transferRx | bytes}}
</span>
<span v-if="client.latestHandshakeAt"
:title="'Last seen at ' + dateTime(new Date(client.latestHandshakeAt))">
· {{new Date(client.latestHandshakeAt) | timeago}}
</span>
</div>
<p class="text-2xl font-medium">Clients</p>
</div>
<div class="text-right">
<div class="text-gray-400">
<!-- Enable/Disable -->
<div @click="disableClient(client)" v-if="client.enabled === true"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all">
<div class="rounded-full w-4 h-4 m-1 ml-5 bg-white"></div>
</div>
<div @click="enableClient(client)" v-if="client.enabled === false"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 cursor-pointer hover:bg-gray-300 transition-all">
<div class="rounded-full w-4 h-4 m-1 bg-white"></div>
</div>
<!-- Show QR-->
<button class="align-middle bg-gray-100 hover:bg-red-800 hover:text-white p-2 rounded transition"
title="Show QR Code" @click="qrcode = `/api/wireguard/client/${client.id}/qrcode.svg`">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
</button>
<!-- Download Config -->
<a :href="'/api/wireguard/client/' + client.id + '/configuration'" download
class="align-middle inline-block bg-gray-100 hover:bg-red-800 hover:text-white p-2 rounded transition"
title="Download Configuration">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<!-- Delete -->
<button class="align-middle bg-gray-100 hover:bg-red-800 hover:text-white p-2 rounded transition"
title="Delete Client" @click="clientDelete = client">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
<div v-if="clients && clients.length === 0">
<p class="text-center m-10 text-gray-400 text-sm">There are no clients yet.<br /><br />
<div class="flex-shrink-0">
<button @click="clientCreate = true; clientCreateName = '';"
class="bg-red-800 text-white hover:bg-red-700 border-2 border-none py-2 px-4 rounded inline-flex items-center transition">
class="hover:bg-red-800 hover:border-red-800 hover:text-white text-gray-700 border-2 border-gray-100 py-2 px-4 rounded inline-flex items-center transition">
<svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span class="text-sm">New Client</span>
<span class="text-sm">New</span>
</button>
</p>
</div>
</div>
<div v-if="clients === null" class="text-gray-200 p-5">
<div>
<!-- Client -->
<div v-if="clients && clients.length > 0" v-for="client in clients" :key="client.id"
class="relative overflow-hidden border-b border-gray-100 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">
</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"
style="transform: scaleY(-1);">
</apexchart>
</div>
<div class="relative p-5 z-10 flex flex-row">
<div class="h-10 w-10 mr-5 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"
clip-rule="evenodd" />
</svg>
<img v-if="client.avatar" :src="client.avatar" class="w-10 rounded-full absolute top-0 left-0" />
<div
v-if="client.latestHandshakeAt && ((new Date() - new Date(client.latestHandshakeAt) < 1000 * 60 * 10))">
<div class="animate-ping w-4 h-4 p-1 bg-red-100 rounded-full absolute -bottom-1 -right-1"></div>
<div class="w-2 h-2 bg-red-800 rounded-full absolute bottom-0 right-0"></div>
</div>
</div>
<div class="flex-grow">
<!-- Name -->
<div class="text-gray-700 group" :title="'Created at ' + 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 border-gray-100 focus:border-gray-200 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 text-xs">
<!-- Address -->
<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 border-gray-100 focus:border-gray-200 outline-none w-20 text-black" />
<span v-show="clientEditAddressId !== client.id"
class="inline-block border-t-2 border-b-2 border-transparent">{{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>
<!-- Transfer TX -->
<span v-if="client.transferTx":title="'Total Download: ' + 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>
<!-- Transfer RX -->
<span v-if="client.transferRx" :title="'Total Upload: ' + 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 v-if="client.latestHandshakeAt"
:title="'Last seen at ' + dateTime(new Date(client.latestHandshakeAt))">
· {{new Date(client.latestHandshakeAt) | timeago}}
</span>
</div>
</div>
<div class="text-right">
<div class="text-gray-400">
<!-- Enable/Disable -->
<div @click="disableClient(client)" v-if="client.enabled === true" title="Disable Client"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-red-800 cursor-pointer hover:bg-red-700 transition-all">
<div class="rounded-full w-4 h-4 m-1 ml-5 bg-white"></div>
</div>
<div @click="enableClient(client)" v-if="client.enabled === false" title="Enable Client"
class="inline-block align-middle rounded-full w-10 h-6 mr-1 bg-gray-200 cursor-pointer hover:bg-gray-300 transition-all">
<div class="rounded-full w-4 h-4 m-1 bg-white"></div>
</div>
<!-- Show QR-->
<button class="align-middle bg-gray-100 hover:bg-red-800 hover:text-white p-2 rounded transition"
title="Show QR Code" @click="qrcode = `/api/wireguard/client/${client.id}/qrcode.svg`">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
</button>
<!-- Download Config -->
<a :href="'/api/wireguard/client/' + client.id + '/configuration'" download
class="align-middle inline-block bg-gray-100 hover:bg-red-800 hover:text-white p-2 rounded transition"
title="Download Configuration">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<!-- Delete -->
<button class="align-middle bg-gray-100 hover:bg-red-800 hover:text-white p-2 rounded transition"
title="Delete Client" @click="clientDelete = client">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div v-if="clients && clients.length === 0">
<p class="text-center m-10 text-gray-400 text-sm">There are no clients yet.<br /><br />
<button @click="clientCreate = true; clientCreateName = '';"
class="bg-red-800 text-white hover:bg-red-700 border-2 border-none py-2 px-4 rounded inline-flex items-center transition">
<svg class="w-4 mr-2" inline xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span class="text-sm">New Client</span>
</button>
</p>
</div>
<div v-if="clients === null" class="text-gray-200 p-5">
<svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
</div>
</div>
<!-- QR Code-->
<div v-if="qrcode">
<div class="bg-black bg-opacity-50 fixed top-0 right-0 left-0 bottom-0 flex items-center justify-center z-20">
<div class="bg-white rounded-md shadow-lg relative p-8">
<button @click="qrcode = null" class="absolute right-4 top-4 text-gray-600 hover:text-gray-800">
<svg class="w-8" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img :src="qrcode" />
</div>
</div>
</div>
<!-- Create Dialog -->
<div v-if="clientCreate" class="fixed z-10 inset-0 overflow-y-auto">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
-->
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-800 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-white" inline xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div class="flex-grow mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
New Client
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
<input class="rounded p-2 border-2 border-gray-100 focus:border-gray-200 outline-none w-full"
type="text" v-model.trim="clientCreateName" placeholder="Name" />
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button v-if="clientCreateName.length" type="button" @click="createClient(); clientCreate = null"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-800 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
Create
</button>
<button v-else type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-200 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm cursor-not-allowed">
Create
</button>
<button type="button" @click="clientCreate = null"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Delete Dialog -->
<div v-if="clientDelete" class="fixed z-10 inset-0 overflow-y-auto">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
-->
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<!-- Heroicon name: outline/exclamation -->
<svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Delete Client
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Are you sure you want to delete <strong>{{clientDelete.name}}</strong>?
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" @click="deleteClient(clientDelete); clientDelete = null"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
Delete
</button>
<button type="button" @click="clientDelete = null"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="authenticated === false">
<h1 class="text-4xl font-medium my-16 text-gray-700 text-center">WireGuard</h1>
<form @submit="login" class="shadow rounded-md bg-white mx-auto w-64 p-5 overflow-hidden mt-10">
<!-- Avatar -->
<div class="h-20 w-20 mb-10 mt-5 mx-auto rounded-full bg-red-800 relative overflow-hidden">
<svg class="w-10 h-10 m-5 text-white" 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" clip-rule="evenodd" />
</svg>
</div>
<input type="password" name="password" placeholder="Password" v-model="password"
class="px-3 py-2 text-sm text-gray-500 mb-5 border-2 border-gray-100 rounded-lg w-full focus:border-red-800 outline-none" />
<button v-if="authenticating"
class="bg-red-800 w-full rounded shadow py-2 text-sm text-white cursor-not-allowed">
<svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@ -162,220 +442,40 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
</div>
</button>
<input v-if="!authenticating && password" type="submit"
class="bg-red-800 w-full rounded shadow py-2 text-sm text-white hover:bg-red-700 transition cursor-pointer"
value="Sign In">
<input v-if="!authenticating && !password" type="submit"
class="bg-gray-200 w-full rounded shadow py-2 text-sm text-white cursor-not-allowed" value="Sign In">
</form>
</div>
<!-- QR Code-->
<div v-if="qrcode">
<div class="bg-black bg-opacity-50 fixed top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="bg-white rounded-md shadow-lg relative p-8">
<button @click="qrcode = null" class="absolute right-4 top-4 text-gray-600 hover:text-gray-800">
<svg class="w-8" 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img :src="qrcode" />
</div>
</div>
<div v-if="authenticated === null" class="text-gray-300 pt-24 pb-12">
<svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
<!-- Create Dialog -->
<div v-if="clientCreate" class="fixed z-10 inset-0 overflow-y-auto">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
-->
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-800 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-white" inline xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div class="flex-grow mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
New Client
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
<input class="rounded p-2 border-2 border-gray-100 focus:border-gray-200 outline-none w-full"
type="text" v-model.trim="clientCreateName" placeholder="Name" />
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button v-if="clientCreateName.length" type="button" @click="createClient(); clientCreate = null"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-800 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
Create
</button>
<button v-else type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-200 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm cursor-not-allowed">
Create
</button>
<button type="button" @click="clientCreate = null"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Delete Dialog -->
<div v-if="clientDelete" class="fixed z-10 inset-0 overflow-y-auto">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
-->
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<!-- Heroicon name: outline/exclamation -->
<svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Delete Client
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Are you sure you want to delete <strong>{{clientDelete.name}}</strong>?
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" @click="deleteClient(clientDelete); clientDelete = null"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
Delete
</button>
<button type="button" @click="clientDelete = null"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="authenticated === false">
<h1 class="text-4xl font-medium my-16 text-gray-700 text-center">WireGuard</h1>
<form @submit="login" class="shadow rounded-md bg-white mx-auto w-64 p-5 overflow-hidden mt-10">
<!-- Avatar -->
<div class="h-20 w-20 mb-10 mt-5 mx-auto rounded-full bg-red-800 relative overflow-hidden">
<svg class="w-10 h-10 m-5 text-white" 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" clip-rule="evenodd" />
</svg>
</div>
<input type="password" name="password" placeholder="Password" v-model="password"
class="px-3 py-2 text-sm text-gray-500 mb-5 border-2 border-gray-100 rounded-lg w-full focus:border-red-800 outline-none" />
<button v-if="authenticating"
class="bg-red-800 w-full rounded shadow py-2 text-sm text-white cursor-not-allowed">
<svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</button>
<input v-if="!authenticating && password" type="submit"
class="bg-red-800 w-full rounded shadow py-2 text-sm text-white hover:bg-red-700 transition cursor-pointer"
value="Sign In">
<input v-if="!authenticating && !password" type="submit"
class="bg-gray-200 w-full rounded shadow py-2 text-sm text-white cursor-not-allowed" value="Sign In">
</form>
</div>
<div v-if="authenticated === null" class="text-gray-300 pt-24 pb-12">
<svg class="w-5 animate-spin mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
<p class="text-center m-10 text-gray-300 text-xs">Made by <a target="_blank" class="hover:underline"
href="https://emilenijssen.nl/?ref=wg-easy">Emile Nijssen</a> · <a class="hover:underline"
href="https://github.com/sponsors/WeeJeWel" target="_blank">Donate</a> · <a class="hover:underline"
href="https://github.com/weejewel/wg-easy" target="_blank">GitHub</a></p>
</div>
<script src="/js/vendor/vue.min.js"></script>
<script src="/js/vendor/apexcharts.min.js"></script>
<script src="/js/vendor/vue-apexcharts.min.js"></script>
<script src="/js/vendor/md5.min.js"></script>
<script src="/js/vendor/timeago.min.js"></script>
<script src="/js/api.js"></script>

View File

@ -29,6 +29,13 @@ class API {
return json;
}
async getRelease() {
return this.call({
method: 'get',
path: '/release',
});
}
async getSession() {
return this.call({
method: 'get',
@ -94,4 +101,20 @@ class API {
});
}
async updateClientName({ clientId, name }) {
return this.call({
method: 'put',
path: `/wireguard/client/${clientId}/name/`,
body: { name },
});
}
async updateClientAddress({ clientId, address }) {
return this.call({
method: 'put',
path: `/wireguard/client/${clientId}/address/`,
body: { address },
});
}
}

View File

@ -7,6 +7,9 @@
new Vue({
el: '#app',
components: {
apexchart: VueApexCharts,
},
data: {
authenticated: null,
authenticating: false,
@ -14,10 +17,79 @@ new Vue({
requiresPassword: null,
clients: null,
clientsPersist: {},
clientDelete: null,
clientCreate: null,
clientCreateName: '',
clientEditName: null,
clientEditNameId: null,
clientEditAddress: null,
clientEditAddressId: null,
qrcode: null,
currentRelease: null,
latestRelease: null,
chartOptions: {
chart: {
background: 'transparent',
type: 'area',
toolbar: {
show: false,
},
},
fill: {
type: 'gradient',
},
colors: ['#CCCCCC'],
dataLabels: {
enabled: false,
},
stroke: {
curve: 'smooth',
width: 0,
},
xaxis: {
labels: {
show: false,
},
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
},
yaxis: {
labels: {
show: false,
},
min: 0,
},
tooltip: {
enabled: false,
},
legend: {
show: false,
},
grid: {
show: false,
padding: {
left: -10,
right: 0,
bottom: -15,
top: -15,
},
column: {
opacity: 0,
},
xaxis: {
lines: {
show: false,
},
},
},
},
},
methods: {
dateTime: value => {
@ -38,10 +110,51 @@ new Vue({
client.avatar = `https://www.gravatar.com/avatar/${md5(client.name)}?d=blank`;
}
if (!this.clientsPersist[client.id]) {
this.clientsPersist[client.id] = {};
this.clientsPersist[client.id].transferRxHistory = Array(20).fill(0);
this.clientsPersist[client.id].transferRxPrevious = client.transferRx;
this.clientsPersist[client.id].transferTxHistory = Array(20).fill(0);
this.clientsPersist[client.id].transferTxPrevious = client.transferTx;
this.clientsPersist[client.id].chartOptions = {
...this.chartOptions,
yaxis: {
...this.chartOptions.yaxis,
max: () => this.clientsPersist[client.id].chartMax,
},
};
}
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;
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.transferTxSeries = [{
name: 'tx',
data: this.clientsPersist[client.id].transferTxHistory,
}];
client.transferRxCurrent = this.clientsPersist[client.id].transferRxCurrent;
client.transferRxSeries = [{
name: 'rx',
data: this.clientsPersist[client.id].transferRxHistory,
}];
this.clientsPersist[client.id].chartMax = Math.max(...this.clientsPersist[client.id].transferTxHistory, ...this.clientsPersist[client.id].transferRxHistory);
client.chartOptions = this.clientsPersist[client.id].chartOptions;
return client;
});
console.log(clients);
},
login(e) {
e.preventDefault();
@ -64,6 +177,7 @@ new Vue({
})
.finally(() => {
this.authenticating = false;
this.password = null;
});
},
logout(e) {
@ -101,28 +215,22 @@ new Vue({
.catch(err => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
updateClientName(client, name) {
this.api.updateClientName({ clientId: client.id, name })
.catch(err => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
updateClientAddress(client, address) {
this.api.updateClientAddress({ clientId: client.id, address })
.catch(err => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
},
filters: {
bytes,
timeago: value => {
return timeago().format(value);
},
bytes: (bytes, decimals, kib, maxunit) => {
kib = kib || false;
if (bytes === 0) return '0 Bytes';
if (Number.isNaN(parseFloat(bytes)) && !Number.isFinite(bytes)) return 'Not an number';
const k = kib ? 1024 : 1000;
const dm = decimals != null && !Number.isNaN(decimals) && decimals >= 0 ? decimals : 2;
const sizes = kib
? ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', 'BiB']
: ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB'];
let i = Math.floor(Math.log(bytes) / Math.log(k));
if (maxunit !== undefined) {
const index = sizes.indexOf(maxunit);
if (index !== -1) i = index;
}
// eslint-disable-next-line no-restricted-properties
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
},
},
mounted() {
this.api = new API();
@ -141,5 +249,48 @@ new Vue({
setInterval(() => {
this.refresh().catch(console.error);
}, 1000);
Promise.resolve().then(async () => {
const currentRelease = await this.api.getRelease();
const latestRelease = await fetch('https://weejewel.github.io/wg-easy/changelog.json')
.then(res => res.json())
.then(releases => {
const releasesArray = Object.entries(releases).map(([version, changelog]) => ({
version: parseInt(version, 10),
changelog,
}));
releasesArray.sort((a, b) => {
return b.version - a.version;
});
return releasesArray[0];
});
console.log(`Current Release: ${currentRelease}`);
console.log(`Latest Release: ${latestRelease.version}`);
if (currentRelease >= latestRelease.version) return;
this.currentRelease = currentRelease;
this.latestRelease = latestRelease;
}).catch(console.error);
},
});
function bytes(bytes, decimals, kib, maxunit) {
kib = kib || false;
if (bytes === 0) return '0 B';
if (Number.isNaN(parseFloat(bytes)) && !Number.isFinite(bytes)) return 'NaN';
const k = kib ? 1024 : 1000;
const dm = decimals != null && !Number.isNaN(decimals) && decimals >= 0 ? decimals : 2;
const sizes = kib
? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', 'BiB']
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB'];
let i = Math.floor(Math.log(bytes) / Math.log(k));
if (maxunit !== undefined) {
const index = sizes.indexOf(maxunit);
if (index !== -1) i = index;
}
// eslint-disable-next-line no-restricted-properties
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}

14
src/www/js/vendor/apexcharts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
/**
* Minified by jsDelivr using Terser v5.7.1.
* Original file: /npm/vue-apexcharts@1.6.2/dist/vue-apexcharts.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function (t, e) { "object" == typeof exports && "undefined" != typeof module ? module.exports = e(require("apexcharts/dist/apexcharts.min")) : "function" == typeof define && define.amd ? define(["apexcharts/dist/apexcharts.min"], e) : t.VueApexCharts = e(t.ApexCharts) }(this, (function (t) { "use strict"; function e(t) { return (e = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (t) { return typeof t } : function (t) { return t && "function" == typeof Symbol && t.constructor === Symbol && t !== Symbol.prototype ? "symbol" : typeof t })(t) } function n(t, e, n) { return e in t ? Object.defineProperty(t, e, { value: n, enumerable: !0, configurable: !0, writable: !0 }) : t[e] = n, t } t = t && t.hasOwnProperty("default") ? t.default : t; var i = { props: { options: { type: Object }, type: { type: String }, series: { type: Array, required: !0, default: function () { return [] } }, width: { default: "100%" }, height: { default: "auto" } }, data: function () { return { chart: null } }, beforeMount: function () { window.ApexCharts = t }, mounted: function () { this.init() }, created: function () { var t = this; this.$watch("options", (function (e) { !t.chart && e ? t.init() : t.chart.updateOptions(t.options) })), this.$watch("series", (function (e) { !t.chart && e ? t.init() : t.chart.updateSeries(t.series) }));["type", "width", "height"].forEach((function (e) { t.$watch(e, (function () { t.refresh() })) })) }, beforeDestroy: function () { this.chart && this.destroy() }, render: function (t) { return t("div") }, methods: { init: function () { var e = this, n = { chart: { type: this.type || this.options.chart.type || "line", height: this.height, width: this.width, events: {} }, series: this.series }; Object.keys(this.$listeners).forEach((function (t) { n.chart.events[t] = e.$listeners[t] })); var i = this.extend(this.options, n); return this.chart = new t(this.$el, i), this.chart.render() }, isObject: function (t) { return t && "object" === e(t) && !Array.isArray(t) && null != t }, extend: function (t, e) { var i = this; "function" != typeof Object.assign && (Object.assign = function (t) { if (null == t) throw new TypeError("Cannot convert undefined or null to object"); for (var e = Object(t), n = 1; n < arguments.length; n++) { var i = arguments[n]; if (null != i) for (var r in i) i.hasOwnProperty(r) && (e[r] = i[r]) } return e }); var r = Object.assign({}, t); return this.isObject(t) && this.isObject(e) && Object.keys(e).forEach((function (o) { i.isObject(e[o]) && o in t ? r[o] = i.extend(t[o], e[o]) : Object.assign(r, n({}, o, e[o])) })), r }, refresh: function () { return this.destroy(), this.init() }, destroy: function () { this.chart.destroy() }, updateSeries: function (t, e) { return this.chart.updateSeries(t, e) }, updateOptions: function (t, e, n, i) { return this.chart.updateOptions(t, e, n, i) }, toggleSeries: function (t) { return this.chart.toggleSeries(t) }, showSeries: function (t) { this.chart.showSeries(t) }, hideSeries: function (t) { this.chart.hideSeries(t) }, appendSeries: function (t, e) { return this.chart.appendSeries(t, e) }, resetSeries: function () { this.chart.resetSeries() }, zoomX: function (t, e) { this.chart.zoomX(t, e) }, toggleDataPointSelection: function (t, e) { this.chart.toggleDataPointSelection(t, e) }, appendData: function (t) { return this.chart.appendData(t) }, addText: function (t) { this.chart.addText(t) }, addImage: function (t) { this.chart.addImage(t) }, addShape: function (t) { this.chart.addShape(t) }, dataURI: function () { return this.chart.dataURI() }, setLocale: function (t) { return this.chart.setLocale(t) }, addXaxisAnnotation: function (t, e) { this.chart.addXaxisAnnotation(t, e) }, addYaxisAnnotation: function (t, e) { this.chart.addYaxisAnnotation(t, e) }, addPointAnnotation: function (t, e) { this.chart.addPointAnnotation(t, e) }, removeAnnotation: function (t, e) { this.chart.removeAnnotation(t, e) }, clearAnnotations: function () { this.chart.clearAnnotations() } } }; return window.ApexCharts = t, i.install = function (e) { e.ApexCharts = t, window.ApexCharts = t, Object.defineProperty(e.prototype, "$apexcharts", { get: function () { return t } }) }, i }));