stats display, expiry date field

This commit is contained in:
Christoph Haas 2023-07-18 16:05:06 +02:00
parent 8e0c59bb52
commit 2235c2a0d7
15 changed files with 289 additions and 30 deletions

View File

@ -172,7 +172,7 @@ function handleChangeAllowedIPs(tags) {
}
})
if(validInput) {
formData.value.AllowedIPs = tags
formData.value.AllowedIPs.Value = tags
}
}
@ -340,9 +340,17 @@ async function del() {
</fieldset>
<fieldset>
<legend class="mt-4">State</legend>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.Disabled">
<label class="form-check-label" >Disabled</label>
<div class="row">
<div class="form-group col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.Disabled">
<label class="form-check-label" >Disabled</label>
</div>
</div>
<div class="form-group col-md-6">
<label class="form-label">{{ $t('modals.peeredit.expiresat') }}</label>
<input type="date" pattern="\d{4}-\d{2}-\d{2}" class="form-control" min="2023-01-01" v-model="formData.ExpiresAt">
</div>
</div>
</fieldset>
</template>

View File

@ -4,7 +4,7 @@ import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import {useI18n} from "vue-i18n";
import { freshInterface, freshPeer } from '@/helpers/models';
import {freshInterface, freshPeer, freshStats} from '@/helpers/models';
import Prism from "vue-prism-component";
import {notify} from "@kyvg/vue3-notification";
@ -36,6 +36,16 @@ const selectedPeer = computed(() => {
return p
})
const selectedStats = computed(() => {
let s = peers.Statistics(props.peerId)
if (!s) {
s = freshStats() // dummy peer to avoid 'undefined' exceptions
}
return s
})
const selectedInterface = computed(() => {
let i = interfaces.GetSelected;
@ -105,14 +115,14 @@ function email() {
<template>
<Modal :title="title" :visible="visible" @close="close">
<template #default>
<div class="accordion">
<div class="accordion" id="peerInformation">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDetails" aria-expanded="true" aria-controls="collapseDetails">
Peer Information
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#accordionExample" style="">
<div id="collapseDetails" class="accordion-collapse collapse show" aria-labelledby="headingDetails" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<div class="row">
<div class="col-md-8">
@ -122,9 +132,8 @@ function email() {
<li>IP Addresses: <span v-for="ip in selectedPeer.Addresses" :key="ip" class="badge rounded-pill bg-light">{{ ip }}</span></li>
<li>Linked User: {{ selectedPeer.UserIdentifier }}</li>
<li>Notes: {{ selectedPeer.Notes }}</li>
<li>Expires At: {{ selectedPeer.ExpiresAt }}</li>
</ul>
<h4>Traffic</h4>
<p><i class="fas fa-long-arrow-alt-down"></i> 1.5 MB / <i class="fas fa-long-arrow-alt-up"></i> 3.9 MB</p>
</div>
<div class="col-md-4">
<img class="config-qr-img" :src="peers.ConfigQrUrl(props.peerId)" loading="lazy" alt="Configuration QR Code">
@ -133,19 +142,42 @@ function email() {
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingStatus">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseStatus" aria-expanded="false" aria-controls="collapseStatus">
Current Status
</button>
</h2>
<div id="collapseStatus" class="accordion-collapse collapse" aria-labelledby="headingStatus" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<div class="row">
<div class="col-md-12">
<h4>Traffic</h4>
<p><i class="fas fa-long-arrow-alt-down"></i> {{ selectedStats.BytesReceived }} Bytes / <i class="fas fa-long-arrow-alt-up"></i> {{ selectedStats.BytesTransmitted }} Bytes</p>
<h4>Connection Stats</h4>
<ul>
<li>Pingable: {{ selectedStats.IsPingable }}</li>
<li>Last Handshake: {{ selectedStats.LastHandshake }}</li>
<li>Connected Since: {{ selectedStats.LastSessionStart }}</li>
<li>Endpoint: {{ selectedStats.EndpointAddress }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div v-if="selectedInterface.Mode==='server'" class="accordion-item">
<h2 class="accordion-header" id="headingTwo">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
<h2 class="accordion-header" id="headingConfig">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseConfig" aria-expanded="false" aria-controls="collapseConfig">
Peer Configuration
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo" data-bs-parent="#accordionExample" style="">
<div id="collapseConfig" class="accordion-collapse collapse" aria-labelledby="headingConfig" data-bs-parent="#peerInformation" style="">
<div class="accordion-body">
<Prism language="ini" :code="configString"></Prism>
</div>
</div>
</div>
</div>
</template>
<template #footer>

View File

@ -117,4 +117,17 @@ export function freshPeer() {
Overridable: true,
}
}
}
export function freshStats() {
return {
IsConnected: false,
IsPingable: false,
LastHandshake: null,
LastPing: null,
LastSessionStart: null,
BytesTransmitted: 0,
BytesReceived: 0,
EndpointAddress: ""
}
}

View File

@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import {apiWrapper} from "../helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import {interfaceStore} from "./interfaces";
import { freshPeer } from '@/helpers/models';
import {freshPeer, freshStats} from '@/helpers/models';
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/peer`
@ -11,6 +11,8 @@ export const peerStore = defineStore({
id: 'peers',
state: () => ({
peers: [],
stats: {},
statsEnabled: false,
peer: freshPeer(),
prepared: freshPeer(),
configuration: "",
@ -24,6 +26,7 @@ export const peerStore = defineStore({
Find: (state) => {
return (id) => state.peers.find((p) => p.Identifier === id)
},
Count: (state) => state.peers.length,
Prepared: (state) => {console.log("STATE:", state.prepared); return state.prepared},
FilteredCount: (state) => state.Filtered.length,
@ -46,6 +49,11 @@ export const peerStore = defineStore({
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
Statistics: (state) => {
return (id) => state.statsEnabled && (id in state.stats) ? state.stats[id] : freshStats()
},
hasStatistics: (state) => state.statsEnabled,
},
actions: {
afterPageSizeChange() {
@ -90,6 +98,14 @@ export const peerStore = defineStore({
setPeerConfig(config) {
this.configuration = config;
},
setStats(statsResponse) {
if (!statsResponse) {
this.stats = {}
this.statsEnabled = false
}
this.stats = statsResponse.Stats
this.statsEnabled = statsResponse.Enabled
},
async PreparePeer(interfaceId) {
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/prepare`)
.then(this.setPreparedPeer)
@ -143,6 +159,27 @@ export const peerStore = defineStore({
})
})
},
async LoadStats(interfaceId) {
// if no interfaceId is given, use the currently selected interface
if (!interfaceId) {
interfaceId = interfaceStore().GetSelected.Identifier
if (!interfaceId) {
return // no interface, nothing to load
}
}
this.fetching = true
return apiWrapper.get(`${baseUrl}/iface/${base64_url_encode(interfaceId)}/stats`)
.then(this.setStats)
.catch(error => {
this.setStats(undefined)
console.log("Failed to load peer stats: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load peer stats!",
})
})
},
async DeletePeer(id) {
this.fetching = true
return apiWrapper.delete(`${baseUrl}/${base64_url_encode(id)}`)

View File

@ -46,7 +46,8 @@ async function download() {
onMounted(async () => {
await interfaces.LoadInterfaces()
await peers.LoadPeers()
await peers.LoadPeers(undefined) // use default interface
await peers.LoadStats(undefined) // use default interface
})
</script>
@ -294,7 +295,7 @@ onMounted(async () => {
<th scope="col">{{ $t('interfaces.tableHeadings[1]') }}</th>
<th scope="col">{{ $t('interfaces.tableHeadings[2]') }}</th>
<th v-if="interfaces.GetSelected.Mode==='client'" scope="col">{{ $t('interfaces.tableHeadings[3]') }}</th>
<th scope="col">{{ $t('interfaces.tableHeadings[4]') }}</th>
<th v-if="peers.hasStatistics" scope="col">{{ $t('interfaces.tableHeadings[4]') }}</th>
<th scope="col"></th><!-- Actions -->
</tr>
</thead>
@ -309,7 +310,14 @@ onMounted(async () => {
<span v-for="ip in peer.Addresses" :key="ip" class="badge bg-light me-1">{{ ip }}</span>
</td>
<td v-if="interfaces.GetSelected.Mode==='client'">{{peer.Endpoint.Value}}</td>
<td>{{peer.LastConnected}}</td>
<td v-if="peers.hasStatistics">
<div v-if="peers.Statistics(peer.Identifier).IsConnected">
<span class="badge rounded-pill bg-success"><i class="fa-solid fa-link"></i></span> <span :title="peers.Statistics(peer.Identifier).LastHandshake">Connected</span>
</div>
<div v-else>
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
</div>
</td>
<td class="text-center">
<a href="#" title="Show peer" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
<a href="#" title="Edit peer" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>

View File

@ -230,6 +230,21 @@ func (r *SqlRepo) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceI
return in, peers, nil
}
func (r *SqlRepo) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
if len(ids) == 0 {
return nil, nil
}
var stats []domain.PeerStatus
err := r.db.WithContext(ctx).Where("identifier IN ?", ids).Find(&stats).Error
if err != nil {
return nil, err
}
return stats, nil
}
func (r *SqlRepo) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
var interfaces []domain.Interface

View File

@ -22,6 +22,7 @@ func (e peerEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenti
apiGroup := g.Group("/peer", e.authenticator.LoggedIn())
apiGroup.GET("/iface/:iface/all", e.handleAllGet())
apiGroup.GET("/iface/:iface/stats", e.handleStatsGet())
apiGroup.GET("/iface/:iface/prepare", e.handlePrepareGet())
apiGroup.POST("/iface/:iface/new", e.handleCreatePost())
apiGroup.POST("/iface/:iface/multiplenew", e.handleCreateMultiplePost())
@ -404,3 +405,34 @@ func (e peerEndpoint) handleEmailPost() gin.HandlerFunc {
c.Status(http.StatusNoContent)
}
}
// handleStatsGet returns a gorm handler function.
//
// @ID peers_handleStatsGet
// @Tags Peer
// @Summary Get peer stats for the given interface.
// @Produce json
// @Param iface path string true "The interface identifier"
// @Success 200 {object} model.PeerStats
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/iface/{iface}/stats [get]
func (e peerEndpoint) handleStatsGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
interfaceId := Base64UrlDecode(c.Param("iface"))
if interfaceId == "" {
c.JSON(http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"})
return
}
stats, err := e.app.GetPeerStats(ctx, domain.InterfaceIdentifier(interfaceId))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.NewPeerStats(true, stats))
}
}

View File

@ -6,6 +6,38 @@ import (
"time"
)
const ExpiryDateTimeLayout = "\"2006-01-02\""
type ExpiryDate struct {
*time.Time
}
// UnmarshalJSON will unmarshal using 2006-01-02 layout
func (d *ExpiryDate) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" {
return nil
}
parsed, err := time.Parse(ExpiryDateTimeLayout, string(b))
if err != nil {
return err
}
if !parsed.IsZero() {
d.Time = &parsed
}
return nil
}
// MarshalJSON will marshal using 2006-01-02 layout
func (d *ExpiryDate) MarshalJSON() ([]byte, error) {
if d == nil || d.Time == nil {
return []byte("null"), nil
}
s := d.Format(ExpiryDateTimeLayout)
return []byte(s), nil
}
type Peer struct {
Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier
DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer
@ -13,7 +45,7 @@ type Peer struct {
InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id
Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down)
DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled
ExpiresAt *time.Time `json:"ExpiresAt"` // expiry dates for peers
ExpiresAt ExpiryDate `json:"ExpiresAt,omitempty"` // expiry dates for peers
Notes string `json:"Notes"` // a note field for peers
Endpoint StringConfigOption `json:"Endpoint"` // the endpoint address
@ -50,7 +82,7 @@ func NewPeer(src *domain.Peer) *Peer {
InterfaceIdentifier: string(src.InterfaceIdentifier),
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
ExpiresAt: src.ExpiresAt,
ExpiresAt: ExpiryDate{src.ExpiresAt},
Notes: src.Notes,
Endpoint: StringConfigOptionFromDomain(src.Endpoint),
EndpointPublicKey: StringConfigOptionFromDomain(src.EndpointPublicKey),
@ -103,7 +135,7 @@ func NewDomainPeer(src *Peer) *domain.Peer {
InterfaceIdentifier: domain.InterfaceIdentifier(src.InterfaceIdentifier),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
ExpiresAt: src.ExpiresAt,
ExpiresAt: src.ExpiresAt.Time,
Notes: src.Notes,
Interface: domain.PeerInterfaceConfig{
KeyPair: domain.KeyPair{
@ -148,3 +180,45 @@ type PeerMailRequest struct {
Identifiers []string `json:"Identifiers"`
LinkOnly bool `json:"LinkOnly"`
}
type PeerStats struct {
Enabled bool `json:"Enabled" example:"true"` // peer stats tracking enabled
Stats map[string]PeerStatData `json:"Stats"` // stats, map key = Peer identifier
}
func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats {
stats := make(map[string]PeerStatData, len(src))
for _, srcStat := range src {
stats[string(srcStat.PeerId)] = PeerStatData{
IsConnected: srcStat.IsConnected(),
IsPingable: srcStat.IsPingable,
LastPing: srcStat.LastPing,
BytesReceived: srcStat.BytesReceived,
BytesTransmitted: srcStat.BytesTransmitted,
LastHandshake: srcStat.LastHandshake,
EndpointAddress: srcStat.Endpoint,
LastSessionStart: srcStat.LastSessionStart,
}
}
return &PeerStats{
Enabled: enabled,
Stats: stats,
}
}
type PeerStatData struct {
IsConnected bool `json:"IsConnected"`
IsPingable bool `json:"IsPingable"`
LastPing *time.Time `json:"LastPing"`
BytesReceived uint64 `json:"BytesReceived"`
BytesTransmitted uint64 `json:"BytesTransmitted"`
LastHandshake *time.Time `json:"LastHandshake"`
EndpointAddress string `json:"EndpointAddress"`
LastSessionStart *time.Time `json:"LastSessionStart"`
}

View File

@ -31,6 +31,7 @@ type WireGuardManager interface {
RestoreInterfaceState(ctx context.Context, updateDbOnError bool, filter ...domain.InterfaceIdentifier) error
CreateDefaultPeer(ctx context.Context, user *domain.User) error
GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
GetAllInterfaces(ctx context.Context) ([]domain.Interface, error)
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
PrepareInterface(ctx context.Context) (*domain.Interface, error)

View File

@ -8,6 +8,7 @@ import (
type InterfaceAndPeerDatabaseRepo interface {
GetInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, error)
GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error)
GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error)
GetAllInterfaces(ctx context.Context) ([]domain.Interface, error)
FindInterfaces(ctx context.Context, search string) ([]domain.Interface, error)
GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error)

View File

@ -137,8 +137,8 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) {
}
}
func getSessionStartTime(oldStats domain.PeerStatus, newReceived, newTransmitted uint64, lastHandshake *time.Time) *time.Time {
if lastHandshake == nil {
func getSessionStartTime(oldStats domain.PeerStatus, newReceived, newTransmitted uint64, latestHandshake *time.Time) *time.Time {
if latestHandshake == nil {
return nil // currently not connected
}
@ -146,16 +146,19 @@ func getSessionStartTime(oldStats domain.PeerStatus, newReceived, newTransmitted
switch {
// old session was never initiated
case oldStats.BytesReceived == 0 && oldStats.BytesTransmitted == 0 && (newReceived > 0 || newTransmitted > 0):
return lastHandshake
return latestHandshake
// session never received bytes -> first receive
case oldStats.BytesReceived == 0 && newReceived > 0 && (oldStats.LastHandshake == nil || oldStats.LastHandshake.Before(oldestHandshakeTime)):
return lastHandshake
return latestHandshake
// session never transmitted bytes -> first transmit
case oldStats.BytesTransmitted == 0 && newTransmitted > 0 && (oldStats.LastSessionStart == nil || oldStats.LastHandshake.Before(oldestHandshakeTime)):
return lastHandshake
return latestHandshake
// session restarted as newer send or transmit counts are lower
case (newReceived != 0 && newReceived < oldStats.BytesReceived) || (newTransmitted != 0 && newTransmitted < oldStats.BytesTransmitted):
return lastHandshake
return latestHandshake
// session initiated (but some bytes were already transmitted
case oldStats.LastSessionStart == nil && (newReceived > oldStats.BytesReceived || newTransmitted > oldStats.BytesTransmitted):
return latestHandshake
default:
return oldStats.LastSessionStart
}

View File

@ -53,6 +53,16 @@ func Test_getSessionStartTime(t *testing.T) {
},
want: &now,
},
{
name: "freshly connected (no prev session but bytes)",
args: args{
oldStats: domain.PeerStatus{LastSessionStart: nil, BytesReceived: 10, BytesTransmitted: 20},
newReceived: 100,
newTransmitted: 100,
lastHandshake: &now,
},
want: &now,
},
{
name: "still connected",
args: args{

View File

@ -269,7 +269,7 @@ func (m Manager) RestoreInterfaceState(ctx context.Context, updateDbOnError bool
func (m Manager) CreateDefaultPeer(ctx context.Context, user *domain.User) error {
// TODO: implement
return nil
return fmt.Errorf("IMPLEMENT ME")
}
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error) {
@ -885,3 +885,17 @@ func (m Manager) validatePeerDeletion(ctx context.Context, del *domain.Peer) err
return nil
}
func (m Manager) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
_, peers, err := m.db.GetInterfaceAndPeers(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for interface %s: %w", id, err)
}
peerIds := make([]domain.PeerIdentifier, len(peers))
for i, peer := range peers {
peerIds[i] = peer.Identifier
}
return m.db.GetPeersStats(ctx, peerIds...)
}

View File

@ -132,10 +132,10 @@ type PhysicalPeer struct {
}
func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key {
if p.PrivateKey == "" {
if p.PresharedKey == "" {
return nil
}
key, err := wgtypes.ParseKey(p.PrivateKey)
key, err := wgtypes.ParseKey(string(p.PresharedKey))
if err != nil {
return nil
}

View File

@ -17,6 +17,17 @@ type PeerStatus struct {
LastSessionStart *time.Time `gorm:"column:last_session_start"`
}
func (s PeerStatus) IsConnected() bool {
oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected
handshakeValid := false
if s.LastHandshake != nil {
handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime)
}
return s.IsPingable || handshakeValid
}
type InterfaceStatus struct {
InterfaceId InterfaceIdentifier `gorm:"primaryKey;column:identifier"`
UpdatedAt time.Time `gorm:"column:updated_at"`