diff --git a/frontend/src/helpers/models.js b/frontend/src/helpers/models.js
index feb6ce5..e339692 100644
--- a/frontend/src/helpers/models.js
+++ b/frontend/src/helpers/models.js
@@ -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: ""
+ }
}
\ No newline at end of file
diff --git a/frontend/src/stores/peers.js b/frontend/src/stores/peers.js
index 532c316..f7b7f75 100644
--- a/frontend/src/stores/peers.js
+++ b/frontend/src/stores/peers.js
@@ -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)}`)
diff --git a/frontend/src/views/InterfaceView.vue b/frontend/src/views/InterfaceView.vue
index 87088d8..0677e2d 100644
--- a/frontend/src/views/InterfaceView.vue
+++ b/frontend/src/views/InterfaceView.vue
@@ -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
})
@@ -294,7 +295,7 @@ onMounted(async () => {
{{ $t('interfaces.tableHeadings[1]') }} |
{{ $t('interfaces.tableHeadings[2]') }} |
{{ $t('interfaces.tableHeadings[3]') }} |
- {{ $t('interfaces.tableHeadings[4]') }} |
+ {{ $t('interfaces.tableHeadings[4]') }} |
|
@@ -309,7 +310,14 @@ onMounted(async () => {
{{ ip }}
{{peer.Endpoint.Value}} |
- {{peer.LastConnected}} |
+
+
+ Connected
+
+
+
+
+ |
diff --git a/internal/adapters/database.go b/internal/adapters/database.go
index 0b52bdb..b7eb5bb 100644
--- a/internal/adapters/database.go
+++ b/internal/adapters/database.go
@@ -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
diff --git a/internal/app/api/v0/handlers/endpoint_peers.go b/internal/app/api/v0/handlers/endpoint_peers.go
index eb9a651..950691f 100644
--- a/internal/app/api/v0/handlers/endpoint_peers.go
+++ b/internal/app/api/v0/handlers/endpoint_peers.go
@@ -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))
+ }
+}
diff --git a/internal/app/api/v0/model/models_peer.go b/internal/app/api/v0/model/models_peer.go
index 6aeb8b7..4b230e1 100644
--- a/internal/app/api/v0/model/models_peer.go
+++ b/internal/app/api/v0/model/models_peer.go
@@ -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"`
+}
diff --git a/internal/app/repos.go b/internal/app/repos.go
index b9895d1..4bb4202 100644
--- a/internal/app/repos.go
+++ b/internal/app/repos.go
@@ -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)
diff --git a/internal/app/wireguard/repos.go b/internal/app/wireguard/repos.go
index 7d6ffd8..23b0a96 100644
--- a/internal/app/wireguard/repos.go
+++ b/internal/app/wireguard/repos.go
@@ -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)
diff --git a/internal/app/wireguard/statistics.go b/internal/app/wireguard/statistics.go
index eb6a6ec..e4ab471 100644
--- a/internal/app/wireguard/statistics.go
+++ b/internal/app/wireguard/statistics.go
@@ -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
}
diff --git a/internal/app/wireguard/statistics_test.go b/internal/app/wireguard/statistics_test.go
index 25b10f1..23840e8 100644
--- a/internal/app/wireguard/statistics_test.go
+++ b/internal/app/wireguard/statistics_test.go
@@ -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{
diff --git a/internal/app/wireguard/wireguard.go b/internal/app/wireguard/wireguard.go
index 00f204c..54102b6 100644
--- a/internal/app/wireguard/wireguard.go
+++ b/internal/app/wireguard/wireguard.go
@@ -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...)
+}
diff --git a/internal/domain/peer.go b/internal/domain/peer.go
index 94ea4b5..577be4b 100644
--- a/internal/domain/peer.go
+++ b/internal/domain/peer.go
@@ -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
}
diff --git a/internal/domain/statistics.go b/internal/domain/statistics.go
index c21bc29..9d04a72 100644
--- a/internal/domain/statistics.go
+++ b/internal/domain/statistics.go
@@ -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"`
|