first commit

This commit is contained in:
yako 2024-09-20 10:55:18 +02:00
commit 364dfbbe07
85 changed files with 3776 additions and 0 deletions

12
.env Normal file
View File

@ -0,0 +1,12 @@
SERVER=0.0.0.0
PORT=8080
GIN_MODE=release
WG_CONF_DIR=./wireguard
WG_INTERFACE_NAME=wg0.conf
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=account@gmail.com
SMTP_PASSWORD="*************"
SMTP_FROM="Wg Gen Web <account@gmail.com>"

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
ARG COMMIT="N/A"
FROM golang:alpine AS build-back
WORKDIR /app
ARG COMMIT
COPY . .
RUN go build -ldflags="-X 'gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util.Version=${COMMIT}'" -o wg-gen-web-linux
FROM node:10-alpine AS build-front
WORKDIR /app
COPY ui/package*.json ./
RUN npm install
COPY ui/ ./
RUN npm run build
FROM alpine
WORKDIR /app
COPY --from=build-back /app/wg-gen-web-linux .
COPY --from=build-front /app/dist ./ui/dist
COPY .env .
RUN chmod +x ./wg-gen-web-linux
RUN apk add --no-cache ca-certificates
EXPOSE 8080
CMD ["/app/wg-gen-web-linux"]

13
LICENSE-WTFPL Normal file
View File

@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2013 Stephen Mathieson <me@stephenmathieson.com>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

241
LISEZMOI.md Normal file
View File

@ -0,0 +1,241 @@
# Wg Gen Web
<h1><img src="./web-ui.png" ></h1>
Générateur de configuration simple basé sur le Web pour [WireGuard](https://wireguard.com).
## Why another one ?
Toutes les implémentations de WireGuard UI essaient de gérer le service en appliquant des configurations et en créant des règles de réseau.
Cette implémentation ne fait que générer de la configuration et c'est à vous de créer des règles réseau et d'appliquer la configuration à WireGuard.
Par exemple en surveillant le répertoire généré avec [inotifywait](https://github.com/inotify-tools/inotify-tools/wiki) ou avec systemd.path.
Le but est d'exécuter Wg-Gen-Web dans un espace et WireGuard sur le système hôte.
### Caractéristiques
* Libre-service et basé sur le web
* QR-Code pour une configuration pratique du client mobile
* Support optionnel multi-utilisateurs derrière un proxy d'authentification
* Prise en charge de l'authentification simple
* Zéro dépendance externe - juste un binaire unique utilisant le module noyau wireguard
* Déploiement de binaires et de conteneurs
Vous devez avoir WireGuard installé sur la machine qui exécute `wg-ui`.
## Wg ui
### Installation de Go (Debian)
Installer la dernière version de Go à partir de (<https://golang.org/dl/>)
```
cd ~
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
echo "export PATH=$PATH:/usr/local/go/bin" >> ~/.bashrc
source ~/.bashrc
```
Installer la version LTS de nodejs pour le frontend
```
# debian
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo bash -
sudo apt update
sudo apt install nodejs
```
### Cloner wg-webui-fr
```
git clone https://gitea.rnmkcy.eu/yann/wg-webui-fr
```
### Construire wg-webui-fr
Exécuter les commandes suivantes
```
cd ~/wg-webui-fr/
go mod tidy
go build -o wg-ui main.go
cd ui
export NODE_OPTIONS=--openssl-legacy-provider
export VUE_APP_API_BASE_URL=http://localhost:8080/api/v1.0
npm install
npm run build
sudo mkdir -p /opt/appwg/ui
sudo cp ../wg-ui /opt/appwg
sudo cp -r dist /opt/appwg/ui/
```
### Environnement
Créer un fichier environnement `/opt/appwg/.env`
```
SERVER=127.0.0.1
PORT=8090
GIN_MODE=debug
WG_CONF_DIR=/opt/appwg/wireguard
WG_INTERFACE_NAME=wg0.conf
SMTP_HOST="mx.exemple.com"
SMTP_PORT=587
SMTP_USERNAME="Utilisateur@exemple.com"
SMTP_PASSWORD="Mot de passe"
SMTP_FROM="Utilisateur@exemple.com"
```
ATTENTION!!! il faut au minimun un fichier `wg0.conf` dans `/opt/appwg/wireguard`
Avec une installation de l'application wireguard, `WG_CONF_DIR=/etc/wireguard`
### Utilisation systemd
Créer le service `/etc/systemd/system/wgweb.service`
```
[Unit]
Description=Wireguard web
After=network.target
[Service]
Type=simple
Restart=on-failure
RestartSec=10
WorkingDirectory=/opt/appwg
ExecStart=/opt/appwg/wg-ui
[Install]
WantedBy=multi-user.target
```
Recharger
```
sudo systemctl daemon-reload
```
### Activer et lancer le service
```
sudo systemctl enable wgweb.service --now
```
Wireguard ui est accessible sur localhost:8090
Pour un accès via ssh
```
ssh -L 9500:localhost:8090 utilisateur@adresse_serveur
```
### Accès navigateur
Ensuite ouvrir un navigateur localement sur localhost:9500
![](web-ui-a.png)
Activer le service
sudo systemctl enable wgweb.service
### Mise è jour wg-webui-fr
Exécuter les commandes suivantes
```bash
echo "Arrêt wgweb.service"
sudo systemctl stop wgweb.service
echo "Sauvegarde"
sudo cp /opt/appwg/.env _.env
echo "Supprimer appwg"
sudo rm -r /opt/appwg
echo "Construire wg-ui main.go"
cd $HOME/wg-webui-fr/
go mod tidy
go build -o wg-ui main.go
cd ui
export NODE_OPTIONS=--openssl-legacy-provider
export VUE_APP_API_BASE_URL=http://localhost:8080/api/v1.0
npm install
npm run build
sudo mkdir -p /opt/appwg/ui
sudo cp ../wg-ui /opt/appwg
sudo cp -r dist /opt/appwg/ui/
echo "Restaurer environnement"
sudo cp $HOME/_.env /opt/appwg/.env
echo "Démarrer le service"
sudo systemctl start wgweb.service
echo "FIN reconstruction"
```
Wireguard ui est accessible sur localhost:8090
### Appliquer automatiquement les changements à WireGuard
#### Utilisation de systemd (DEFAUT)
Utilisation de `systemd.path` pour surveiller les changements de répertoire voir [systemd doc](https://www.freedesktop.org/software/systemd/man/systemd.path.html)
```
# /etc/systemd/system/wg-gen-web.path
[Unit]
Description=Watch /etc/wireguard for changes
[Path]
PathModified=/etc/wireguard
[Install]
WantedBy=multi-user.target
```
Ce `.path` activera le fichier unit avec le même nom
```
# /etc/systemd/system/wg-gen-web.service
[Unit]
Description=Restart WireGuard
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl restart wg-quick@wg0.service
[Install]
WantedBy=multi-user.target
```
Ce qui va redémarrer le service WireGuard
Activer et lancer
```
sudo systemctl enable wg-gen-web.path --now
```
### Utiliser inotifywait
Pour tout autre système d'initialisation, créez un daemon exécutant ce script
```
#!/bin/sh
while inotifywait -e modify -e create /etc/wireguard; do
wg-quick down wg0
wg-quick up wg0
done
```
### Comment utiliser avec une configuration WireGuard existante
Après le premier lancement, Wg Gen Web créera le fichier `server.json` dans le répertoire défini le paramètre `WG_INTERFACE_NAME` du fichier environnement.
Modifier le fichier existant **server.json** pour être identique au paramétrage de wireguard **wg0.conf**

245
api/api.go Normal file
View File

@ -0,0 +1,245 @@
package api
import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/core"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/template"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"net/http"
)
// ApplyRoutes applies router to gin Router
func ApplyRoutes(r *gin.Engine) {
client := r.Group("/api/v1.0/client")
{
client.POST("", createClient)
client.GET("/:id", readClient)
client.PATCH("/:id", updateClient)
client.DELETE("/:id", deleteClient)
client.GET("", readClients)
client.GET("/:id/config", configClient)
client.GET("/:id/email", emailClient)
}
server := r.Group("/api/v1.0/server")
{
server.GET("", readServer)
server.PATCH("", updateServer)
server.GET("/config", configServer)
server.GET("/version", version)
}
}
func createClient(c *gin.Context) {
var data model.Client
if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}
client, err := core.CreateClient(&data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to create client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, client)
}
func readClient(c *gin.Context) {
id := c.Param("id")
client, err := core.ReadClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, client)
}
func updateClient(c *gin.Context) {
var data model.Client
id := c.Param("id")
if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}
client, err := core.UpdateClient(id, &data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to update client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, client)
}
func deleteClient(c *gin.Context) {
id := c.Param("id")
err := core.DeleteClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to remove client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{})
}
func readClients(c *gin.Context) {
clients, err := core.ReadClients()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to list clients")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, clients)
}
func configClient(c *gin.Context) {
configData, err := core.ReadClientConfig(c.Param("id"))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client config")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
formatQr := c.DefaultQuery("qrcode", "false")
if formatQr == "false" {
// return config as txt file
c.Header("Content-Disposition", "attachment; filename=wg0.conf")
c.Data(http.StatusOK, "application/config", configData)
return
}
// return config as png qrcode
png, err := qrcode.Encode(string(configData), qrcode.Medium, 250)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to create qrcode")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.Data(http.StatusOK, "image/png", png)
return
}
func emailClient(c *gin.Context) {
id := c.Param("id")
err := core.EmailClient(id)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to send email to client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{})
}
func readServer(c *gin.Context) {
client, err := core.ReadServer()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, client)
}
func updateServer(c *gin.Context) {
var data model.Server
if err := c.ShouldBindJSON(&data); err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to bind")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}
client, err := core.UpdateServer(&data)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to update client")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, client)
}
func configServer(c *gin.Context) {
clients, err := core.ReadClients()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read clients")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}
server, err := core.ReadServer()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read server")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}
configData, err := template.DumpServerWg(clients, server)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to dump wg config")
c.AbortWithStatus(http.StatusUnprocessableEntity)
return
}
// return config as txt file
c.Header("Content-Disposition", "attachment; filename=wg0.conf")
c.Data(http.StatusOK, "application/config", configData)
}
func version(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": util.Version,
})
}

13
appwg/.env Executable file
View File

@ -0,0 +1,13 @@
SERVER=127.0.0.1
PORT=8100
GIN_MODE=release
WG_CONF_DIR=/etc/wireguard
WG_INTERFACE_NAME=wg0.conf
SMTP_HOST=xoyaz.xyz
SMTP_PORT=587
SMTP_USERNAME="ian@xoyaz.xyz"
SMTP_PASSWORD="HaveuseOrmeauxEncageSaisine"
SMTP_FROM="ian@xoyaz.xyz"

1
appwg/ui/dist/css/Clients.56a11097.css vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
appwg/ui/dist/favicon.png vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
appwg/ui/dist/img/logo.8f75d612.png vendored Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

1
appwg/ui/dist/index.html vendored Executable file
View File

@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.png"><title>ui</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css"><link href="/css/Clients.56a11097.css" rel="prefetch"><link href="/css/Clients~Server.f87dfffe.css" rel="prefetch"><link href="/js/Clients.adbe44c3.js" rel="prefetch"><link href="/js/Clients~Server.12421a31.js" rel="prefetch"><link href="/js/Index.711255c3.js" rel="prefetch"><link href="/js/Server.e0cb69bc.js" rel="prefetch"><link href="/css/chunk-vendors.0ec741de.css" rel="preload" as="style"><link href="/js/app.12cb1049.js" rel="preload" as="script"><link href="/js/chunk-vendors.40c1d4b4.js" rel="preload" as="script"><link href="/css/chunk-vendors.0ec741de.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but ui doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/js/chunk-vendors.40c1d4b4.js"></script><script src="/js/app.12cb1049.js"></script></body></html>

2
appwg/ui/dist/js/Clients.1916468e.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/Clients.1916468e.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Clients.64ae6101.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/Clients.64ae6101.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Clients.adbe44c3.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Clients.c3e03423.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/Clients.c3e03423.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Clients.d1e169a5.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/Clients.d1e169a5.js.map vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Clients~Server.f3845244.js vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Index.39a233d4.js vendored Executable file
View File

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["Index"],{d504:function(e,n,t){"use strict";t.r(n);var r=function(){var e=this,n=e._self._c;return n("div")},c=[],l={created(){this.$router.replace({name:"clients"})}},s=l,u=t("2877"),a=Object(u["a"])(s,r,c,!1,null,null,null);n["default"]=a.exports}}]);
//# sourceMappingURL=Index.39a233d4.js.map

1
appwg/ui/dist/js/Index.39a233d4.js.map vendored Executable file
View File

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/views/Index.vue?e07c","webpack:///src/views/Index.vue","webpack:///./src/views/Index.vue?062b","webpack:///./src/views/Index.vue"],"names":["render","_vm","this","_c","_self","staticRenderFns","$router","replace","name","component"],"mappings":"8GAAA,IAAIA,EAAS,WAAkB,IAAIC,EAAIC,KAAKC,EAAGF,EAAIG,MAAMD,GAAG,OAAOA,EAAG,QAElEE,EAAkB,GCEL,GACb,UACEH,KAAKI,QAAQC,QAAQ,CAAEC,KAAM,cCN0J,I,YCOzLC,EAAY,eACd,EACAT,EACAK,GACA,EACA,KACA,KACA,MAIa,aAAAI,E","file":"js/Index.39a233d4.js","sourcesContent":["var render = function render(){var _vm=this,_c=_vm._self._c;return _c(\"div\")\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","<template>\n</template>\n\n<script>\n export default {\n created () {\n this.$router.replace({ name: 'clients' })\n }\n }\n</script>\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--1-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Index.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--1-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Index.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./Index.vue?vue&type=template&id=2e4a022b\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports"],"sourceRoot":""}

2
appwg/ui/dist/js/Index.711255c3.js vendored Normal file
View File

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["Index"],{d504:function(e,n,t){"use strict";t.r(n);var r=function(){var e=this,n=e._self._c;return n("div")},c=[],l={created(){this.$router.replace({name:"clients"})}},s=l,u=t("2877"),a=Object(u["a"])(s,r,c,!1,null,null,null);n["default"]=a.exports}}]);
//# sourceMappingURL=Index.711255c3.js.map

View File

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/views/Index.vue?2335","webpack:///src/views/Index.vue","webpack:///./src/views/Index.vue?062b","webpack:///./src/views/Index.vue"],"names":["render","_vm","this","_c","_self","staticRenderFns","$router","replace","name","component"],"mappings":"8GAAA,IAAIA,EAAS,WAAkB,IAAIC,EAAIC,KAAKC,EAAGF,EAAIG,MAAMD,GAAG,OAAOA,EAAG,QAElEE,EAAkB,GCEL,GACb,UACEH,KAAKI,QAAQC,QAAQ,CAAEC,KAAM,cCN0J,I,YCOzLC,EAAY,eACd,EACAT,EACAK,GACA,EACA,KACA,KACA,MAIa,aAAAI,E","file":"js/Index.711255c3.js","sourcesContent":["var render = function render(){var _vm=this,_c=_vm._self._c;return _c(\"div\")\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","<template>\n</template>\n\n<script>\n export default {\n created () {\n this.$router.replace({ name: 'clients' })\n }\n }\n</script>\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--1-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Index.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--1-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Index.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./Index.vue?vue&type=template&id=2e4a022b\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports"],"sourceRoot":""}

2
appwg/ui/dist/js/Index.72bdca15.js vendored Executable file
View File

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["Index"],{d504:function(e,n,t){"use strict";t.r(n);var r=function(){var e=this,n=e._self._c;return n("div")},c=[],l={created(){this.$router.replace({name:"clients"})}},s=l,u=t("2877"),a=Object(u["a"])(s,r,c,!1,null,null,null);n["default"]=a.exports}}]);
//# sourceMappingURL=Index.72bdca15.js.map

1
appwg/ui/dist/js/Index.72bdca15.js.map vendored Executable file
View File

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/views/Index.vue?363c","webpack:///src/views/Index.vue","webpack:///./src/views/Index.vue?062b","webpack:///./src/views/Index.vue"],"names":["render","_vm","this","_c","_self","staticRenderFns","$router","replace","name","component"],"mappings":"8GAAA,IAAIA,EAAS,WAAkB,IAAIC,EAAIC,KAAKC,EAAGF,EAAIG,MAAMD,GAAG,OAAOA,EAAG,QAElEE,EAAkB,GCEL,GACb,UACEH,KAAKI,QAAQC,QAAQ,CAAEC,KAAM,cCN0J,I,YCOzLC,EAAY,eACd,EACAT,EACAK,GACA,EACA,KACA,KACA,MAIa,aAAAI,E","file":"js/Index.72bdca15.js","sourcesContent":["var render = function render(){var _vm=this,_c=_vm._self._c;return _c(\"div\")\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","<template>\n</template>\n\n<script>\n export default {\n created () {\n this.$router.replace({ name: 'clients' })\n }\n }\n</script>\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--1-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Index.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--1-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Index.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./Index.vue?vue&type=template&id=2e4a022b\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports"],"sourceRoot":""}

2
appwg/ui/dist/js/Server.2c31711e.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/Server.2c31711e.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Server.33db6d96.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/Server.33db6d96.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/Server.e0cb69bc.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/app.12cb1049.js vendored Normal file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/app.12cb1049.js.map vendored Normal file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/app.2e28011b.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/app.2e28011b.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/app.50b90708.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/app.50b90708.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/app.a3a42c5a.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/app.a3a42c5a.js.map vendored Executable file

File diff suppressed because one or more lines are too long

2
appwg/ui/dist/js/app.d8243852.js vendored Executable file

File diff suppressed because one or more lines are too long

1
appwg/ui/dist/js/app.d8243852.js.map vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

287
appwg/ui/dist/js/chunk-vendors.b9edb8dc.js vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
appwg/wg-ui Executable file

Binary file not shown.

280
core/client.go Normal file
View File

@ -0,0 +1,280 @@
package core
import (
"errors"
uuid "github.com/satori/go.uuid"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/storage"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/template"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"gopkg.in/gomail.v2"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strconv"
"time"
)
// CreateClient client with all necessary data
func CreateClient(client *model.Client) (*model.Client, error) {
// check if client is valid
errs := client.IsValid()
if len(errs) != 0 {
for _, err := range errs {
log.WithFields(log.Fields{
"err": err,
}).Error("client validation error")
}
return nil, errors.New("failed to validate client")
}
u := uuid.NewV4()
client.Id = u.String()
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return nil, err
}
client.PrivateKey = key.String()
client.PublicKey = key.PublicKey().String()
presharedKey, err := wgtypes.GenerateKey()
if err != nil {
return nil, err
}
client.PresharedKey = presharedKey.String()
reserverIps, err := GetAllReservedIps()
if err != nil {
return nil, err
}
ips := make([]string, 0)
for _, network := range client.Address {
ip, err := util.GetAvailableIp(network, reserverIps)
if err != nil {
return nil, err
}
if util.IsIPv6(ip) {
ip = ip + "/128"
} else {
ip = ip + "/32"
}
ips = append(ips, ip)
}
client.Address = ips
client.Created = time.Now().UTC()
client.Updated = client.Created
err = storage.Serialize(client.Id, client)
if err != nil {
return nil, err
}
v, err := storage.Deserialize(client.Id)
if err != nil {
return nil, err
}
client = v.(*model.Client)
// data modified, dump new config
return client, UpdateServerConfigWg()
}
// ReadClient client by id
func ReadClient(id string) (*model.Client, error) {
v, err := storage.Deserialize(id)
if err != nil {
return nil, err
}
client := v.(*model.Client)
return client, nil
}
// UpdateClient preserve keys
func UpdateClient(Id string, client *model.Client) (*model.Client, error) {
v, err := storage.Deserialize(Id)
if err != nil {
return nil, err
}
current := v.(*model.Client)
if current.Id != client.Id {
return nil, errors.New("records Id mismatch")
}
// check if client is valid
errs := client.IsValid()
if len(errs) != 0 {
for _, err := range errs {
log.WithFields(log.Fields{
"err": err,
}).Error("client validation error")
}
return nil, errors.New("failed to validate client")
}
// keep keys
client.PrivateKey = current.PrivateKey
client.PublicKey = current.PublicKey
client.Updated = time.Now().UTC()
err = storage.Serialize(client.Id, client)
if err != nil {
return nil, err
}
v, err = storage.Deserialize(Id)
if err != nil {
return nil, err
}
client = v.(*model.Client)
// data modified, dump new config
return client, UpdateServerConfigWg()
}
// DeleteClient from disk
func DeleteClient(id string) error {
path := filepath.Join(os.Getenv("WG_CONF_DIR"), id)
err := os.Remove(path)
if err != nil {
return err
}
// data modified, dump new config
return UpdateServerConfigWg()
}
// ReadClients all clients
func ReadClients() ([]*model.Client, error) {
clients := make([]*model.Client, 0)
files, err := ioutil.ReadDir(filepath.Join(os.Getenv("WG_CONF_DIR")))
if err != nil {
return nil, err
}
for _, f := range files {
// clients file name is an uuid
_, err := uuid.FromString(f.Name())
if err == nil {
c, err := storage.Deserialize(f.Name())
if err != nil {
log.WithFields(log.Fields{
"err": err,
"path": f.Name(),
}).Error("failed to deserialize client")
} else {
clients = append(clients, c.(*model.Client))
}
}
}
sort.Slice(clients, func(i, j int) bool {
return clients[i].Created.After(clients[j].Created)
})
return clients, nil
}
// ReadClientConfig in wg format
func ReadClientConfig(id string) ([]byte, error) {
client, err := ReadClient(id)
if err != nil {
return nil, err
}
server, err := ReadServer()
if err != nil {
return nil, err
}
configDataWg, err := template.DumpClientWg(client, server)
if err != nil {
return nil, err
}
return configDataWg, nil
}
// SendEmail to client
func EmailClient(id string) error {
client, err := ReadClient(id)
if err != nil {
return err
}
configData, err := ReadClientConfig(id)
if err != nil {
return err
}
// conf as .conf file
tmpfileCfg, err := ioutil.TempFile("", "wireguard-vpn-*.conf")
if err != nil {
return err
}
if _, err := tmpfileCfg.Write(configData); err != nil {
return err
}
if err := tmpfileCfg.Close(); err != nil {
return err
}
defer os.Remove(tmpfileCfg.Name()) // clean up
// conf as png image
png, err := qrcode.Encode(string(configData), qrcode.Medium, 280)
if err != nil {
return err
}
tmpfilePng, err := ioutil.TempFile("", "qrcode-*.png")
if err != nil {
return err
}
if _, err := tmpfilePng.Write(png); err != nil {
return err
}
if err := tmpfilePng.Close(); err != nil {
return err
}
defer os.Remove(tmpfilePng.Name()) // clean up
// get email body
emailBody, err := template.DumpEmail(client, filepath.Base(tmpfilePng.Name()))
if err != nil {
return err
}
// port to int
port, err := strconv.Atoi(os.Getenv("SMTP_PORT"))
if err != nil {
return err
}
d := gomail.NewDialer(os.Getenv("SMTP_HOST"), port, os.Getenv("SMTP_USERNAME"), os.Getenv("SMTP_PASSWORD"))
s, err := d.Dial()
if err != nil {
return err
}
m := gomail.NewMessage()
m.SetHeader("From", os.Getenv("SMTP_FROM"))
m.SetAddressHeader("To", client.Email, client.Name)
m.SetHeader("Subject", "WireGuard VPN Configuration")
m.SetBody("text/html", string(emailBody))
m.Attach(tmpfileCfg.Name())
m.Embed(tmpfilePng.Name())
err = gomail.Send(s, m)
if err != nil {
return err
}
return nil
}

309
core/migrate.go Normal file
View File

@ -0,0 +1,309 @@
package core
import (
"encoding/json"
uuid "github.com/satori/go.uuid"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/storage"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"io/ioutil"
"os"
"path/filepath"
"strings"
"time"
)
// Migrate all changes, current struct fields change
func MigrateInitialStructChange() error {
clients, err := readClients()
if err != nil {
return err
}
s, err := deserialize("server.json")
if err != nil {
return err
}
for _, client := range clients {
switch v := client["allowedIPs"].(type) {
case []interface{}:
log.Infof("client %s has been already migrated", client["id"])
continue
default:
log.Infof("unexpected type %T, mus be migrated", v)
}
c := &model.Client{}
c.Id = client["id"].(string)
c.Name = client["name"].(string)
c.Email = client["email"].(string)
c.Enable = client["enable"].(bool)
c.AllowedIPs = make([]string, 0)
for _, address := range strings.Split(client["allowedIPs"].(string), ",") {
if util.IsValidCidr(strings.TrimSpace(address)) {
c.AllowedIPs = append(c.AllowedIPs, strings.TrimSpace(address))
}
}
c.Address = make([]string, 0)
for _, address := range strings.Split(client["address"].(string), ",") {
if util.IsValidCidr(strings.TrimSpace(address)) {
c.Address = append(c.Address, strings.TrimSpace(address))
}
}
c.PrivateKey = client["privateKey"].(string)
c.PublicKey = client["publicKey"].(string)
created, err := time.Parse(time.RFC3339, client["created"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
continue
}
c.Created = created
updated, err := time.Parse(time.RFC3339, client["updated"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
continue
}
c.Updated = updated
err = storage.Serialize(c.Id, c)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to Serialize client")
}
}
switch v := s["address"].(type) {
case []interface{}:
log.Info("server has been already migrated")
return nil
default:
log.Infof("unexpected type %T, mus be migrated", v)
}
server := &model.Server{}
server.Address = make([]string, 0)
for _, address := range strings.Split(s["address"].(string), ",") {
if util.IsValidCidr(strings.TrimSpace(address)) {
server.Address = append(server.Address, strings.TrimSpace(address))
}
}
server.ListenPort = int(s["listenPort"].(float64))
server.PrivateKey = s["privateKey"].(string)
server.PublicKey = s["publicKey"].(string)
//server.PresharedKey = s["presharedKey"].(string)
server.Endpoint = s["endpoint"].(string)
server.PersistentKeepalive = int(s["persistentKeepalive"].(float64))
server.Dns = make([]string, 0)
for _, address := range strings.Split(s["dns"].(string), ",") {
if util.IsValidIp(strings.TrimSpace(address)) {
server.Dns = append(server.Dns, strings.TrimSpace(address))
}
}
if val, ok := s["preUp"]; ok {
server.PreUp = val.(string)
}
if val, ok := s["postUp"]; ok {
server.PostUp = val.(string)
}
if val, ok := s["preDown"]; ok {
server.PreDown = val.(string)
}
if val, ok := s["postDown"]; ok {
server.PostDown = val.(string)
}
created, err := time.Parse(time.RFC3339, s["created"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
}
server.Created = created
updated, err := time.Parse(time.RFC3339, s["updated"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
}
server.Updated = updated
err = storage.Serialize("server.json", server)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to Serialize server")
}
return nil
}
// Migrate presharedKey issue #23
func MigratePresharedKey() error {
clients, err := readClients()
if err != nil {
return err
}
s, err := deserialize("server.json")
if err != nil {
return err
}
for _, client := range clients {
if _, ok := client["presharedKey"]; ok {
log.Infof("client %s has been already migrated for preshared key", client["id"])
continue
}
c := &model.Client{}
c.Id = client["id"].(string)
c.Name = client["name"].(string)
c.Email = client["email"].(string)
c.Enable = client["enable"].(bool)
if val, ok := client["ignorePersistentKeepalive"]; ok {
c.IgnorePersistentKeepalive = val.(bool)
} else {
c.IgnorePersistentKeepalive = false
}
c.PresharedKey = s["presharedKey"].(string)
c.AllowedIPs = make([]string, 0)
for _, address := range client["allowedIPs"].([]interface{}) {
c.AllowedIPs = append(c.AllowedIPs, address.(string))
}
c.Address = make([]string, 0)
for _, address := range client["address"].([]interface{}) {
c.Address = append(c.Address, address.(string))
}
c.PrivateKey = client["privateKey"].(string)
c.PublicKey = client["publicKey"].(string)
created, err := time.Parse(time.RFC3339, client["created"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
continue
}
c.Created = created
updated, err := time.Parse(time.RFC3339, client["updated"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
continue
}
c.Updated = updated
err = storage.Serialize(c.Id, c)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to Serialize client")
}
}
if _, ok := s["presharedKey"]; ok {
server := &model.Server{}
server.Address = make([]string, 0)
server.Address = make([]string, 0)
for _, address := range s["address"].([]interface{}) {
server.Address = append(server.Address, address.(string))
}
server.ListenPort = int(s["listenPort"].(float64))
server.PrivateKey = s["privateKey"].(string)
server.PublicKey = s["publicKey"].(string)
server.Endpoint = s["endpoint"].(string)
server.PersistentKeepalive = int(s["persistentKeepalive"].(float64))
server.Dns = make([]string, 0)
for _, address := range s["dns"].([]interface{}) {
server.Dns = append(server.Dns, address.(string))
}
if val, ok := s["preUp"]; ok {
server.PreUp = val.(string)
}
if val, ok := s["postUp"]; ok {
server.PostUp = val.(string)
}
if val, ok := s["preDown"]; ok {
server.PreDown = val.(string)
}
if val, ok := s["postDown"]; ok {
server.PostDown = val.(string)
}
created, err := time.Parse(time.RFC3339, s["created"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
}
server.Created = created
updated, err := time.Parse(time.RFC3339, s["updated"].(string))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to parse time")
}
server.Updated = updated
err = storage.Serialize("server.json", server)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Errorf("failed to Serialize server")
}
}
return nil
}
func readClients() ([]map[string]interface{}, error) {
clients := make([]map[string]interface{}, 0)
files, err := ioutil.ReadDir(filepath.Join(os.Getenv("WG_CONF_DIR")))
if err != nil {
return nil, err
}
for _, f := range files {
// clients file name is an uuid
_, err := uuid.FromString(f.Name())
if err == nil {
c, err := deserialize(f.Name())
if err != nil {
log.WithFields(log.Fields{
"err": err,
"path": f.Name(),
}).Error("failed to deserialize client")
} else {
clients = append(clients, c)
}
}
}
return clients, nil
}
func deserialize(id string) (map[string]interface{}, error) {
path := filepath.Join(os.Getenv("WG_CONF_DIR"), id)
data, err := util.ReadFile(path)
if err != nil {
return nil, err
}
var d map[string]interface{}
err = json.Unmarshal(data, &d)
if err != nil {
return nil, err
}
return d, nil
}

166
core/server.go Normal file
View File

@ -0,0 +1,166 @@
package core
import (
"errors"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/storage"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/template"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"os"
"path/filepath"
"time"
)
// ReadServer object, create default one
func ReadServer() (*model.Server, error) {
if !util.FileExists(filepath.Join(os.Getenv("WG_CONF_DIR"), "server.json")) {
server := &model.Server{}
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return nil, err
}
server.PrivateKey = key.String()
server.PublicKey = key.PublicKey().String()
server.Endpoint = "wireguard.example.com:123"
server.ListenPort = 51820
server.Address = make([]string, 0)
server.Address = append(server.Address, "fd9f:6666::10:6:6:1/64")
server.Address = append(server.Address, "10.6.6.1/24")
server.Dns = make([]string, 0)
server.Dns = append(server.Dns, "fd9f::10:0:0:2")
server.Dns = append(server.Dns, "10.0.0.2")
server.PersistentKeepalive = 16
server.Mtu = 0
server.PreUp = "echo WireGuard PreUp"
server.PostUp = "echo WireGuard PostUp"
server.PreDown = "echo WireGuard PreDown"
server.PostDown = "echo WireGuard PostDown"
server.Created = time.Now().UTC()
server.Updated = server.Created
err = storage.Serialize("server.json", server)
if err != nil {
return nil, err
}
// server.json was missing, dump wg config after creation
err = UpdateServerConfigWg()
if err != nil {
return nil, err
}
}
c, err := storage.Deserialize("server.json")
if err != nil {
return nil, err
}
return c.(*model.Server), nil
}
// UpdateServer keep private values from existing one
func UpdateServer(server *model.Server) (*model.Server, error) {
current, err := storage.Deserialize("server.json")
if err != nil {
return nil, err
}
// check if server is valid
errs := server.IsValid()
if len(errs) != 0 {
for _, err := range errs {
log.WithFields(log.Fields{
"err": err,
}).Error("server validation error")
}
return nil, errors.New("failed to validate server")
}
server.PrivateKey = current.(*model.Server).PrivateKey
server.PublicKey = current.(*model.Server).PublicKey
//server.PresharedKey = current.(*model.Server).PresharedKey
server.Updated = time.Now().UTC()
err = storage.Serialize("server.json", server)
if err != nil {
return nil, err
}
v, err := storage.Deserialize("server.json")
if err != nil {
return nil, err
}
server = v.(*model.Server)
return server, UpdateServerConfigWg()
}
// UpdateServerConfigWg in wg format
func UpdateServerConfigWg() error {
clients, err := ReadClients()
if err != nil {
return err
}
server, err := ReadServer()
if err != nil {
return err
}
_, err = template.DumpServerWg(clients, server)
if err != nil {
return err
}
return nil
}
// GetAllReservedIps the list of all reserved IPs, client and server
func GetAllReservedIps() ([]string, error) {
clients, err := ReadClients()
if err != nil {
return nil, err
}
server, err := ReadServer()
if err != nil {
return nil, err
}
reserverIps := make([]string, 0)
for _, client := range clients {
for _, cidr := range client.Address {
ip, err := util.GetIpFromCidr(cidr)
if err != nil {
log.WithFields(log.Fields{
"err": err,
"cidr": cidr,
}).Error("failed to ip from cidr")
} else {
reserverIps = append(reserverIps, ip)
}
}
}
for _, cidr := range server.Address {
ip, err := util.GetIpFromCidr(cidr)
if err != nil {
log.WithFields(log.Fields{
"err": err,
"cidr": err,
}).Error("failed to ip from cidr")
} else {
reserverIps = append(reserverIps, ip)
}
}
return reserverIps, nil
}

17
go.mod Normal file
View File

@ -0,0 +1,17 @@
module gitlab.127-0-0-1.fr/vx3r/wg-gen-web
go 1.14
require (
github.com/danielkov/gin-helmet v0.0.0-20171108135313-1387e224435e
github.com/gin-contrib/cors v1.3.1
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
github.com/gin-gonic/gin v1.6.2
github.com/joho/godotenv v1.3.0
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.5.0
github.com/skip2/go-qrcode v0.0.0-20191027152451-9434209cb086
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20200324154536-ceff61240acf
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)

118
main.go Normal file
View File

@ -0,0 +1,118 @@
package main
import (
"fmt"
"github.com/danielkov/gin-helmet"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/core"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"os"
"path/filepath"
)
func init() {
log.SetFormatter(&log.TextFormatter{})
log.SetOutput(os.Stderr)
log.SetLevel(log.DebugLevel)
}
func main() {
log.Infof("Starting Wg Gen Web version: %s", util.Version)
// load .env environment variables
err := godotenv.Load()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Fatal("failed to load .env file")
}
// check directories or create it
if !util.DirectoryExists(filepath.Join(os.Getenv("WG_CONF_DIR"))) {
err = os.Mkdir(filepath.Join(os.Getenv("WG_CONF_DIR")), 0755)
if err != nil {
log.WithFields(log.Fields{
"err": err,
"dir": filepath.Join(os.Getenv("WG_CONF_DIR")),
}).Fatal("failed to create directory")
}
}
// check if server.json exists otherwise create it with default values
if !util.FileExists(filepath.Join(os.Getenv("WG_CONF_DIR"), "server.json")) {
_, err = core.ReadServer()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Fatal("server.json doesnt not exists and can not read it")
}
}
if os.Getenv("GIN_MODE") == "debug" {
// set gin release debug
gin.SetMode(gin.DebugMode)
} else {
// set gin release mode
gin.SetMode(gin.ReleaseMode)
// disable console color
gin.DisableConsoleColor()
// log level info
log.SetLevel(log.InfoLevel)
}
// migrate
err = core.MigrateInitialStructChange()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Fatal("failed to migrate initial struct changes")
}
err = core.MigratePresharedKey()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Fatal("failed to migrate preshared key struct changes")
}
// dump wg config file
err = core.UpdateServerConfigWg()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Fatal("failed to dump wg config file")
}
// creates a gin router with default middleware: logger and recovery (crash-free) middleware
app := gin.Default()
// cors middleware
config := cors.DefaultConfig()
config.AllowAllOrigins = true
app.Use(cors.New(config))
// protection middleware
app.Use(helmet.Default())
// no route redirect to frontend app
app.NoRoute(func(c *gin.Context) {
c.Redirect(301, "/index.html")
})
// serve static files
app.Use(static.Serve("/", static.LocalFile("./ui/dist", false)))
// apply api router
api.ApplyRoutes(app)
err = app.Run(fmt.Sprintf("%s:%s", os.Getenv("SERVER"), os.Getenv("PORT")))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Fatal("failed to start server")
}
}

64
model/client.go Normal file
View File

@ -0,0 +1,64 @@
package model
import (
"fmt"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"time"
)
// Client structure
type Client struct {
Id string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Enable bool `json:"enable"`
IgnorePersistentKeepalive bool `json:"ignorePersistentKeepalive"`
PresharedKey string `json:"presharedKey"`
AllowedIPs []string `json:"allowedIPs"`
Address []string `json:"address"`
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
func (a Client) IsValid() []error {
errs := make([]error, 0)
// check if the name empty
if a.Name == "" {
errs = append(errs, fmt.Errorf("name est requis"))
}
// check the name field is between 3 to 40 chars
if len(a.Name) < 2 || len(a.Name) > 40 {
errs = append(errs, fmt.Errorf("name field must be between 2-40 chars"))
}
// email is not required, but if provided must match regex
if a.Email != "" {
if !util.RegexpEmail.MatchString(a.Email) {
errs = append(errs, fmt.Errorf("email %s is invalid", a.Email))
}
}
// check if the allowedIPs empty
if len(a.AllowedIPs) == 0 {
errs = append(errs, fmt.Errorf("allowedIPs field est requis"))
}
// check if the allowedIPs are valid
for _, allowedIP := range a.AllowedIPs {
if !util.IsValidCidr(allowedIP) {
errs = append(errs, fmt.Errorf("allowedIP %s is invalid", allowedIP))
}
}
// check if the address empty
if len(a.Address) == 0 {
errs = append(errs, fmt.Errorf("address field est requis"))
}
// check if the address are valid
for _, address := range a.Address {
if !util.IsValidCidr(address) {
errs = append(errs, fmt.Errorf("address %s is invalid", address))
}
}
return errs
}

64
model/server.go Normal file
View File

@ -0,0 +1,64 @@
package model
import (
"fmt"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"time"
)
// Server structure
type Server struct {
Address []string `json:"address"`
ListenPort int `json:"listenPort"`
Mtu int `json:"mtu"`
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
Endpoint string `json:"endpoint"`
PersistentKeepalive int `json:"persistentKeepalive"`
Dns []string `json:"dns"`
PreUp string `json:"preUp"`
PostUp string `json:"postUp"`
PreDown string `json:"preDown"`
PostDown string `json:"postDown"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
func (a Server) IsValid() []error {
errs := make([]error, 0)
// check if the address empty
if len(a.Address) == 0 {
errs = append(errs, fmt.Errorf("address est requis"))
}
// check if the address are valid
for _, address := range a.Address {
if !util.IsValidCidr(address) {
errs = append(errs, fmt.Errorf("address %s is invalid", address))
}
}
// check if the listenPort is valid
if a.ListenPort < 0 || a.ListenPort > 65535 {
errs = append(errs, fmt.Errorf("listenPort %s is invalid", a.ListenPort))
}
// check if the endpoint empty
if a.Endpoint == "" {
errs = append(errs, fmt.Errorf("endpoint est requis"))
}
// check if the persistentKeepalive is valid
if a.PersistentKeepalive < 0 {
errs = append(errs, fmt.Errorf("persistentKeepalive %d is invalid", a.PersistentKeepalive))
}
// check if the mtu is valid
if a.Mtu < 0 {
errs = append(errs, fmt.Errorf("MTU %d is invalid", a.PersistentKeepalive))
}
// check if the address are valid
for _, dns := range a.Dns {
if !util.IsValidIp(dns) {
errs = append(errs, fmt.Errorf("dns %s is invalid", dns))
}
}
return errs
}

47
storage/file.go Normal file
View File

@ -0,0 +1,47 @@
package storage
import (
"encoding/json"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"os"
"path/filepath"
)
// Serialize write interface to disk
func Serialize(id string, c interface{}) error {
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return util.WriteFile(filepath.Join(os.Getenv("WG_CONF_DIR"), id), b)
}
// Deserialize read interface from disk
func Deserialize(id string) (interface{}, error) {
path := filepath.Join(os.Getenv("WG_CONF_DIR"), id)
data, err := util.ReadFile(path)
if err != nil {
return nil, err
}
if id == "server.json" {
var s *model.Server
err = json.Unmarshal(data, &s)
if err != nil {
return nil, err
}
return s, nil
}
// if not the server, must be client
var c *model.Client
err = json.Unmarshal(data, &c)
if err != nil {
return nil, err
}
return c, nil
}

311
template/template.go Normal file
View File

@ -0,0 +1,311 @@
package template
import (
"bytes"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/util"
"os"
"path/filepath"
"strings"
"text/template"
)
var (
emailTpl = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="format-detection" content="date=no" />
<meta name="format-detection" content="address=no" />
<meta name="format-detection" content="telephone=no" />
<meta name="x-apple-disable-message-reformatting" />
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,700,700i" rel="stylesheet" />
<!--<![endif]-->
<title>Email Template</title>
<!--[if gte mso 9]>
<style type="text/css" media="all">
sup { font-size: 100% !important; }
</style>
<![endif]-->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style type="text/css" media="screen">
/* Linked Styles */
body { padding:0 !important; margin:0 !important; display:block !important; min-width:100% !important; width:100% !important; background:#001736; -webkit-text-size-adjust:none }
a { color:#66c7ff; text-decoration:none }
p { padding:0 !important; margin:0 !important }
img { -ms-interpolation-mode: bicubic; /* Allow smoother rendering of resized image in Internet Explorer */ }
.mcnPreviewText { display: none !important; }
/* Mobile styles */
@media only screen and (max-device-width: 480px), only screen and (max-width: 480px) {
.mobile-shell { width: 100% !important; min-width: 100% !important; }
.bg { background-size: 100% auto !important; -webkit-background-size: 100% auto !important; }
.text-header,
.m-center { text-align: center !important; }
.center { margin: 0 auto !important; }
.container { padding: 20px 10px !important }
.td { width: 100% !important; min-width: 100% !important; }
.m-br-15 { height: 15px !important; }
.p30-15 { padding: 30px 15px !important; }
.m-td,
.m-hide { display: none !important; width: 0 !important; height: 0 !important; font-size: 0 !important; line-height: 0 !important; min-height: 0 !important; }
.m-block { display: block !important; }
.fluid-img img { width: 100% !important; max-width: 100% !important; height: auto !important; }
.column,
.column-top,
.column-empty,
.column-empty2,
.column-dir-top { float: left !important; width: 100% !important; display: block !important; }
.column-empty { padding-bottom: 10px !important; }
.column-empty2 { padding-bottom: 30px !important; }
.content-spacing { width: 15px !important; }
}
</style>
</head>
<body class="body" style="padding:0 !important; margin:0 !important; display:block !important; min-width:100% !important; width:100% !important; background:#001736; -webkit-text-size-adjust:none;">
<table width="100%" border="0" cellspacing="0" cellpadding="0" bgcolor="#001736">
<tr>
<td align="center" valign="top">
<table width="650" border="0" cellspacing="0" cellpadding="0" class="mobile-shell">
<tr>
<td class="td container" style="width:650px; min-width:650px; font-size:0pt; line-height:0pt; margin:0; font-weight:normal; padding:55px 0px;">
<!-- Article / Image On The Left - Copy On The Right -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="padding-bottom: 10px;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="tbrr p30-15" style="padding: 60px 30px; border-radius:26px 26px 0px 0px;" bgcolor="#12325c">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<th class="column-top" width="280" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="fluid-img" style="font-size:0pt; line-height:0pt; text-align:left;"><img src="cid:{{.QrcodePngName}}" width="280" height="210" border="0" alt="" /></td>
</tr>
</table>
</th>
<th class="column-empty2" width="30" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;"></th>
<th class="column-top" width="280" style="font-size:0pt; line-height:0pt; padding:0; margin:0; font-weight:normal; vertical-align:top;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="h4 pb20" style="color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:20px; line-height:28px; text-align:left; padding-bottom:20px;">Hello</td>
</tr>
<tr>
<td class="text pb20" style="color:#ffffff; font-family:Arial,sans-serif; font-size:14px; line-height:26px; text-align:left; padding-bottom:20px;">Vous avez probablement demandé une configuration VPN. Voici la configuration <strong>{{.Client.Name}}</strong> créée le <strong>{{.Client.Created.Format "02/01/2006 15:4"}}</strong>. Scannez le Qrcode ou ouvrez le fichier de configuration joint dans le client VPN.</td>
</tr>
</table>
</th>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- END Article / Image On The Left - Copy On The Right -->
<!-- Two Columns / Articles -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="padding-bottom: 10px;">
<table width="100%" border="0" cellspacing="0" cellpadding="0" bgcolor="#0e264b">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="p30-15" style="padding: 50px 30px;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="h3 pb20" style="color:#ffffff; font-family:'Muli', Arial,sans-serif; font-size:25px; line-height:32px; text-align:left; padding-bottom:20px;">A propos de WireGuard</td>
</tr>
<tr>
<td class="text pb20" style="color:#ffffff; font-family:Arial,sans-serif; font-size:14px; line-height:26px; text-align:left; padding-bottom:20px;">WireGuard est un VPN extrêmement simple, rapide et moderne qui utilise un chiffrement de pointe. Il vise à être plus rapide, plus simple, plus léger et plus utile tout en évitant le casse-tête. Il est beaucoup plus performant comparé à OpenVPN.</td>
</tr>
<!-- Button -->
<tr>
<td align="left">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="blue-button text-button" style="background:#66c7ff; color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:14px; line-height:18px; padding:12px 30px; text-align:center; border-radius:0px 22px 22px 22px; font-weight:bold;"><a href="https://www.wireguard.com/install" target="_blank" class="link-white" style="color:#ffffff; text-decoration:none;"><span class="link-white" style="color:#ffffff; text-decoration:none;">Télécharger WireGuard VPN Client</span></a></td>
</tr>
</table>
</td>
</tr>
<!-- END Button -->
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- END Two Columns / Articles -->
<!-- Footer -->
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="p30-15 bbrr" style="padding: 50px 30px; border-radius:0px 0px 26px 26px;" bgcolor="#0e264b">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="text-footer1 pb10" style="color:#c1cddc; font-family:'Muli', Arial,sans-serif; font-size:16px; line-height:20px; text-align:center; padding-bottom:10px;">wg-webui-fr - Générateur de configuration simple basé sur le Web pour WireGuard</td>
</tr>
<tr>
<td class="text-footer2" style="color:#8297b3; font-family:'Muli', Arial,sans-serif; font-size:12px; line-height:26px; text-align:center;"><a href="https://gitea.xoyize.xyz/yako/wg-webui-fr" target="_blank" class="link" style="color:#66c7ff; text-decoration:none;"><span class="link" style="color:#66c7ff; text-decoration:none;">Informations sur Gitea</span></a></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- END Footer -->
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`
clientTpl = `[Interface]
Address = {{ StringsJoin .Client.Address ", " }}
PrivateKey = {{ .Client.PrivateKey }}
{{ if ne (len .Server.Dns) 0 -}}
DNS = {{ StringsJoin .Server.Dns ", " }}
{{- end }}
{{ if ne .Server.Mtu 0 -}}
MTU = {{.Server.Mtu}}
{{- end}}
[Peer]
PublicKey = {{ .Server.PublicKey }}
PresharedKey = {{ .Client.PresharedKey }}
AllowedIPs = {{ StringsJoin .Client.AllowedIPs ", " }}
Endpoint = {{ .Server.Endpoint }}
{{ if and (ne .Server.PersistentKeepalive 0) (not .Client.IgnorePersistentKeepalive) -}}
PersistentKeepalive = {{.Server.PersistentKeepalive}}
{{- end}}
`
wgTpl = `# Updated: {{ .Server.Updated }} / Created: {{ .Server.Created }}
[Interface]
{{- range .Server.Address }}
Address = {{ . }}
{{- end }}
ListenPort = {{ .Server.ListenPort }}
PrivateKey = {{ .Server.PrivateKey }}
{{ if ne .Server.Mtu 0 -}}
MTU = {{.Server.Mtu}}
{{- end}}
PreUp = {{ .Server.PreUp }}
PostUp = {{ .Server.PostUp }}
PreDown = {{ .Server.PreDown }}
PostDown = {{ .Server.PostDown }}
{{- range .Clients }}
{{ if .Enable -}}
# {{.Name}} / {{.Email}} / Updated: {{.Updated}} / Created: {{.Created}}
[Peer]
PublicKey = {{ .PublicKey }}
PresharedKey = {{ .PresharedKey }}
AllowedIPs = {{ StringsJoin .Address ", " }}
{{- end }}
{{ end }}`
)
// DumpClientWg dump client wg config with go template
func DumpClientWg(client *model.Client, server *model.Server) ([]byte, error) {
t, err := template.New("client").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(clientTpl)
if err != nil {
return nil, err
}
return dump(t, struct {
Client *model.Client
Server *model.Server
}{
Client: client,
Server: server,
})
}
// DumpServerWg dump server wg config with go template, write it to file and return bytes
func DumpServerWg(clients []*model.Client, server *model.Server) ([]byte, error) {
t, err := template.New("server").Funcs(template.FuncMap{"StringsJoin": strings.Join}).Parse(wgTpl)
if err != nil {
return nil, err
}
configDataWg, err := dump(t, struct {
Clients []*model.Client
Server *model.Server
}{
Clients: clients,
Server: server,
})
if err != nil {
return nil, err
}
err = util.WriteFile(filepath.Join(os.Getenv("WG_CONF_DIR"), os.Getenv("WG_INTERFACE_NAME")), configDataWg)
if err != nil {
return nil, err
}
return configDataWg, nil
}
// DumpEmail dump server wg config with go template
func DumpEmail(client *model.Client, qrcodePngName string) ([]byte, error) {
t, err := template.New("email").Parse(emailTpl)
if err != nil {
return nil, err
}
return dump(t, struct {
Client *model.Client
QrcodePngName string
}{
Client: client,
QrcodePngName: qrcodePngName,
})
}
func dump(tpl *template.Template, data interface{}) ([]byte, error) {
var tplBuff bytes.Buffer
err := tpl.Execute(&tplBuff, data)
if err != nil {
return nil, err
}
return tplBuff.Bytes(), nil
}

2
ui/.browserslistrc Normal file
View File

@ -0,0 +1,2 @@
> 1%
last 2 versions

19
ui/README.md Normal file
View File

@ -0,0 +1,19 @@
# ui
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

27
ui/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^0.19.2",
"is-cidr": "^3.1.0",
"moment": "^2.24.0",
"vue": "^2.6.10",
"vue-moment": "^4.1.0",
"vue-router": "^3.1.6",
"vuetify": "^2.2.18"
},
"devDependencies": {
"@vue/cli-plugin-router": "^4.2.3",
"@vue/cli-service": "^4.2.3",
"sass": "^1.26.3",
"sass-loader": "^8.0.0",
"vue-cli-plugin-vuetify": "^2.0.5",
"vue-template-compiler": "^2.6.10",
"vuetify-loader": "^1.3.0"
}
}

BIN
ui/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

19
ui/public/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.png">
<title>ui</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
</head>
<body>
<noscript>
<strong>We're sorry but ui doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

97
ui/src/App.vue Normal file
View File

@ -0,0 +1,97 @@
<template>
<v-app id="inspire">
<v-app-bar app>
<img class="mr-3" :src="require('./assets/logo.png')" height="50" alt="Wg Gen Web"/>
<v-toolbar-title to="/">Configuration Wireguard VPN</v-toolbar-title>
<v-spacer />
<v-toolbar-items>
<v-btn to="/clients">
Clients
<v-icon right dark>mdi-account-network-outline</v-icon>
</v-btn>
<v-btn to="/server">
Serveur
<v-icon right dark>mdi-vpn</v-icon>
</v-btn>
</v-toolbar-items>
</v-app-bar>
<v-content>
<v-container>
<router-view />
</v-container>
<Notification v-bind:notification="notification"/>
</v-content>
<v-footer app>
<v-row justify="start" no-gutters>
<v-col cols="12" lg="6" md="12" sm="12">
<div :align="$vuetify.breakpoint.smAndDown ? 'center' : 'left'">
<small>{{ new Date().getFullYear() }} </small>
<small><a class="pr-1 pl-1" href="http://www.wtfpl.net/" target="_blank">Licence WTFPL</a></small>
</div>
</v-col>
</v-row>
<v-row justify="end" no-gutters>
<v-col cols="12" lg="6" md="12" sm="12">
<div :align="$vuetify.breakpoint.smAndDown ? 'center' : 'right'">
<small>Sources </small>
<a href="https://gitea.rnmkcy.eu/yann/wg-webui-fr">Gitea</a>
</div>
</v-col>
</v-row>
</v-footer>
</v-app>
</template>
<script>
import {ApiService} from "./services/ApiService";
import Notification from './components/Notification'
export default {
name: 'App',
components: {
Notification
},
data: () => ({
api: null,
version:'N/A',
notification: {
show: false,
color: '',
text: '',
},
}),
mounted() {
this.api = new ApiService();
this.getVersion()
},
created () {
this.$vuetify.theme.dark = true
},
methods: {
getVersion() {
this.api.get('/server/version').then((res) => {
this.version = res.version;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
notify(color, msg) {
this.notification.show = true;
this.notification.color = color;
this.notification.text = msg;
}
}
};
</script>

BIN
ui/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,476 @@
<template>
<v-container>
<v-row>
<v-col cols="12">
<v-card dark>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="headline">Clients</v-list-item-title>
</v-list-item-content>
<v-btn
color="success"
@click="startAddClient"
>
Ajout nouveau client
<v-icon right dark>mdi-account-multiple-plus-outline</v-icon>
</v-btn>
</v-list-item>
<v-row>
<v-col
v-for="(client, i) in clients"
:key="i"
sm12 lg6
>
<v-card
:color="client.enable ? '#1F7087' : 'warning'"
class="mx-auto"
raised
shaped
>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="headline">{{ client.name }}</v-list-item-title>
<v-list-item-subtitle>{{ client.email }}</v-list-item-subtitle>
<v-list-item-subtitle>Créé: {{ client.created | formatDate }}</v-list-item-subtitle>
<v-list-item-subtitle>Modifié: {{ client.updated | formatDate }}</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-avatar
tile
size="150"
>
<v-img :src="`${apiBaseUrl}/client/${client.id}/config?qrcode=true`"/>
</v-list-item-avatar>
</v-list-item>
<v-card-text class="text--primary">
<v-chip
v-for="(ip, i) in client.address"
:key="i"
color="indigo"
text-color="white"
>
<v-icon left>mdi-ip-network</v-icon>
{{ ip }}
</v-chip>
</v-card-text>
<v-card-actions>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
:href="`${apiBaseUrl}/client/${client.id}/config?qrcode=false`"
v-on="on"
>
<span class="d-none d-lg-flex">Télécharger</span>
<v-icon right dark>mdi-cloud-download-outline</v-icon>
</v-btn>
</template>
<span>Télécharger</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click.stop="startUpdateClient(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Editer</span>
<v-icon right dark>mdi-square-edit-outline</v-icon>
</v-btn>
</template>
<span>Editer</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click="sendEmailClient(client.id)"
v-on="on"
>
<span class="d-none d-lg-flex">Envoyer message</span>
<v-icon right dark>mdi-email-outline</v-icon>
</v-btn>
</template>
<span>Envoyer message</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn
text
@click="deleteClient(client)"
v-on="on"
>
<span class="d-none d-lg-flex">Supprimer</span>
<v-icon right dark>mdi-trash-can-outline</v-icon>
</v-btn>
</template>
<span>Supprimer</span>
</v-tooltip>
<v-spacer/>
<v-tooltip right>
<template v-slot:activator="{ on }">
<v-switch
dark
v-on="on"
color="success"
v-model="client.enable"
v-on:change="updateClient(client)"
/>
</template>
<span> {{client.enable ? 'Désactiver' : 'Activer'}} ce client</span>
</v-tooltip>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
<v-dialog
v-if="client"
v-model="dialogAddClient"
max-width="550"
>
<v-card>
<v-card-title class="headline">Ajout nouveau client</v-card-title>
<v-card-text>
<v-row>
<v-col
cols="12"
>
<v-form
ref="form"
v-model="valid"
>
<v-text-field
v-model="client.name"
label="Nom convivial"
:rules="[ v => !!v || 'Nom client est requis', ]"
required
/>
<v-text-field
v-model="client.email"
label="Client messagerie"
:rules="[ v => (/.+@.+\..+/.test(v) || v === '') || 'Adresse non valide',]"
/>
<v-select
v-model="client.address"
:items="server.address"
label="Adresse IP client choisie parmi les réseaux suivants"
:rules="[ v => !!v || 'Réseau requis', ]"
multiple
chips
persistent-hint
required
/>
<v-combobox
v-model="client.allowedIPs"
chips
hint="Saisir le CIDR IPv4 ou IPv6 et appuyez sur touche Entrée"
label="IP autorisés"
multiple
dark
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="client.allowedIPs.splice(client.allowedIPs.indexOf(item), 1)"
>
<strong>{{ item }}</strong>&nbsp;
</v-chip>
</template>
</v-combobox>
<v-switch
v-model="client.enable"
color="red"
inset
:label="client.enable ? 'Activer client après création': 'Désactiver client après création'"
/>
<v-switch
v-model="client.ignorePersistentKeepalive"
color="red"
inset
:label="'Ignorer le keepalive persistant global: ' + (client.ignorePersistentKeepalive ? 'Oui': 'NON')"
/>
</v-form>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn
:disabled="!valid"
color="success"
@click="addClient(client)"
>
Valider
<v-icon right dark>mdi-check-outline</v-icon>
</v-btn>
<v-btn
color="primary"
@click="dialogAddClient = false"
>
Abandon
<v-icon right dark>mdi-close-circle-outline</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-if="client"
v-model="dialogEditClient"
max-width="550"
>
<v-card>
<v-card-title class="headline">Edition client</v-card-title>
<v-card-text>
<v-row>
<v-col
cols="12"
>
<v-form
ref="form"
v-model="valid"
>
<v-text-field
v-model="client.name"
label="Nom convivial"
:rules="[ v => !!v || 'Nom client est requis',]"
required
/>
<v-text-field
v-model="client.email"
label="Messagerie"
:rules="[ v => (/.+@.+\..+/.test(v) || v === '') || 'Adresse non valide',]"
required
/>
<v-combobox
v-model="client.address"
chips
hint="Saisir le CIDR IPv4 ou IPv6 et appuyez sur touche Entrée"
label="Adresses"
multiple
dark
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="client.address.splice(client.address.indexOf(item), 1)"
>
<strong>{{ item }}</strong>&nbsp;
</v-chip>
</template>
</v-combobox>
<v-combobox
v-model="client.allowedIPs"
chips
hint="Saisir le CIDR IPv4 ou IPv6 et appuyez sur touche Entrée"
label="IP autorisés"
multiple
dark
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="client.allowedIPs.splice(client.allowedIPs.indexOf(item), 1)"
>
<strong>{{ item }}</strong>&nbsp;
</v-chip>
</template>
</v-combobox>
<v-switch
v-model="client.ignorePersistentKeepalive"
color="red"
inset
:label="'Ignorer le keepalive persistant global: ' + (client.ignorePersistentKeepalive ? 'Oui': 'NON')"
/>
</v-form>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn
:disabled="!valid"
color="success"
@click="updateClient(client)"
>
Valider
<v-icon right dark>mdi-check-outline</v-icon>
</v-btn>
<v-btn
color="primary"
@click="dialogEditClient = false"
>
Abandon
<v-icon right dark>mdi-close-circle-outline</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<Notification v-bind:notification="notification"/>
</v-container>
</template>
<script>
import {ApiService, API_BASE_URL} from '../services/ApiService'
import Notification from '../components/Notification'
export default {
name: 'Clients',
components: {
Notification
},
data: () => ({
api: null,
apiBaseUrl: API_BASE_URL,
clients: [],
notification: {
show: false,
color: '',
text: '',
},
dialogAddClient: false,
dialogEditClient: false,
client: null,
server: null,
valid: false,
}),
mounted () {
this.api = new ApiService();
this.getClients();
this.getServer()
},
methods: {
getClients() {
this.api.get('/client').then((res) => {
this.clients = res
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
getServer() {
this.api.get('/server').then((res) => {
this.server = res;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
startAddClient() {
this.dialogAddClient = true;
this.client = {
name: "",
email: "",
enable: true,
allowedIPs: ["0.0.0.0/0", "::/0"],
address: this.server.address,
}
},
addClient(client) {
if (client.allowedIPs.length < 1) {
this.notify('error', 'Veuillez fournir au moins une adresse CIDR valide pour les adresses IP autorisées du client.');
return;
}
for (let i = 0; i < client.allowedIPs.length; i++){
if (this.$isCidr(client.allowedIPs[i]) === 0) {
this.notify('error', 'Un CIDR invalide a été détecté, veuillez corriger avant de soumettre');
return
}
}
this.dialogAddClient = false;
this.api.post('/client', client).then((res) => {
this.notify('success', `Client ${res.name} ajouté avec succès`);
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
deleteClient(client) {
if(confirm(`Voulez-vous vraiment supprimer ${client.name} ?`)){
this.api.delete(`/client/${client.id}`).then((res) => {
this.notify('success', "Client supprimé avec succès");
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
}
},
sendEmailClient(id) {
this.api.get(`/client/${id}/email`).then((res) => {
this.notify('success', "Courriel envoyé avec succès");
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
startUpdateClient(client) {
this.client = client;
this.dialogEditClient = true;
},
updateClient(client) {
// check allowed IPs
if (client.allowedIPs.length < 1) {
this.notify('error', 'Veuillez fournir au moins une adresse CIDR valide pour les adresses IP autorisées du client');
return;
}
for (let i = 0; i < client.allowedIPs.length; i++){
if (this.$isCidr(client.allowedIPs[i]) === 0) {
this.notify('error', 'Un CIDR invalide a été détecté, veuillez corriger avant de soumettre');
return
}
}
// check address
if (client.address.length < 1) {
this.notify('error', 'Veuillez fournir au moins une adresse CIDR valide pour le client');
return;
}
for (let i = 0; i < client.address.length; i++){
if (this.$isCidr(client.address[i]) === 0) {
this.notify('error', 'Un CIDR invalide a été détecté, veuillez corriger avant de soumettre');
return
}
}
// all good, submit
this.dialogEditClient = false;
this.api.patch(`/client/${client.id}`, client).then((res) => {
this.notify('success', `Client ${res.name} mise à jour réussie`);
this.getClients()
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
notify(color, msg) {
this.notification.show = true;
this.notification.color = color;
this.notification.text = msg;
}
}
};
</script>

View File

@ -0,0 +1,25 @@
<template>
<v-snackbar
v-model="notification.show"
:right="true"
:top="true"
:color="notification.color"
>
{{ notification.text }}
<v-btn
dark
text
@click="notification.show = false"
>
Fermer
</v-btn>
</v-snackbar>
</template>
<script>
export default {
name: 'Notification',
props: ['notification'],
data: () => ({
}),
};
</script>

View File

@ -0,0 +1,235 @@
<template>
<v-container v-if="server">
<v-row>
<v-col cols="12">
<v-card dark>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="headline">Interface configuration serveur</v-list-item-title>
</v-list-item-content>
</v-list-item>
<div class="d-flex flex-no-wrap justify-space-between">
<v-col cols="12">
<v-text-field
v-model="server.publicKey"
label="Clé publique"
disabled
/>
<v-text-field
v-model="server.listenPort"
type="number"
:rules="[
v => !!v || 'Le port en écoute est requis',
]"
label="Port en écoute"
required
/>
<v-combobox
v-model="server.address"
chips
hint="Saisir le CIDR IPv4 ou IPv6 et appuyez sur touche Entrée"
label="Adresse interfaces serveur"
multiple
dark
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="server.address.splice(server.address.indexOf(item), 1)"
>
<strong>{{ item }}</strong>&nbsp;
</v-chip>
</template>
</v-combobox>
</v-col>
</div>
</v-card>
</v-col>
<v-col cols="12">
<v-card dark>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="headline">Configuration globale du client</v-list-item-title>
</v-list-item-content>
</v-list-item>
<div class="d-flex flex-no-wrap justify-space-between">
<v-col cols="12">
<v-text-field
v-model="server.endpoint"
label="Point accès public connexion client"
:rules="[
v => !!v || 'Point accès public connexion client est requis',
]"
required
/>
<v-combobox
v-model="server.dns"
chips
hint="Saisir adresse IPv4 ou IPv6 et appui sur touche Entrée"
label="Serveurs DNS pour les clients"
multiple
dark
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="server.dns.splice(server.dns.indexOf(item), 1)"
>
<strong>{{ item }}</strong>&nbsp;
</v-chip>
</template>
</v-combobox>
<v-text-field
type="number"
v-model="server.mtu"
label="Définir le MTU global"
hint="Laisser à 0 et laisser wg-quick gérer le MTU"
/>
<v-text-field
type="number"
v-model="server.persistentKeepalive"
label="Keepalive persistant"
hint="Laissez à 0 pour ne pas spécifier un keepalive persistant"
/>
</v-col>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card dark>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="headline">Interface configuration hooks</v-list-item-title>
</v-list-item-content>
</v-list-item>
<div class="d-flex flex-no-wrap justify-space-between">
<v-col cols="12">
<v-text-field
v-model="server.preUp"
label="PreUp: extraits de scripts qui seront exécutés par bash avant la mise en place de l'interface"
/>
<v-text-field
v-model="server.postUp"
label="PostUp: extraits de scripts qui seront exécutés par bash après la mise en place de l'interface"
/>
<v-text-field
v-model="server.preDown"
label="PreDown: extraits de scripts qui seront exécutés par bash avant la mise en place de l'interface"
/>
<v-text-field
v-model="server.postDown "
label="PostDown : extraits de scripts qui seront exécutés par bash après la mise en place de l'interface"
/>
</v-col>
</div>
</v-card>
</v-col>
</v-row>
<v-row>
<v-divider dark/>
<v-btn
class="ma-2"
color="success"
:href="`${apiBaseUrl}/server/config`"
>
Télécharger la configuration du serveur
<v-icon right dark>mdi-cloud-download-outline</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-btn
class="ma-2"
color="warning"
@click="updateServer"
>
Mise à jour configuration serveur
<v-icon right dark>mdi-update</v-icon>
</v-btn>
<v-divider dark/>
</v-row>
<Notification v-bind:notification="notification"/>
</v-container>
</template>
<script>
import {API_BASE_URL, ApiService} from "../services/ApiService";
import Notification from '../components/Notification'
export default {
name: 'Server',
components: {
Notification
},
data: () => ({
api: null,
server: null,
apiBaseUrl: API_BASE_URL,
notification: {
show: false,
color: '',
text: '',
},
}),
mounted () {
this.api = new ApiService();
this.getServer()
},
methods: {
getServer() {
this.api.get('/server').then((res) => {
this.server = res;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
updateServer () {
// convert int values
this.server.listenPort = parseInt(this.server.listenPort, 10);
this.server.persistentKeepalive = parseInt(this.server.persistentKeepalive, 10);
this.server.mtu = parseInt(this.server.mtu, 10);
// check server addresses
if (this.server.address.length < 1) {
this.notify('error', 'Veuillez fournir au moins une adresse CIDR valide pour le serveur');
return;
}
for (let i = 0; i < this.server.address.length; i++){
if (this.$isCidr(this.server.address[i]) === 0) {
this.notify('error', `Un CIDR invalide a été détecté, veuillez corriger ${this.server.address[i]} avant de soumettre`);
return
}
}
// check DNS correct
for (let i = 0; i < this.server.dns.length; i++){
if (this.$isCidr(this.server.dns[i] + '/32') === 0) {
this.notify('error', `IP invalide détectée, veuillez corriger ${this.server.dns[i]} avant de soumettre`);
return
}
}
this.api.patch('/server', this.server).then((res) => {
this.notify('success', "Mise à jour du serveur réussie");
this.server = res;
}).catch((e) => {
this.notify('error', e.response.status + ' ' + e.response.statusText);
});
},
notify(color, msg) {
this.notification.show = true;
this.notification.color = color;
this.notification.text = msg;
}
}
};
</script>

14
ui/src/main.js Normal file
View File

@ -0,0 +1,14 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify';
import './plugins/moment';
import './plugins/cidr'
Vue.config.productionTip = false
new Vue({
router,
vuetify,
render: function (h) { return h(App) }
}).$mount('#app')

11
ui/src/plugins/cidr.js Normal file
View File

@ -0,0 +1,11 @@
import Vue from 'vue'
const isCidr = require('is-cidr');
const plugin = {
install () {
Vue.isCidr = isCidr;
Vue.prototype.$isCidr = isCidr
}
};
Vue.use(plugin);

15
ui/src/plugins/moment.js Normal file
View File

@ -0,0 +1,15 @@
import Vue from 'vue'
import moment from 'moment';
import VueMoment from 'vue-moment'
moment.locale('en');
Vue.use(VueMoment, {
moment
});
// $moment() accessible in project
Vue.filter('formatDate', function (value) {
if (!value) return '';
return moment(String(value)).format('DD/MM/YYYY HH:mm')
});

View File

@ -0,0 +1,7 @@
import Vue from 'vue';
import Vuetify from 'vuetify/lib';
Vue.use(Vuetify);
export default new Vuetify({
});

36
ui/src/router/index.js Normal file
View File

@ -0,0 +1,36 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter);
const routes = [
{
path: '/',
name: 'index',
component: function () {
return import(/* webpackChunkName: "Index" */ '../views/Index.vue')
},
},
{
path: '/clients',
name: 'clients',
component: function () {
return import(/* webpackChunkName: "Clients" */ '../views/Clients.vue')
},
},
{
path: '/server',
name: 'server',
component: function () {
return import(/* webpackChunkName: "Server" */ '../views/Server.vue')
},
}
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
});
export default router

View File

@ -0,0 +1,40 @@
import axios from 'axios'
let baseUrl = "/api/v1.0";
if (process.env.NODE_ENV === "development"){
baseUrl = process.env.VUE_APP_API_BASE_URL
}
export const API_BASE_URL = baseUrl;
export class ApiService {
get(resource) {
return axios
.get(`${API_BASE_URL}${resource}`)
.then(response => response.data)
};
post(resource, data) {
return axios
.post(`${API_BASE_URL}${resource}`, data)
.then(response => response.data)
};
put(resource, data) {
return axios
.put(`${API_BASE_URL}${resource}`, data)
.then(response => response.data)
};
patch(resource, data) {
return axios
.patch(`${API_BASE_URL}${resource}`, data)
.then(response => response.data)
};
delete(resource) {
return axios
.delete(`${API_BASE_URL}${resource}`)
.then(response => response.data)
};
}

16
ui/src/views/Clients.vue Normal file
View File

@ -0,0 +1,16 @@
<template>
<v-content>
<Clients/>
</v-content>
</template>
<script>
import Clients from '../components/Clients'
export default {
name: 'clients',
components: {
Clients
}
}
</script>

10
ui/src/views/Index.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
</template>
<script>
export default {
created () {
this.$router.replace({ name: 'clients' })
}
}
</script>

16
ui/src/views/Server.vue Normal file
View File

@ -0,0 +1,16 @@
<template>
<v-content>
<Server/>
</v-content>
</template>
<script>
import Server from '../components/Server'
export default {
name: 'server',
components: {
Server
}
}
</script>

9
ui/vue.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
devServer: {
port: 8081,
disableHostCheck: true,
},
"transpileDependencies": [
"vuetify"
]
};

135
util/util.go Normal file
View File

@ -0,0 +1,135 @@
package util
import (
"errors"
"io/ioutil"
"net"
"os"
"regexp"
)
var (
RegexpEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
// pushed build time
Version string
)
// ReadFile file content
func ReadFile(path string) (bytes []byte, err error) {
bytes, err = ioutil.ReadFile(path)
if err != nil {
return nil, err
}
return bytes, nil
}
// WriteFile content to file
func WriteFile(path string, bytes []byte) (err error) {
err = ioutil.WriteFile(path, bytes, 0644)
if err != nil {
return err
}
return nil
}
// FileExists check if file exists
func FileExists(name string) bool {
info, err := os.Stat(name)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
// DirectoryExists check if directory exists
func DirectoryExists(name string) bool {
info, err := os.Stat(name)
if os.IsNotExist(err) {
return false
}
return info.IsDir()
}
// GetAvailableIp search for an available in cidr against a list of reserved ips
func GetAvailableIp(cidr string, reserved []string) (string, error) {
ip, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
}
// this two addresses are not usable
broadcastAddr := BroadcastAddr(ipnet).String()
networkAddr := ipnet.IP.String()
for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) {
ok := true
address := ip.String()
for _, r := range reserved {
if address == r {
ok = false
break
}
}
if ok && address != networkAddr && address != broadcastAddr {
return address, nil
}
}
return "", errors.New("no more available address from cidr")
}
// IsIPv6 check if given ip is IPv6
func IsIPv6(address string) bool {
ip := net.ParseIP(address)
if ip == nil {
return false
}
return ip.To4() == nil
}
// IsValidIp check if ip is valid
func IsValidIp(ip string) bool {
return net.ParseIP(ip) != nil
}
// IsValidCidr check if CIDR is valid
func IsValidCidr(cidr string) bool {
_, _, err := net.ParseCIDR(cidr)
return err == nil
}
// GetIpFromCidr get ip from cidr
func GetIpFromCidr(cidr string) (string, error) {
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
}
return ip.String(), nil
}
// http://play.golang.org/p/m8TNTtygK0
func inc(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- {
ip[j]++
if ip[j] > 0 {
break
}
}
}
// BroadcastAddr returns the last address in the given network, or the broadcast address.
func BroadcastAddr(n *net.IPNet) net.IP {
// The golang net package doesn't make it easy to calculate the broadcast address. :(
var broadcast net.IP
if len(n.IP) == 4 {
broadcast = net.ParseIP("0.0.0.0").To4()
} else {
broadcast = net.ParseIP("::")
}
for i := 0; i < len(n.IP); i++ {
broadcast[i] = n.IP[i] | ^n.Mask[i]
}
return broadcast
}

BIN
web-ui-a.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
web-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB