add metric endpoint to public API (#72, #80)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled

This commit is contained in:
Christoph Haas
2025-01-11 23:42:05 +01:00
parent 63d85d8123
commit 2d78fe33b8
7 changed files with 863 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
package backend
import (
"context"
"fmt"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type MetricsServiceDatabaseRepo interface {
GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error)
GetInterfaceStats(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.InterfaceStatus,
error,
)
GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error)
}
type MetricsServiceUserManagerRepo interface {
GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error)
}
type MetricsServicePeerManagerRepo interface {
GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error)
}
type MetricsService struct {
cfg *config.Config
db MetricsServiceDatabaseRepo
users MetricsServiceUserManagerRepo
peers MetricsServicePeerManagerRepo
}
func NewMetricsService(
cfg *config.Config,
db MetricsServiceDatabaseRepo,
users MetricsServiceUserManagerRepo,
peers MetricsServicePeerManagerRepo,
) *MetricsService {
return &MetricsService{
cfg: cfg,
db: db,
users: users,
peers: peers,
}
}
func (m MetricsService) GetForInterface(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.InterfaceStatus,
error,
) {
if !m.cfg.Statistics.CollectInterfaceData {
return nil, fmt.Errorf("interface statistics collection is disabled")
}
// validate admin rights
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
interfaceStats, err := m.db.GetInterfaceStats(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch stats for interface %s: %w", id, err)
}
return interfaceStats, nil
}
func (m MetricsService) GetForUser(ctx context.Context, id domain.UserIdentifier) (
*domain.User,
[]domain.PeerStatus,
error,
) {
if !m.cfg.Statistics.CollectPeerData {
return nil, nil, fmt.Errorf("statistics collection is disabled")
}
if err := domain.ValidateUserAccessRights(ctx, id); err != nil {
return nil, nil, err
}
user, err := m.users.GetUser(ctx, id)
if err != nil {
return nil, nil, err
}
peers, err := m.db.GetUserPeers(ctx, user.Identifier)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch peers for user %s: %w", user.Identifier, err)
}
peerIds := make([]domain.PeerIdentifier, len(peers))
for i, peer := range peers {
peerIds[i] = peer.Identifier
}
peerStats, err := m.db.GetPeersStats(ctx, peerIds...)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch peer stats for user %s: %w", user.Identifier, err)
}
return user, peerStats, nil
}
func (m MetricsService) GetForPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.PeerStatus, error) {
if !m.cfg.Statistics.CollectPeerData {
return nil, fmt.Errorf("peer statistics collection is disabled")
}
peer, err := m.peers.GetPeer(ctx, id)
if err != nil {
return nil, err
}
if err := domain.ValidateUserAccessRights(ctx, peer.UserIdentifier); err != nil {
return nil, err
}
peerStats, err := m.db.GetPeersStats(ctx, peer.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to fetch stats for peer %s: %w", peer.Identifier, err)
}
if len(peerStats) == 0 {
return nil, fmt.Errorf("no stats found for peer %s: %w", peer.Identifier, domain.ErrNotFound)
}
return &peerStats[0], nil
}

View File

@@ -0,0 +1,140 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app/api/v1/models"
"github.com/h44z/wg-portal/internal/domain"
)
type MetricsEndpointStatisticsService interface {
GetForInterface(ctx context.Context, id domain.InterfaceIdentifier) (*domain.InterfaceStatus, error)
GetForUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, []domain.PeerStatus, error)
GetForPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.PeerStatus, error)
}
type MetricsEndpoint struct {
metrics MetricsEndpointStatisticsService
}
func NewMetricsEndpoint(metrics MetricsEndpointStatisticsService) *MetricsEndpoint {
return &MetricsEndpoint{
metrics: metrics,
}
}
func (e MetricsEndpoint) GetName() string {
return "MetricsEndpoint"
}
func (e MetricsEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authenticationHandler) {
apiGroup := g.Group("/metrics", authenticator.LoggedIn())
apiGroup.GET("/by-interface/:id", authenticator.LoggedIn(ScopeAdmin), e.handleMetricsForInterfaceGet())
apiGroup.GET("/by-user/:id", authenticator.LoggedIn(), e.handleMetricsForUserGet())
apiGroup.GET("/by-peer/:id", authenticator.LoggedIn(), e.handleMetricsForPeerGet())
}
// handleMetricsForInterfaceGet returns a gorm Handler function.
//
// @ID metrics_handleMetricsForInterfaceGet
// @Tags Metrics
// @Summary Get all metrics for a WireGuard Portal interface.
// @Param id path string true "The WireGuard interface identifier."
// @Produce json
// @Success 200 {object} models.InterfaceMetrics
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /metrics/by-interface/{id} [get]
// @Security BasicAuth
func (e MetricsEndpoint) handleMetricsForInterfaceGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
interfaceMetrics, err := e.metrics.GetForInterface(ctx, domain.InterfaceIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewInterfaceMetrics(interfaceMetrics))
}
}
// handleMetricsForUserGet returns a gorm Handler function.
//
// @ID metrics_handleMetricsForUserGet
// @Tags Metrics
// @Summary Get all metrics for a WireGuard Portal user.
// @Param id path string true "The user identifier."
// @Produce json
// @Success 200 {object} models.UserMetrics
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /metrics/by-user/{id} [get]
// @Security BasicAuth
func (e MetricsEndpoint) handleMetricsForUserGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing interface id"})
return
}
user, userMetrics, err := e.metrics.GetForUser(ctx, domain.UserIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewUserMetrics(user, userMetrics))
}
}
// handleMetricsForPeerGet returns a gorm Handler function.
//
// @ID metrics_handleMetricsForPeerGet
// @Tags Metrics
// @Summary Get all metrics for a WireGuard Portal peer.
// @Param id path string true "The peer identifier (public key)."
// @Produce json
// @Success 200 {object} models.PeerMetrics
// @Failure 400 {object} models.Error
// @Failure 401 {object} models.Error
// @Failure 404 {object} models.Error
// @Failure 500 {object} models.Error
// @Router /metrics/by-peer/{id} [get]
// @Security BasicAuth
func (e MetricsEndpoint) handleMetricsForPeerGet() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := domain.SetUserInfoFromGin(c)
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, models.Error{Code: http.StatusBadRequest, Message: "missing peer id"})
return
}
peerMetrics, err := e.metrics.GetForPeer(ctx, domain.PeerIdentifier(id))
if err != nil {
c.JSON(ParseServiceError(err))
return
}
c.JSON(http.StatusOK, models.NewPeerMetrics(peerMetrics))
}
}

View File

@@ -0,0 +1,105 @@
package models
import (
"time"
"github.com/h44z/wg-portal/internal/domain"
)
// PeerMetrics represents the metrics of a WireGuard peer.
type PeerMetrics struct {
// The unique identifier of the peer.
PeerIdentifier string `json:"PeerIdentifier" example:"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg="`
// If this field is set, the peer is pingable.
IsPingable bool `json:"IsPingable" example:"true"`
// The last time the peer responded to a ICMP ping request.
LastPing *time.Time `json:"LastPing" example:"2021-01-01T12:00:00Z"`
// The number of bytes received by the peer.
BytesReceived uint64 `json:"BytesReceived" example:"123456789"`
// The number of bytes transmitted by the peer.
BytesTransmitted uint64 `json:"BytesTransmitted" example:"123456789"`
// The last time the peer initiated a handshake.
LastHandshake *time.Time `json:"LastHandshake" example:"2021-01-01T12:00:00Z"`
// The current endpoint address of the peer.
Endpoint string `json:"Endpoint" example:"12.34.56.78"`
// The last time the peer initiated a session.
LastSessionStart *time.Time `json:"LastSessionStart" example:"2021-01-01T12:00:00Z"`
}
func NewPeerMetrics(src *domain.PeerStatus) *PeerMetrics {
return &PeerMetrics{
PeerIdentifier: string(src.PeerId),
IsPingable: src.IsPingable,
LastPing: src.LastPing,
BytesReceived: src.BytesReceived,
BytesTransmitted: src.BytesTransmitted,
LastHandshake: src.LastHandshake,
Endpoint: src.Endpoint,
LastSessionStart: src.LastSessionStart,
}
}
// InterfaceMetrics represents the metrics of a WireGuard interface.
type InterfaceMetrics struct {
// The unique identifier of the interface.
InterfaceIdentifier string `json:"InterfaceIdentifier" example:"wg0"`
// The number of bytes received by the interface.
BytesReceived uint64 `json:"BytesReceived" example:"123456789"`
// The number of bytes transmitted by the interface.
BytesTransmitted uint64 `json:"BytesTransmitted" example:"123456789"`
}
func NewInterfaceMetrics(src *domain.InterfaceStatus) *InterfaceMetrics {
return &InterfaceMetrics{
InterfaceIdentifier: string(src.InterfaceId),
BytesReceived: src.BytesReceived,
BytesTransmitted: src.BytesTransmitted,
}
}
// UserMetrics represents the metrics of a WireGuard user.
type UserMetrics struct {
// The unique identifier of the user.
UserIdentifier string `json:"UserIdentifier" example:"uid-1234567"`
// PeerCount represents the number of peers linked to the user.
PeerCount int `json:"PeerCount" example:"2"`
// The total number of bytes received by the user. This is the sum of all bytes received by the peers linked to the user.
BytesReceived uint64 `json:"BytesReceived" example:"123456789"`
// The total number of bytes transmitted by the user. This is the sum of all bytes transmitted by the peers linked to the user.
BytesTransmitted uint64 `json:"BytesTransmitted" example:"123456789"`
// PeerMetrics represents the metrics of the peers linked to the user.
PeerMetrics []PeerMetrics `json:"PeerMetrics"`
}
func NewUserMetrics(srcUser *domain.User, src []domain.PeerStatus) *UserMetrics {
if srcUser == nil {
return nil
}
um := &UserMetrics{
UserIdentifier: string(srcUser.Identifier),
PeerCount: srcUser.LinkedPeerCount,
PeerMetrics: []PeerMetrics{},
BytesReceived: 0,
BytesTransmitted: 0,
}
peerMetrics := make([]PeerMetrics, len(src))
for i, peer := range src {
peerMetrics[i] = *NewPeerMetrics(&peer)
um.BytesReceived += peer.BytesReceived
um.BytesTransmitted += peer.BytesTransmitted
}
um.PeerMetrics = peerMetrics
return um
}