From 31dd494d62ad381924d7f584dce4c08b3279fbb6 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Tue, 6 Oct 2020 18:14:34 +0200 Subject: [PATCH 1/5] Integrate https://github.com/jamescun/wg-api stats into the UI --- .env | 5 + api/v1/status/status.go | 44 +++++++++ api/v1/v1.go | 3 +- core/status.go | 168 +++++++++++++++++++++++++++++++ model/status.go | 29 ++++++ ui/src/components/Header.vue | 4 + ui/src/components/Status.vue | 175 +++++++++++++++++++++++++++++++++ ui/src/router/index.js | 12 ++- ui/src/store/index.js | 4 +- ui/src/store/modules/status.js | 77 +++++++++++++++ ui/src/views/Status.vue | 16 +++ 11 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 api/v1/status/status.go create mode 100644 core/status.go create mode 100644 model/status.go create mode 100644 ui/src/components/Status.vue create mode 100644 ui/src/store/modules/status.js create mode 100644 ui/src/views/Status.vue diff --git a/.env b/.env index 5d4e2bc..c1aabe0 100644 --- a/.env +++ b/.env @@ -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=https://wg.example.digital/wg-api +WG_STATS_API_USER= +WG_STATS_API_PASS= \ No newline at end of file diff --git a/api/v1/status/status.go b/api/v1/status/status.go new file mode 100644 index 0000000..7d3124c --- /dev/null +++ b/api/v1/status/status.go @@ -0,0 +1,44 @@ +package status + +import ( + "net/http" + + "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("/interface", readInterfaceStatus) + g.GET("/clients", readClientStatus) + } +} + +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.AbortWithStatus(http.StatusInternalServerError) + 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.AbortWithStatus(http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, status) +} diff --git a/api/v1/v1.go b/api/v1/v1.go index c7d5df5..d44e515 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -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) - } } } diff --git a/core/status.go b/core/status.go new file mode 100644 index 0000000..e2fc54e --- /dev/null +++ b/core/status.go @@ -0,0 +1,168 @@ +package core + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "os" + "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") + 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) + } + + 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: false, + AllowedIPs: peerAddresses, + Endpoint: peer["endpoint"].(string), + LastHandshake: peerHandshake, + 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) + } + return clientStatus, nil +} diff --git a/model/status.go b/model/status.go new file mode 100644 index 0000000..0f63917 --- /dev/null +++ b/model/status.go @@ -0,0 +1,29 @@ +package model + +import ( + "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"` + ReceivedBytes int `json:"receivedBytes"` + TransmittedBytes int `json:"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"` +} diff --git a/ui/src/components/Header.vue b/ui/src/components/Header.vue index 58ca18f..e364544 100644 --- a/ui/src/components/Header.vue +++ b/ui/src/components/Header.vue @@ -15,6 +15,10 @@ Server mdi-vpn + + Status + mdi-chart-bar + + + + + + + WireGuard Interface Status: {{ interface.name }} + + + + Public Key: {{ interface.publicKey }} + Listening Port: {{ interface.listenPort }} + Device Type: {{ interface.type }} + Number of Peers: {{ interface.numPeers }} + + + + + + + + + + WireGuard Client Status + + + + + Reload + mdi-reload + + + + + + + + + + + + + + + + + + No stats available... + + {{ error }} + + + + + + diff --git a/ui/src/router/index.js b/ui/src/router/index.js index ee29359..c4c1d41 100644 --- a/ui/src/router/index.js +++ b/ui/src/router/index.js @@ -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({ diff --git a/ui/src/store/index.js b/ui/src/store/index.js index 97b5b76..ea685b8 100644 --- a/ui/src/store/index.js +++ b/ui/src/store/index.js @@ -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, } }) diff --git a/ui/src/store/modules/status.js b/ui/src/store/modules/status.js new file mode 100644 index 0000000..fef5cd9 --- /dev/null +++ b/ui/src/store/modules/status.js @@ -0,0 +1,77 @@ +import ApiService from "../../services/api.service"; + +const state = { + error: null, + interfaceStatus: null, + clientStatus: [], + version: '_ci_build_not_run_properly_', +} + +const getters = { + error(state) { + return state.error; + }, + + 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) + }); + }, +} + +const mutations = { + error(state, error) { + state.error = error; + }, + + 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 +} diff --git a/ui/src/views/Status.vue b/ui/src/views/Status.vue new file mode 100644 index 0000000..7888cc6 --- /dev/null +++ b/ui/src/views/Status.vue @@ -0,0 +1,16 @@ + + + From 3c155f1c97c991782c07262eedb8de74fa74c0f7 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Tue, 6 Oct 2020 18:58:34 +0200 Subject: [PATCH 2/5] improve formatting and layout --- .env | 2 +- api/v1/status/status.go | 4 +- core/status.go | 30 +++++++----- model/status.go | 60 ++++++++++++++++++----- ui/src/components/Status.vue | 92 ++++++++++++++---------------------- 5 files changed, 107 insertions(+), 81 deletions(-) diff --git a/.env b/.env index c1aabe0..7a97ff7 100644 --- a/.env +++ b/.env @@ -41,6 +41,6 @@ OAUTH2_REDIRECT_URL=https://wg-gen-web-demo.127-0-0-1.fr OAUTH2_PROVIDER_NAME=fake # https://github.com/jamescun/wg-api integration, user and password (basic auth) are optional -WG_STATS_API=https://wg.example.digital/wg-api +WG_STATS_API= WG_STATS_API_USER= WG_STATS_API_PASS= \ No newline at end of file diff --git a/api/v1/status/status.go b/api/v1/status/status.go index 7d3124c..f5a16de 100644 --- a/api/v1/status/status.go +++ b/api/v1/status/status.go @@ -23,7 +23,7 @@ func readInterfaceStatus(c *gin.Context) { log.WithFields(log.Fields{ "err": err, }).Error("failed to read interface status") - c.AbortWithStatus(http.StatusInternalServerError) + c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error()) return } @@ -36,7 +36,7 @@ func readClientStatus(c *gin.Context) { log.WithFields(log.Fields{ "err": err, }).Error("failed to read client status") - c.AbortWithStatus(http.StatusInternalServerError) + c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error()) return } diff --git a/core/status.go b/core/status.go index e2fc54e..cbb4bfe 100644 --- a/core/status.go +++ b/core/status.go @@ -3,6 +3,7 @@ package core import ( "bytes" "encoding/json" + "errors" "io/ioutil" "net/http" "os" @@ -34,6 +35,10 @@ type apiResponse struct { 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 } @@ -135,19 +140,22 @@ func ReadClientStatus() ([]*model.ClientStatus, error) { 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: false, - AllowedIPs: peerAddresses, - Endpoint: peer["endpoint"].(string), - LastHandshake: peerHandshake, - ReceivedBytes: int(peer["receive_bytes"].(float64)), - TransmittedBytes: int(peer["transmit_bytes"].(float64)), + 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 { diff --git a/model/status.go b/model/status.go index 0f63917..1ac07dd 100644 --- a/model/status.go +++ b/model/status.go @@ -1,22 +1,60 @@ 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"` - ReceivedBytes int `json:"receivedBytes"` - TransmittedBytes int `json:"transmittedBytes"` + 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 diff --git a/ui/src/components/Status.vue b/ui/src/components/Status.vue index 88633f0..eb6f9f8 100644 --- a/ui/src/components/Status.vue +++ b/ui/src/components/Status.vue @@ -44,9 +44,19 @@ :items="clients" :search="search" > -