This commit is contained in:
Emile Nijssen 2021-05-22 22:21:09 +02:00
parent eaf3d5c3fb
commit d7bb645470
25 changed files with 4632 additions and 2 deletions

BIN
assets/wg-easy.sketch Normal file

Binary file not shown.

11
config/wg0 copy.conf Normal file
View File

@ -0,0 +1,11 @@
[Interface]
PrivateKey = iOQJS7OUUGPYATsX6nqlL+sOODoiWiN5IOE8Msfw/0o= # wg genkey > privatekey
# PublicKey = BkdntwYazhYZzEEHhcYayq6TGw9/YUDQ251s+5bTgC0=
Address = 10.8.0.1/24
ListenPort = 51820
DNS = 1.1.1.1
[Peer]
PublicKey = i8xWKqicnDkNL14I4B+I1zlB8od/booA1joIosWn7X4=
PresharedKey = MzplKtOQ44/IaAKri2VKqCoIlg4XiVH7TCp5bcYRTQU=
AllowedIPs = 10.8.0.2/32

View File

@ -1,10 +1,15 @@
# Note: Do not edit this file directly.
# Your changes will be overwritten!
# Server
[Interface]
PrivateKey = iOQJS7OUUGPYATsX6nqlL+sOODoiWiN5IOE8Msfw/0o= # wg genkey > privatekey
# PublicKey = BkdntwYazhYZzEEHhcYayq6TGw9/YUDQ251s+5bTgC0=
PrivateKey = iOQJS7OUUGPYATsX6nqlL+sOODoiWiN5IOE8Msfw/0o=
Address = 10.8.0.1/24
ListenPort = 51820
DNS = 1.1.1.1
# Client: Emile (af3111a4-7343-4380-a293-ed498d9aa3b8)
[Peer]
PublicKey = i8xWKqicnDkNL14I4B+I1zlB8od/booA1joIosWn7X4=
PresharedKey = MzplKtOQ44/IaAKri2VKqCoIlg4XiVH7TCp5bcYRTQU=

22
config/wg0.json Normal file
View File

@ -0,0 +1,22 @@
{
"server": {
"privateKey": "iOQJS7OUUGPYATsX6nqlL+sOODoiWiN5IOE8Msfw/0o=",
"publicKey": "BkdntwYazhYZzEEHhcYayq6TGw9/YUDQ251s+5bTgC0=",
"address": "10.8.0.1/24",
"port": "51820",
"dns": "1.1.1.1"
},
"clients": {
"af3111a4-7343-4380-a293-ed498d9aa3b8": {
"name": "Emile",
"createdAt": "2021-05-22T20:02:45.372Z",
"updatedAt": "2021-05-22T20:02:45.372Z",
"privateKey": "sHUUDbaZBQshfOvvF8HeebhhXq3rDKWlW1Vm+6XMklU=",
"publicKey": "i8xWKqicnDkNL14I4B+I1zlB8od/booA1joIosWn7X4=",
"preSharedKey": "MzplKtOQ44/IaAKri2VKqCoIlg4XiVH7TCp5bcYRTQU=",
"address": "10.8.0.2/32",
"allowedIPs": "10.8.0.2/32",
"enabled": true
}
}
}

7
src/.eslintrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "athom",
"rules": {
"consistent-return": "off",
"no-shadow": "off"
}
}

1
src/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/node_modules

8
src/config.js Normal file
View File

@ -0,0 +1,8 @@
'use strict';
module.exports.PORT = process.env.PORT || 80;
module.exports.PASSWORD = process.env.PASSWORD || 'wireguard';
module.exports.WG_PATH = process.env.WG_PATH || '/etc/wireguard/';
module.exports.WG_HOST = process.env.WG_HOST || '127.0.0.1';
module.exports.WG_PORT = process.env.WG_PORT || 51820;
module.exports.WG_DEFAULT_ADDRESS = process.env.WG_DEFAULT_ADDRESS || '10.6.0.1/32';

104
src/lib/Server.js Normal file
View File

@ -0,0 +1,104 @@
'use strict';
const path = require('path');
const express = require('express');
const expressSession = require('express-session');
const debug = require('debug')('Server');
const Util = require('./Util');
const ServerError = require('./ServerError');
const WireGuard = require('../services/WireGuard');
const {
PORT,
PASSWORD,
} = require('../config');
module.exports = class Server {
constructor() {
// Express
this.app = express()
.use('/', express.static(path.join(__dirname, '..', 'www')))
.use(express.json())
.use(expressSession({
secret: String(Math.random()),
resave: true,
saveUninitialized: true,
}))
// Authentication
.get('/api/session', Util.promisify(async req => {
return {
authenticated: !!(req.session && req.session.authenticated),
};
}))
.post('/api/session', Util.promisify(async req => {
const {
password,
} = req.body;
if (typeof password !== 'string') {
throw new ServerError('Missing: Password', 401);
}
if (password !== PASSWORD) {
throw new ServerError('Incorrect Password', 401);
}
req.session.authenticated = true;
req.session.save();
debug(`New Session: ${req.session.id})`);
}))
// WireGuard
.use(Util.requireSession)
.delete('/api/session', Util.promisify(async req => {
const sessionId = req.session.id;
req.session.destroy();
debug(`Deleted Session: ${sessionId}`);
}))
.get('/api/wireguard/client', Util.promisify(async req => {
return WireGuard.getClients();
}))
.get('/api/wireguard/client/:clientId/qrcode.svg', Util.promisify(async (req, res) => {
const { clientId } = req.params;
const svg = await WireGuard.getClientQRCodeSVG({ clientId });
res.header('Content-Type', 'image/svg+xml');
res.send(svg);
}))
.get('/api/wireguard/client/:clientId/configuration', Util.promisify(async (req, res) => {
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"`);
res.header('Content-Type', 'text/plain');
res.send(config);
}))
.post('/api/wireguard/client', Util.promisify(async req => {
const { name } = req.body;
return WireGuard.createClient({ name });
}))
.delete('/api/wireguard/client/:clientId', Util.promisify(async req => {
const { clientId } = req.params;
return WireGuard.deleteClient({ clientId });
}))
.post('/api/wireguard/client/:clientId/enable', Util.promisify(async req => {
const { clientId } = req.params;
return WireGuard.enableClient({ clientId });
}))
.post('/api/wireguard/client/:clientId/disable', Util.promisify(async req => {
const { clientId } = req.params;
return WireGuard.disableClient({ clientId });
}))
.listen(PORT, () => {
debug(`Listening on http://0.0.0.0:${PORT}`);
});
}
};

10
src/lib/ServerError.js Normal file
View File

@ -0,0 +1,10 @@
'use strict';
module.exports = class ServerError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
}
};

46
src/lib/Util.js Normal file
View File

@ -0,0 +1,46 @@
'use strict';
module.exports = class Util {
static requireSession(req, res, next) {
if (req.session && req.session.authenticated) {
return next();
}
return res.status(401).json({
error: 'Not Logged In',
});
}
static promisify(fn) {
return function(req, res) {
Promise.resolve().then(async () => fn(req, res))
.then(result => {
if (res.headersSent) return;
if (typeof result === 'undefined') {
return res
.status(204)
.end();
}
return res
.status(200)
.json(result);
})
.catch(error => {
if (typeof error === 'string') {
error = new Error(error);
}
return res
.status(error.statusCode || 500)
.json({
error: error.message || error.toString(),
stack: error.stack,
});
});
};
}
};

183
src/lib/WireGuard.js Normal file
View File

@ -0,0 +1,183 @@
'use strict';
const fs = require('fs').promises;
const path = require('path');
const QRCode = require('qrcode');
const ServerError = require('./ServerError');
const {
WG_PATH,
WG_HOST,
WG_PORT,
WG_DEFAULT_ADDRESS,
} = require('../config');
module.exports = class WireGuard {
constructor() {
Promise.resolve().then(async () => {
try {
const config = await fs.readFile(path.join(WG_PATH, 'wg0.json'), 'utf8');
this.config = JSON.parse(config);
} catch (err) {
this.config = {
// TODO: Generate new config
server: {
address: WG_DEFAULT_ADDRESS,
},
clients: {},
};
// TODO: Save JSON config
}
console.log('this.config', this.config);
await this.saveConfig();
}).catch(err => {
// eslint-disable-next-line no-console
console.error(err);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
}
async saveConfig() {
let result = `
# Note: Do not edit this file directly.
# Your changes will be overwritten!
# Server
[Interface]
PrivateKey = ${this.config.server.privateKey}
Address = ${this.config.server.address}
ListenPort = ${this.config.server.port}
DNS = ${this.config.server.dns}`;
for (const [clientId, client] of Object.entries(this.config.clients)) {
if (!client.enabled) continue;
result += `
# Client: ${client.name} (${clientId})
[Peer]
PublicKey = ${client.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIPs}`;
}
await fs.writeFile(path.join(WG_PATH, 'wg0.json'), JSON.stringify(this.config, false, 2));
await fs.writeFile(path.join(WG_PATH, 'wg0.conf'), result);
}
async getClients() {
return Object.entries(this.config.clients).map(([clientId, client]) => ({
id: clientId,
name: client.name,
enabled: client.enabled,
publicKey: client.publicKey,
createdAt: client.createdAt,
updatedAt: client.updatedAt,
allowedIPs: client.allowedIPs,
// TODO:
latestHandshake: new Date(),
transferRx: 0,
transferTx: 0,
}));
// const { stdout } = await Util.exec('sudo cat /etc/wireguard/configs/clients.txt');
// return stdout
// .trim()
// .split('\n')
// .filter(line => {
// return line.length > 0;
// })
// .map(line => {
// const [ name, publicKey, createdAt ] = line.split(' ');
// return {
// name,
// publicKey,
// createdAt: new Date(Number(createdAt + '000')),
// };
// });
}
async getClient({ clientId }) {
const client = this.config.clients[clientId];
if (!client) {
throw new ServerError(`Client Not Found: ${clientId}`, 404);
}
return client;
}
async getClientConfiguration({ clientId }) {
const client = await this.getClient({ clientId });
return `
[Interface]
PrivateKey = ${client.privateKey}
Address = ${client.address}
DNS = ${this.config.server.dns}
[Peer]
PublicKey = ${client.publicKey}
PresharedKey = ${client.preSharedKey}
AllowedIPs = ${client.allowedIPs}
Endpoint = ${WG_HOST}:${WG_PORT}`;
}
async getClientQRCodeSVG({ clientId }) {
const config = await this.getClientConfiguration({ clientId });
return QRCode.toString(config, {
type: 'svg',
width: 512,
});
}
async createClient({ name }) {
if (!name) {
throw new Error('Missing: Name');
}
// try {
// await this.getClient({ name });
// throw new Error(`Duplicate Client: ${name}`);
// } catch( err ) {
// if( err.message.startsWith('Duplicate Client') ) {
// throw err;
// }
// }
// // TODO: This is unsafe
// await this.ssh.exec(`pivpn add -n ${name}`);
// return this.getClient({ name });
await this.saveConfig();
}
async deleteClient({ clientId }) {
if (this.config.clients[clientId]) {
delete this.config.clients[clientId];
await this.saveConfig();
}
}
async enableClient({ clientId }) {
const client = await this.getClient({ clientId });
client.enabled = true;
await this.saveConfig();
}
async disableClient({ clientId }) {
const client = await this.getClient({ clientId });
client.enabled = false;
await this.saveConfig();
}
};

3560
src/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
src/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "wg-easy",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"serve": "DEBUG=Server PASSWORD=p WG_PATH=../config/ nodemon server.js"
},
"author": "Emile Nijssen",
"license": "GPL",
"dependencies": {
"debug": "^4.3.1",
"express": "^4.17.1",
"express-session": "^1.17.1",
"qrcode": "^1.4.4",
"uuid": "^8.3.2"
},
"devDependencies": {
"eslint": "^7.27.0",
"eslint-config-athom": "^2.1.0",
"nodemon": "^2.0.7"
},
"nodemonConfig": {
"ignore": [
"www/*"
]
},
"engines": {
"node": "16"
}
}

3
src/server.js Normal file
View File

@ -0,0 +1,3 @@
'use strict';
require('./services/Server');

5
src/services/Server.js Normal file
View File

@ -0,0 +1,5 @@
'use strict';
const Server = require('../lib/Server');
module.exports = new Server();

View File

@ -0,0 +1,5 @@
'use strict';
const WireGuard = require('../lib/WireGuard');
module.exports = new WireGuard();

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

380
src/www/index.html Normal file
View File

@ -0,0 +1,380 @@
<!DOCTYPE html>
<html>
<head>
<title>WireGuard</title>
<link href="https://unpkg.com/tailwindcss@^2/dist/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">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
</head>
<body class="bg-gray-50">
<div id="app" class="container mx-auto">
<div v-if="authenticated === true">
<h1 class="text-4xl font-medium mt-10 mb-2">WireGuard</h1>
<h2 class="text-sm text-gray-400 mb-10"><span class="cursor-pointer hover:underline" @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></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-green-400 hover:border-green-400 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>
</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.latestHandshake && ((new Date() - new Date(client.latestHandshake) < 1000 * 60 * 10))">
<div class="animate-ping w-4 h-4 p-1 bg-green-100 rounded-full absolute -bottom-1 -right-1"></div>
<div class="w-2 h-2 bg-green-300 rounded-full absolute bottom-0 right-0"></div>
</div>
</div>
<div class="flex-grow">
<div class="text-gray-700" :title="'Created at ' + dateTime(new Date(client.createdAt))">{{client.name}}
</div>
<div v-if="client.allowedIps" class="text-gray-300 text-xs">{{client.iface}}
· {{client.allowedIps.split('/')[0]}}
<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.latestHandshake"
:title="'Last seen at ' + dateTime(new Date(client.latestHandshake))">
· {{new Date(client.latestHandshake) | timeago}}
</span>
</div>
</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-green-400 cursor-pointer hover:bg-green-500 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-blue-400 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-blue-400 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-400 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 />
<button @click="clientCreate = true; clientCreateName = '';"
class="bg-green-400 text-white hover:bg-green-500 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">
<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-green-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-green-600" 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-green-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-green-400 text-base font-medium text-white hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 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 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>
<!-- 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-gray-100 relative overflow-hidden">
<svg class="w-10 h-10 m-5 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>
</div>
<input type="password" name="password" placeholder="password" v-model="password"
class="px-2 py-1 text-sm text-gray-500 mb-5 border-2 border-gray-100 rounded-lg w-full focus:border-green-400 outline-none" />
<button v-if="authenticating"
class="bg-green-400 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-green-400 w-full rounded shadow py-2 text-sm text-white hover:bg-green-500 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/weejewel/wg-easy" target="_blank">GitHub</a></p>
</div>
<script src="/js/vendor/vue.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>
<script src="/js/app.js"></script>
</body>
</html>

90
src/www/js/api.js Normal file
View File

@ -0,0 +1,90 @@
/* eslint-disable no-unused-vars */
/* eslint-disable no-undef */
'use strict';
class API {
async call({ method, path, body }) {
const res = await fetch(`/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
},
body: body
? JSON.stringify(body)
: undefined,
});
if (res.status === 204) {
return undefined;
}
const json = await res.json();
if (!res.ok) {
throw new Error(json.error || res.statusText);
}
return json;
}
async getSession() {
return this.call({
method: 'get',
path: '/session',
});
}
async createSession({ password }) {
return this.call({
method: 'post',
path: '/session',
body: { password },
});
}
async deleteSession() {
return this.call({
method: 'delete',
path: '/session',
});
}
async getClients() {
return this.call({
method: 'get',
path: '/wireguard/client',
});
}
async createClient({ name }) {
return this.call({
method: 'post',
path: '/wireguard/client',
body: { name },
});
}
async deleteClient({ clientId }) {
return this.call({
method: 'delete',
path: `/wireguard/client/${clientId}`,
});
}
async enableClient({ clientId }) {
return this.call({
method: 'post',
path: `/wireguard/client/${clientId}/enable`,
});
}
async disableClient({ clientId }) {
return this.call({
method: 'post',
path: `/wireguard/client/${clientId}/disable`,
});
}
}

140
src/www/js/app.js Normal file
View File

@ -0,0 +1,140 @@
/* eslint-disable no-console */
/* eslint-disable no-alert */
/* eslint-disable no-undef */
/* eslint-disable no-new */
'use strict';
new Vue({
el: '#app',
data: {
authenticated: null,
authenticating: false,
password: null,
clients: null,
clientDelete: null,
clientCreate: null,
clientCreateName: '',
qrcode: null,
},
methods: {
dateTime: value => {
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(value);
},
async refresh() {
if (!this.authenticated) return;
const clients = await this.api.getClients();
this.clients = clients.map(client => {
if (client.name.includes('@') && client.name.includes('.')) {
client.avatar = `https://www.gravatar.com/avatar/${md5(client.name)}?d=blank`;
}
return client;
});
},
login(e) {
e.preventDefault();
if (!this.password) return;
if (this.authenticating) return;
this.authenticating = true;
this.api.createSession({
password: this.password,
})
.then(async () => {
const session = await this.api.getSession();
this.authenticated = session.authenticated;
return this.refresh();
})
.catch(err => {
alert(err.message || err.toString());
})
.finally(() => {
this.authenticating = false;
});
},
logout(e) {
e.preventDefault();
this.api.deleteSession()
.then(() => {
this.authenticated = false;
this.clients = null;
})
.catch(err => {
alert(err.message || err.toString());
});
},
createClient() {
const name = this.clientCreateName;
if (!name) return;
this.api.createClient({ name })
.catch(err => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
deleteClient(clientId) {
this.api.deleteClient({ clientId: client.id })
.catch(err => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
enableClient(client) {
this.api.enableClient({ clientId: client.id })
.catch(err => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
disableClient(client) {
this.api.disableClient({ clientId: client.id })
.catch(err => alert(err.message || err.toString()))
.finally(() => this.refresh().catch(console.error));
},
},
filters: {
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();
this.api.getSession()
.then(session => {
this.authenticated = session.authenticated;
this.refresh().catch(err => {
alert(err.message || err.toString());
});
})
.catch(err => {
alert(err.message || err.toString());
});
setInterval(() => {
this.refresh().catch(console.error);
}, 1000);
},
});

1
src/www/js/vendor/md5.min.js vendored Normal file
View File

@ -0,0 +1 @@
!function(n){"use strict";function d(n,t){var r=(65535&n)+(65535&t);return(n>>16)+(t>>16)+(r>>16)<<16|65535&r}function f(n,t,r,e,o,u){return d((c=d(d(t,n),d(e,u)))<<(f=o)|c>>>32-f,r);var c,f}function l(n,t,r,e,o,u,c){return f(t&r|~t&e,n,t,o,u,c)}function v(n,t,r,e,o,u,c){return f(t&e|r&~e,n,t,o,u,c)}function g(n,t,r,e,o,u,c){return f(t^r^e,n,t,o,u,c)}function m(n,t,r,e,o,u,c){return f(r^(t|~e),n,t,o,u,c)}function i(n,t){var r,e,o,u;n[t>>5]|=128<<t%32,n[14+(t+64>>>9<<4)]=t;for(var c=1732584193,f=-271733879,i=-1732584194,a=271733878,h=0;h<n.length;h+=16)c=l(r=c,e=f,o=i,u=a,n[h],7,-680876936),a=l(a,c,f,i,n[h+1],12,-389564586),i=l(i,a,c,f,n[h+2],17,606105819),f=l(f,i,a,c,n[h+3],22,-1044525330),c=l(c,f,i,a,n[h+4],7,-176418897),a=l(a,c,f,i,n[h+5],12,1200080426),i=l(i,a,c,f,n[h+6],17,-1473231341),f=l(f,i,a,c,n[h+7],22,-45705983),c=l(c,f,i,a,n[h+8],7,1770035416),a=l(a,c,f,i,n[h+9],12,-1958414417),i=l(i,a,c,f,n[h+10],17,-42063),f=l(f,i,a,c,n[h+11],22,-1990404162),c=l(c,f,i,a,n[h+12],7,1804603682),a=l(a,c,f,i,n[h+13],12,-40341101),i=l(i,a,c,f,n[h+14],17,-1502002290),c=v(c,f=l(f,i,a,c,n[h+15],22,1236535329),i,a,n[h+1],5,-165796510),a=v(a,c,f,i,n[h+6],9,-1069501632),i=v(i,a,c,f,n[h+11],14,643717713),f=v(f,i,a,c,n[h],20,-373897302),c=v(c,f,i,a,n[h+5],5,-701558691),a=v(a,c,f,i,n[h+10],9,38016083),i=v(i,a,c,f,n[h+15],14,-660478335),f=v(f,i,a,c,n[h+4],20,-405537848),c=v(c,f,i,a,n[h+9],5,568446438),a=v(a,c,f,i,n[h+14],9,-1019803690),i=v(i,a,c,f,n[h+3],14,-187363961),f=v(f,i,a,c,n[h+8],20,1163531501),c=v(c,f,i,a,n[h+13],5,-1444681467),a=v(a,c,f,i,n[h+2],9,-51403784),i=v(i,a,c,f,n[h+7],14,1735328473),c=g(c,f=v(f,i,a,c,n[h+12],20,-1926607734),i,a,n[h+5],4,-378558),a=g(a,c,f,i,n[h+8],11,-2022574463),i=g(i,a,c,f,n[h+11],16,1839030562),f=g(f,i,a,c,n[h+14],23,-35309556),c=g(c,f,i,a,n[h+1],4,-1530992060),a=g(a,c,f,i,n[h+4],11,1272893353),i=g(i,a,c,f,n[h+7],16,-155497632),f=g(f,i,a,c,n[h+10],23,-1094730640),c=g(c,f,i,a,n[h+13],4,681279174),a=g(a,c,f,i,n[h],11,-358537222),i=g(i,a,c,f,n[h+3],16,-722521979),f=g(f,i,a,c,n[h+6],23,76029189),c=g(c,f,i,a,n[h+9],4,-640364487),a=g(a,c,f,i,n[h+12],11,-421815835),i=g(i,a,c,f,n[h+15],16,530742520),c=m(c,f=g(f,i,a,c,n[h+2],23,-995338651),i,a,n[h],6,-198630844),a=m(a,c,f,i,n[h+7],10,1126891415),i=m(i,a,c,f,n[h+14],15,-1416354905),f=m(f,i,a,c,n[h+5],21,-57434055),c=m(c,f,i,a,n[h+12],6,1700485571),a=m(a,c,f,i,n[h+3],10,-1894986606),i=m(i,a,c,f,n[h+10],15,-1051523),f=m(f,i,a,c,n[h+1],21,-2054922799),c=m(c,f,i,a,n[h+8],6,1873313359),a=m(a,c,f,i,n[h+15],10,-30611744),i=m(i,a,c,f,n[h+6],15,-1560198380),f=m(f,i,a,c,n[h+13],21,1309151649),c=m(c,f,i,a,n[h+4],6,-145523070),a=m(a,c,f,i,n[h+11],10,-1120210379),i=m(i,a,c,f,n[h+2],15,718787259),f=m(f,i,a,c,n[h+9],21,-343485551),c=d(c,r),f=d(f,e),i=d(i,o),a=d(a,u);return[c,f,i,a]}function a(n){for(var t="",r=32*n.length,e=0;e<r;e+=8)t+=String.fromCharCode(n[e>>5]>>>e%32&255);return t}function h(n){var t=[];for(t[(n.length>>2)-1]=void 0,e=0;e<t.length;e+=1)t[e]=0;for(var r=8*n.length,e=0;e<r;e+=8)t[e>>5]|=(255&n.charCodeAt(e/8))<<e%32;return t}function e(n){for(var t,r="0123456789abcdef",e="",o=0;o<n.length;o+=1)t=n.charCodeAt(o),e+=r.charAt(t>>>4&15)+r.charAt(15&t);return e}function r(n){return unescape(encodeURIComponent(n))}function o(n){return a(i(h(t=r(n)),8*t.length));var t}function u(n,t){return function(n,t){var r,e,o=h(n),u=[],c=[];for(u[15]=c[15]=void 0,16<o.length&&(o=i(o,8*n.length)),r=0;r<16;r+=1)u[r]=909522486^o[r],c[r]=1549556828^o[r];return e=i(u.concat(h(t)),512+8*t.length),a(i(c.concat(e),640))}(r(n),r(t))}function t(n,t,r){return t?r?u(t,n):e(u(t,n)):r?o(n):e(o(n))}"function"==typeof define&&define.amd?define(function(){return t}):"object"==typeof module&&module.exports?module.exports=t:n.md5=t}(this);

1
src/www/js/vendor/timeago.min.js vendored Normal file
View File

@ -0,0 +1 @@
!function(t,e){"object"==typeof module&&module.exports?module.exports=e(t):t.timeago=e(t)}("undefined"!=typeof window?window:this,function(){function t(t){return t instanceof Date?t:isNaN(t)?/^\d+$/.test(t)?new Date(e(t,10)):(t=(t||"").trim().replace(/\.\d+/,"").replace(/-/,"/").replace(/-/,"/").replace(/T/," ").replace(/Z/," UTC").replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"),new Date(t)):new Date(e(t))}function e(t){return parseInt(t)}function n(t,n,r){n=d[n]?n:d[r]?r:"en";var i=0;for(agoin=t<0?1:0,t=Math.abs(t);t>=l[i]&&i<p;i++)t/=l[i];return t=e(t),i*=2,t>(0===i?9:1)&&(i+=1),d[n](t,i)[agoin].replace("%s",t)}function r(e,n){return n=n?t(n):new Date,(n-t(e))/1e3}function i(t){for(var e=1,n=0,r=Math.abs(t);t>=l[n]&&n<p;n++)t/=l[n],e*=l[n];return r%=e,r=r?e-r:e,Math.ceil(r)}function o(t){return t.getAttribute?t.getAttribute(h):t.attr?t.attr(h):void 0}function a(t,e){function a(o,c,f,s){var d=r(c,t);o.innerHTML=n(d,f,e),u["k"+s]=setTimeout(function(){a(o,c,f,s)},1e3*i(d))}var u={};return e||(e="en"),this.format=function(i,o){return n(r(i,t),o,e)},this.render=function(t,e){void 0===t.length&&(t=[t]);for(var n=0;n<t.length;n++)a(t[n],o(t[n]),e,++c)},this.cancel=function(){for(var t in u)clearTimeout(u[t]);u={}},this.setLocale=function(t){e=t},this}function u(t,e){return new a(t,e)}var c=0,f="second_minute_hour_day_week_month_year".split("_"),s="秒_分钟_小时_天_周_月_年".split("_"),d={en:function(t,e){if(0===e)return["just now","right now"];var n=f[parseInt(e/2)];return t>1&&(n+="s"),[t+" "+n+" ago","in "+t+" "+n]},zh_CN:function(t,e){if(0===e)return["刚刚","片刻后"];var n=s[parseInt(e/2)];return[t+n+"前",t+n+"后"]}},l=[60,60,24,7,365/7/12,12],p=6,h="datetime";return u.register=function(t,e){d[t]=e},u});

6
src/www/js/vendor/vue.min.js vendored Normal file

File diff suppressed because one or more lines are too long

11
src/www/manifest.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "WireGuard",
"display": "standalone",
"background_color": "#fff",
"icons": [
{
"src": "img/favicon.png",
"type": "image/png"
}
]
}