diff --git a/.env b/.env index 5d4e2bc..7a97ff7 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= +WG_STATS_API_USER= +WG_STATS_API_PASS= \ No newline at end of file diff --git a/README.md b/README.md index be524a5..51c1e85 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/api/v1/status/status.go b/api/v1/status/status.go new file mode 100644 index 0000000..f61f82b --- /dev/null +++ b/api/v1/status/status.go @@ -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) +} 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..474e8a5 --- /dev/null +++ b/core/status.go @@ -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 +} diff --git a/model/status.go b/model/status.go new file mode 100644 index 0000000..1ac07dd --- /dev/null +++ b/model/status.go @@ -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"` +} 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-lan-connect + mdi-lan-disconnect + + + {{ humanFileSize(item.receivedBytes) }} + + + {{ humanFileSize(item.transmittedBytes) }} + + + + mdi-ip-network + {{ ip }} + + + + + {{ item.lastHandshake | formatDate }} ({{ item.lastHandshakeRelative }}) + + + + + + + + + + + No stats available... + + {{ error }} + Status API integration not configured. + + + + + + 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/services/api.service.js b/ui/src/services/api.service.js index 9a5e6d2..237c308 100644 --- a/ui/src/services/api.service.js +++ b/ui/src/services/api.service.js @@ -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) { 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..b1db23e --- /dev/null +++ b/ui/src/store/modules/status.js @@ -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 +} 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 @@ + + + + + + +
{{ item.lastHandshake | formatDate }} ({{ item.lastHandshakeRelative }})