diff --git a/.env b/.env index 5d4e2bc6d0fcb3e591d87ea5e92ce6bc696622e8..c1aabe03015b67420d0c27a50e6da8a301078d79 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 0000000000000000000000000000000000000000..7d3124cb2eb764116b9fd432b746ced830effbd8 --- /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 c7d5df5a119f29a8ec02c2f7b709440655300bda..d44e515a5d67a49e1ddc9b4de1d7c96956a1fd0a 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 0000000000000000000000000000000000000000..e2fc54e224a766067e17a4ce3a3f005bebe2b173 --- /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 0000000000000000000000000000000000000000..0f63917717250214d75b0c444dbadf343757a6d7 --- /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 58ca18fa712add7492071ab0d89928f1f6a2cf1a..e3645449bf38c0fb1b2b3a0ea3e7cce38145794d 100644 --- a/ui/src/components/Header.vue +++ b/ui/src/components/Header.vue @@ -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 diff --git a/ui/src/components/Status.vue b/ui/src/components/Status.vue new file mode 100644 index 0000000000000000000000000000000000000000..88633f02f4aaf46a122cfc1fa9f1b7f825ffe34e --- /dev/null +++ b/ui/src/components/Status.vue @@ -0,0 +1,175 @@ +<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.address="{ item }"> + <v-chip + v-for="(ip, i) in item.address" + :key="i" + color="indigo" + text-color="white" + > + <v-icon left>mdi-ip-network</v-icon> + {{ ip }} + </v-chip> + </template> + <template v-slot:item.tags="{ item }"> + <v-chip + v-for="(tag, i) in item.tags" + :key="i" + color="blue-grey" + text-color="white" + > + <v-icon left>mdi-tag</v-icon> + {{ tag }} + </v-chip> + </template> + <template v-slot:item.created="{ item }"> + <v-row> + <p>At {{ item.created | formatDate }} by {{ item.createdBy }}</p> + </v-row> + </template> + <template v-slot:item.updated="{ item }"> + <v-row> + <p>At {{ item.updated | formatDate }} by {{ item.updatedBy }}</p> + </v-row> + </template> + <template v-slot:item.action="{ item }"> + <v-row> + <v-icon + class="pr-1 pl-1" + @click.stop="startUpdate(item)" + > + mdi-square-edit-outline + </v-icon> + <v-icon + class="pr-1 pl-1" + @click.stop="forceFileDownload(item)" + > + mdi-cloud-download-outline + </v-icon> + <v-icon + class="pr-1 pl-1" + @click.stop="email(item)" + > + mdi-email-send-outline + </v-icon> + <v-icon + class="pr-1 pl-1" + @click="remove(item)" + > + mdi-trash-can-outline + </v-icon> + <v-switch + dark + class="pr-1 pl-1" + color="success" + v-model="item.enable" + v-on:change="update(item)" + /> + </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>{{ error }}</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', + error: 'status/error', + }), + dataLoaded: function () { + return this.interface != null && this.interface.name !== ""; + } + }, + + mounted () { + this.readStatus() + }, + + methods: { + ...mapActions('status', { + readStatus: 'read', + }), + + reload() { + this.readStatus() + }, + } + }; +</script> diff --git a/ui/src/router/index.js b/ui/src/router/index.js index ee29359639b949ce6a018767c37c9931482b6c85..c4c1d414feb012ec6042a63cad13858515aacb0c 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 97b5b767ebbfaf23cac1b4503bc1589ca0c627dc..ea685b8d27c2d173824de7826dd65ea63e4fcbea 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 0000000000000000000000000000000000000000..fef5cd9a7cc4b616d416cbd407ecf816d6af516a --- /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 0000000000000000000000000000000000000000..7888cc61ce6b6352e3bb6084a0d4c2d9c6d3b6df --- /dev/null +++ b/ui/src/views/Status.vue @@ -0,0 +1,16 @@ +<template> + <v-content> + <Status/> + </v-content> +</template> + +<script> + import Status from '../components/Status' + + export default { + name: 'status', + components: { + Status + } + } +</script>