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 + + + + + + mdi-ip-network + {{ ip }} + + + + + mdi-tag + {{ tag }} + + + + + At {{ item.created | formatDate }} by {{ item.createdBy }} + + + + + At {{ item.updated | formatDate }} by {{ item.updatedBy }} + + + + + + mdi-square-edit-outline + + + mdi-cloud-download-outline + + + mdi-email-send-outline + + + mdi-trash-can-outline + + + + + + + + + + + + + + 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 @@ + + + + + + +
At {{ item.created | formatDate }} by {{ item.createdBy }}
At {{ item.updated | formatDate }} by {{ item.updatedBy }}