Merge pull request #58 from h44z/wg-api-integration

Wg api integration
This commit is contained in:
vx3r 2020-11-12 15:07:31 +01:00 committed by GitHub
commit 532587ff62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 636 additions and 8 deletions

5
.env
View File

@ -39,3 +39,8 @@ OAUTH2_REDIRECT_URL=https://wg-gen-web-demo.127-0-0-1.fr

# set provider name to fake to disable auth, also the default
OAUTH2_PROVIDER_NAME=fake

# https://github.com/jamescun/wg-api integration, user and password (basic auth) are optional
WG_STATS_API=
WG_STATS_API_USER=
WG_STATS_API_PASS=

View File

@ -46,7 +46,7 @@ The easiest way to run Wg Gen Web is using the container image
```
docker run --rm -it -v /tmp/wireguard:/data -p 8080:8080 -e "WG_CONF_DIR=/data" vx3r/wg-gen-web:latest
```
Docker compose snippet, used for demo server
Docker compose snippet, used for demo server, wg-json-api service is optional
```
version: '3.6'
wg-gen-web-demo:
@ -70,6 +70,14 @@ version: '3.6'
- OAUTH2_REDIRECT_URL=https://wg-gen-web-demo.127-0-0-1.fr
volumes:
- /etc/wireguard:/data
wg-json-api:
image: james/wg-api:latest
container_name: wg-json-api
restart: unless-stopped
cap_add:
- NET_ADMIN
network_mode: "host"
command: wg-api --device wg0 --listen localhost:8182
```
Please note that mapping ```/etc/wireguard``` to ```/data``` inside the docker, will erase your host's current configuration.
If needed, please make sure to backup your files from ```/etc/wireguard```.
@ -177,9 +185,21 @@ OAUTH2_CLIENT_SECRET=********************
OAUTH2_REDIRECT_URL=https://wg-gen-web-demo.127-0-0-1.fr
```

Please fell free to test and report any bugs.
Wg Gen Web will only access your profile to get email address and your name, no other unnecessary scopes will be requested.

## WireGuard Status Display
Wg Gen Web integrates a [WireGuard API implementation](https://github.com/jamescun/wg-api) to display client stats.
In order to enable the Status API integration, the following settings need to be configured:
```
# https://github.com/jamescun/wg-api integration, user and password (basic auth) are optional
WG_STATS_API=http://localhost:8182
WG_STATS_API_USER=
WG_STATS_API_PASS=
```
To setup the WireGuard API take a look at [https://github.com/jamescun/wg-api/blob/master/README.md](https://github.com/jamescun/wg-api/blob/master/README.md).

Please fell free to test and report any bugs.

## Need Help

* Join us on [Discord](https://discord.gg/fjx7gGJ)

50
api/v1/status/status.go Normal file
View File

@ -0,0 +1,50 @@
package status

import (
"net/http"
"os"

"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/core"
)

// ApplyRoutes applies router to gin Router
func ApplyRoutes(r *gin.RouterGroup) {
g := r.Group("/status")
{
g.GET("/enabled", readEnabled)
g.GET("/interface", readInterfaceStatus)
g.GET("/clients", readClientStatus)
}
}

func readEnabled(c *gin.Context) {
c.JSON(http.StatusOK, os.Getenv("WG_STATS_API") != "")
}

func readInterfaceStatus(c *gin.Context) {
status, err := core.ReadInterfaceStatus()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read interface status")
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())
return
}

c.JSON(http.StatusOK, status)
}

func readClientStatus(c *gin.Context) {
status, err := core.ReadClientStatus()
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("failed to read client status")
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())
return
}

c.JSON(http.StatusOK, status)
}

View File

@ -5,6 +5,7 @@ import (
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1/auth"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1/client"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1/server"
"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/api/v1/status"
)

// ApplyRoutes apply routes to gin router
@ -14,9 +15,9 @@ func ApplyRoutes(r *gin.RouterGroup, private bool) {
if private {
client.ApplyRoutes(v1)
server.ApplyRoutes(v1)
status.ApplyRoutes(v1)
} else {
auth.ApplyRoutes(v1)

}
}
}

182
core/status.go Normal file
View File

@ -0,0 +1,182 @@
package core

import (
"bytes"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"os"
"sort"
"time"

"gitlab.127-0-0-1.fr/vx3r/wg-gen-web/model"
)

// apiError implements a top-level JSON-RPC error.
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`

Data interface{} `json:"data,omitempty"`
}

type apiRequest struct {
Version string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}

type apiResponse struct {
Version string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"`
Error *apiError `json:"error,omitempty"`
ID json.RawMessage `json:"id"`
}

func fetchWireGuardAPI(reqData apiRequest) (*apiResponse, error) {
apiUrl := os.Getenv("WG_STATS_API")
if apiUrl == "" {
return nil, errors.New("Status API integration not configured")
}

apiClient := http.Client{
Timeout: time.Second * 2, // Timeout after 2 seconds
}
jsonData, _ := json.Marshal(reqData)
req, err := http.NewRequest(http.MethodPost, apiUrl, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}

req.Header.Set("User-Agent", "wg-gen-web")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Cache-Control", "no-cache")

if os.Getenv("WG_STATS_API_USER") != "" {
req.SetBasicAuth(os.Getenv("WG_STATS_API_USER"), os.Getenv("WG_STATS_API_PASS"))
}

res, getErr := apiClient.Do(req)
if getErr != nil {
return nil, getErr
}

if res.Body != nil {
defer res.Body.Close()
}

body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
return nil, readErr
}

response := apiResponse{}
jsonErr := json.Unmarshal(body, &response)
if jsonErr != nil {
return nil, jsonErr
}

return &response, nil
}

// ReadInterfaceStatus object, create default one
func ReadInterfaceStatus() (*model.InterfaceStatus, error) {
interfaceStatus := &model.InterfaceStatus{
Name: "unknown",
DeviceType: "unknown",
ListenPort: 0,
NumberOfPeers: 0,
PublicKey: "",
}

data, err := fetchWireGuardAPI(apiRequest{
Version: "2.0",
Method: "GetDeviceInfo",
Params: nil,
})
if err != nil {
return interfaceStatus, err
}

resultData := data.Result.(map[string]interface{})
device := resultData["device"].(map[string]interface{})
interfaceStatus.Name = device["name"].(string)
interfaceStatus.DeviceType = device["type"].(string)
interfaceStatus.PublicKey = device["public_key"].(string)
interfaceStatus.ListenPort = int(device["listen_port"].(float64))
interfaceStatus.NumberOfPeers = int(device["num_peers"].(float64))

return interfaceStatus, nil
}

// ReadClientStatus object, create default one, last recent active client is listed first
func ReadClientStatus() ([]*model.ClientStatus, error) {
var clientStatus []*model.ClientStatus

data, err := fetchWireGuardAPI(apiRequest{
Version: "2.0",
Method: "ListPeers",
Params: []byte("{}"),
})
if err != nil {
return clientStatus, err
}

resultData := data.Result.(map[string]interface{})
peers := resultData["peers"].([]interface{})

clients, err := ReadClients()
withClientDetails := true
if err != nil {
withClientDetails = false
}

for _, tmpPeer := range peers {
peer := tmpPeer.(map[string]interface{})
peerHandshake, _ := time.Parse(time.RFC3339Nano, peer["last_handshake"].(string))
peerIPs := peer["allowed_ips"].([]interface{})
peerAddresses := make([]string, len(peerIPs))
for i, peerIP := range peerIPs {
peerAddresses[i] = peerIP.(string)
}
peerHandshakeRelative := time.Since(peerHandshake)
peerActive := peerHandshakeRelative.Minutes() < 3 // TODO: we need a better detection... ping for example?

newClientStatus := &model.ClientStatus{
PublicKey: peer["public_key"].(string),
HasPresharedKey: peer["has_preshared_key"].(bool),
ProtocolVersion: int(peer["protocol_version"].(float64)),
Name: "UNKNOWN",
Email: "UNKNOWN",
Connected: peerActive,
AllowedIPs: peerAddresses,
Endpoint: peer["endpoint"].(string),
LastHandshake: peerHandshake,
LastHandshakeRelative: peerHandshakeRelative,
ReceivedBytes: int(peer["receive_bytes"].(float64)),
TransmittedBytes: int(peer["transmit_bytes"].(float64)),
}

if withClientDetails {
for _, client := range clients {
if client.PublicKey != newClientStatus.PublicKey {
continue
}

newClientStatus.Name = client.Name
newClientStatus.Email = client.Email
break
}
}

clientStatus = append(clientStatus, newClientStatus)
}

sort.Slice(clientStatus, func(i, j int) bool {
return clientStatus[i].LastHandshakeRelative < clientStatus[j].LastHandshakeRelative
})

return clientStatus, nil
}

67
model/status.go Normal file
View File

@ -0,0 +1,67 @@
package model

import (
"encoding/json"
"fmt"
"time"
)

// ClientStatus structure
type ClientStatus struct {
PublicKey string `json:"publicKey"`
HasPresharedKey bool `json:"hasPresharedKey"`
ProtocolVersion int `json:"protocolVersion"`
Name string `json:"name"`
Email string `json:"email"`
Connected bool `json:"connected"`
AllowedIPs []string `json:"allowedIPs"`
Endpoint string `json:"endpoint"`
LastHandshake time.Time `json:"lastHandshake"`
LastHandshakeRelative time.Duration `json:"lastHandshakeRelative"`
ReceivedBytes int `json:"receivedBytes"`
TransmittedBytes int `json:"transmittedBytes"`
}

func (c *ClientStatus) MarshalJSON() ([]byte, error) {

duration := fmt.Sprintf("%v ago", c.LastHandshakeRelative)
if c.LastHandshakeRelative.Hours() > 5208 { // 24*7*31 = approx one month
duration = "more than a month ago"
}
return json.Marshal(&struct {
PublicKey string `json:"publicKey"`
HasPresharedKey bool `json:"hasPresharedKey"`
ProtocolVersion int `json:"protocolVersion"`
Name string `json:"name"`
Email string `json:"email"`
Connected bool `json:"connected"`
AllowedIPs []string `json:"allowedIPs"`
Endpoint string `json:"endpoint"`
LastHandshake time.Time `json:"lastHandshake"`
LastHandshakeRelative string `json:"lastHandshakeRelative"`
ReceivedBytes int `json:"receivedBytes"`
TransmittedBytes int `json:"transmittedBytes"`
}{
PublicKey: c.PublicKey,
HasPresharedKey: c.HasPresharedKey,
ProtocolVersion: c.ProtocolVersion,
Name: c.Name,
Email: c.Email,
Connected: c.Connected,
AllowedIPs: c.AllowedIPs,
Endpoint: c.Endpoint,
LastHandshake: c.LastHandshake,
LastHandshakeRelative: duration,
ReceivedBytes: c.ReceivedBytes,
TransmittedBytes: c.TransmittedBytes,
})
}

// InterfaceStatus structure
type InterfaceStatus struct {
Name string `json:"name"`
DeviceType string `json:"type"`
ListenPort int `json:"listenPort"`
NumberOfPeers int `json:"numPeers"`
PublicKey string `json:"publicKey"`
}

View File

@ -15,6 +15,10 @@
Server
<v-icon right dark>mdi-vpn</v-icon>
</v-btn>
<v-btn to="/status">
Status
<v-icon right dark>mdi-chart-bar</v-icon>
</v-btn>
</v-toolbar-items>

<v-menu

View File

@ -0,0 +1,170 @@
<template>
<v-container>
<v-row v-if="dataLoaded">
<v-col cols="12">
<v-card>
<v-card-title>
WireGuard Interface Status: {{ interface.name }}
</v-card-title>
<v-list-item>
<v-list-item-content>
<v-list-item-subtitle>Public Key: {{ interface.publicKey }}</v-list-item-subtitle>
<v-list-item-subtitle>Listening Port: {{ interface.listenPort }}</v-list-item-subtitle>
<v-list-item-subtitle>Device Type: {{ interface.type }}</v-list-item-subtitle>
<v-list-item-subtitle>Number of Peers: {{ interface.numPeers }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-card>
</v-col>
</v-row>
<v-row v-if="dataLoaded">
<v-col cols="12">
<v-card>
<v-card-title>
WireGuard Client Status
<v-spacer></v-spacer>
<v-text-field
v-model="search"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
></v-text-field>
<v-spacer></v-spacer>
<v-btn
color="success"
@click="reload"
>
Reload
<v-icon right dark>mdi-reload</v-icon>
</v-btn>
</v-card-title>
<v-data-table
:headers="headers"
:items="clients"
:search="search"
>
<template v-slot:item.connected="{ item }">
<v-icon left v-if="item.connected" color="success">mdi-lan-connect</v-icon>
<v-icon left v-else>mdi-lan-disconnect</v-icon>
</template>
<template v-slot:item.receivedBytes="{ item }">
{{ humanFileSize(item.receivedBytes) }}
</template>
<template v-slot:item.transmittedBytes="{ item }">
{{ humanFileSize(item.transmittedBytes) }}
</template>
<template v-slot:item.allowedIPs="{ item }">
<v-chip
v-for="(ip, i) in item.allowedIPs"
:key="i"
color="indigo"
text-color="white"
>
<v-icon left>mdi-ip-network</v-icon>
{{ ip }}
</v-chip>
</template>
<template v-slot:item.lastHandshake="{ item }">
<v-row>
<p>{{ item.lastHandshake | formatDate }} ({{ item.lastHandshakeRelative }})</p>
</v-row>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12">
<v-card>
<v-card-title>
No stats available...
</v-card-title>
<v-card-text v-if="enabled">{{ error }}</v-card-text>
<v-card-text v-else>Status API integration not configured.</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'

export default {
name: 'Status',

data: () => ({
search: '',
headers: [
{ text: 'Connected', value: 'connected', },
{ text: 'Name', value: 'name', },
{ text: 'Endpoint', value: 'endpoint', },
{ text: 'IP addresses', value: 'allowedIPs', sortable: false, },
{ text: 'Received Bytes', value: 'receivedBytes', },
{ text: 'Transmitted Bytes', value: 'transmittedBytes', },
{ text: 'Last Handshake', value: 'lastHandshake',} ,
],
}),

computed:{
...mapGetters({
interface: 'status/interfaceStatus',
clients: 'status/clientStatus',
enabled: 'status/enabled',
error: 'status/error',
}),
dataLoaded: function () {
return this.enabled && this.interface != null && this.interface.name !== "";
}
},

mounted () {
this.readEnabled()
if(this.enabled) {
this.readStatus()
}
},

watch: {
enabled(newValue, oldValue) {
if (this.enabled) {
this.readStatus()
}
},
},

methods: {
...mapActions('status', {
readStatus: 'read',
readEnabled: 'isEnabled',
}),

reload() {
this.readStatus()
},

// https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
humanFileSize(bytes, si=false, dp=1) {
const thresh = si ? 1000 : 1024;

if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}

const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10**dp;

do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);


return bytes.toFixed(dp) + ' ' + units[u];
}
}
};
</script>

View File

@ -24,7 +24,17 @@ const routes = [
meta: {
requiresAuth: true
}
}
},
{
path: '/status',
name: 'status',
component: function () {
return import(/* webpackChunkName: "Status" */ '../views/Status.vue')
},
meta: {
requiresAuth: true
}
},
];

const router = new VueRouter({

View File

@ -10,9 +10,13 @@ const ApiService = {
get(resource) {
return Vue.axios.get(resource)
.then(response => response.data)
.catch(error => {
throw new Error(`ApiService: ${error}`)
});
.catch(error => {
if(typeof error.response !== 'undefined') {
throw new Error(`${error.response.status} - ${error.response.statusText}: ${error.response.data}`)
} else {
throw new Error(`ApiService: ${error}`)
}
});
},

post(resource, params) {

View File

@ -3,6 +3,7 @@ import Vuex from 'vuex'
import auth from "./modules/auth";
import client from "./modules/client";
import server from "./modules/server";
import status from "./modules/status";

Vue.use(Vuex)

@ -14,6 +15,7 @@ export default new Vuex.Store({
modules: {
auth,
client,
server
server,
status,
}
})

View File

@ -0,0 +1,97 @@
import ApiService from "../../services/api.service";

const state = {
error: null,
enabled: false,
interfaceStatus: null,
clientStatus: [],
version: '_ci_build_not_run_properly_',
}

const getters = {
error(state) {
return state.error;
},

enabled(state) {
return state.enabled;
},

interfaceStatus(state) {
return state.interfaceStatus;
},

clientStatus(state) {
return state.clientStatus;
},

version(state) {
return state.version;
},
}

const actions = {
error({ commit }, error){
commit('error', error)
},

read({ commit }){
ApiService.get("/status/interface")
.then(resp => {
commit('interfaceStatus', resp)
})
.catch(err => {
commit('interfaceStatus', null);
commit('error', err)
});
ApiService.get("/status/clients")
.then(resp => {
commit('clientStatus', resp)
})
.catch(err => {
commit('clientStatus', []);
commit('error', err)
});
},

isEnabled({ commit }){
ApiService.get("/status/enabled")
.then(resp => {
commit('enabled', resp)
})
.catch(err => {
commit('enabled', false);
commit('error', err.response.data)
});
},
}

const mutations = {
error(state, error) {
state.error = error;
},

enabled(state, enabled) {
state.enabled = enabled;
},

interfaceStatus(state, interfaceStatus){
state.interfaceStatus = interfaceStatus
},

clientStatus(state, clientStatus){
state.clientStatus = clientStatus
},

version(state, version){
state.version = version
},
}

export default {
namespaced: true,
state,
getters,
actions,
mutations
}

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

@ -0,0 +1,16 @@
<template>
<v-content>
<Status/>
</v-content>
</template>

<script>
import Status from '../components/Status'

export default {
name: 'status',
components: {
Status
}
}
</script>