mirror of https://github.com/h44z/wg-portal.git
				
				
				
			add webhook event for peer state change (#444)
new event types: connect and disconnect
example payload:
```json
{
  "event": "connect",
  "entity": "peer",
  "identifier": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
  "payload": {
    "PeerId": "Fb5TaziAs1WrPBjC/MFbWsIelVXvi0hDKZ3YQM9wmU8=",
    "IsConnected": true,
    "IsPingable": false,
    "LastPing": null,
    "BytesReceived": 1860,
    "BytesTransmitted": 10824,
    "LastHandshake": "2025-06-26T23:04:33.325216659+02:00",
    "Endpoint": "10.55.66.77:33874",
    "LastSessionStart": "2025-06-26T22:50:40.10221606+02:00"
  }
}
```
			
			
This commit is contained in:
		
							parent
							
								
									3a732fd3e5
								
							
						
					
					
						commit
						3c72a26e91
					
				|  | @ -133,5 +133,5 @@ func (m *MetricsServer) UpdatePeerMetrics(peer *domain.Peer, status domain.PeerS | ||||||
| 	} | 	} | ||||||
| 	m.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived)) | 	m.peerReceivedBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesReceived)) | ||||||
| 	m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted)) | 	m.peerSendBytesTotal.WithLabelValues(labels...).Set(float64(status.BytesTransmitted)) | ||||||
| 	m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected())) | 	m.peerIsConnected.WithLabelValues(labels...).Set(internal.BoolToFloat64(status.IsConnected)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -198,7 +198,7 @@ func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats { | ||||||
| 
 | 
 | ||||||
| 	for _, srcStat := range src { | 	for _, srcStat := range src { | ||||||
| 		stats[string(srcStat.PeerId)] = PeerStatData{ | 		stats[string(srcStat.PeerId)] = PeerStatData{ | ||||||
| 			IsConnected:      srcStat.IsConnected(), | 			IsConnected:      srcStat.IsConnected, | ||||||
| 			IsPingable:       srcStat.IsPingable, | 			IsPingable:       srcStat.IsPingable, | ||||||
| 			LastPing:         srcStat.LastPing, | 			LastPing:         srcStat.LastPing, | ||||||
| 			BytesReceived:    srcStat.BytesReceived, | 			BytesReceived:    srcStat.BytesReceived, | ||||||
|  |  | ||||||
|  | @ -36,6 +36,7 @@ const TopicPeerDeleted = "peer:deleted" | ||||||
| const TopicPeerUpdated = "peer:updated" | const TopicPeerUpdated = "peer:updated" | ||||||
| const TopicPeerInterfaceUpdated = "peer:interface:updated" | const TopicPeerInterfaceUpdated = "peer:interface:updated" | ||||||
| const TopicPeerIdentifierUpdated = "peer:identifier:updated" | const TopicPeerIdentifierUpdated = "peer:identifier:updated" | ||||||
|  | const TopicPeerStateChanged = "peer:state:changed" | ||||||
| 
 | 
 | ||||||
| // endregion peer-events
 | // endregion peer-events
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -64,6 +64,7 @@ func (m Manager) connectToMessageBus() { | ||||||
| 	_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent) | 	_ = m.bus.Subscribe(app.TopicPeerCreated, m.handlePeerCreateEvent) | ||||||
| 	_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent) | 	_ = m.bus.Subscribe(app.TopicPeerUpdated, m.handlePeerUpdateEvent) | ||||||
| 	_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent) | 	_ = m.bus.Subscribe(app.TopicPeerDeleted, m.handlePeerDeleteEvent) | ||||||
|  | 	_ = m.bus.Subscribe(app.TopicPeerStateChanged, m.handlePeerStateChangeEvent) | ||||||
| 
 | 
 | ||||||
| 	_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent) | 	_ = m.bus.Subscribe(app.TopicInterfaceCreated, m.handleInterfaceCreateEvent) | ||||||
| 	_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent) | 	_ = m.bus.Subscribe(app.TopicInterfaceUpdated, m.handleInterfaceUpdateEvent) | ||||||
|  | @ -135,6 +136,14 @@ func (m Manager) handleInterfaceDeleteEvent(iface domain.Interface) { | ||||||
| 	m.handleGenericEvent(WebhookEventDelete, iface) | 	m.handleGenericEvent(WebhookEventDelete, iface) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (m Manager) handlePeerStateChangeEvent(peerStatus domain.PeerStatus) { | ||||||
|  | 	if peerStatus.IsConnected { | ||||||
|  | 		m.handleGenericEvent(WebhookEventConnect, peerStatus) | ||||||
|  | 	} else { | ||||||
|  | 		m.handleGenericEvent(WebhookEventDisconnect, peerStatus) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (m Manager) handleGenericEvent(action WebhookEvent, payload any) { | func (m Manager) handleGenericEvent(action WebhookEvent, payload any) { | ||||||
| 	eventData, err := m.createWebhookData(action, payload) | 	eventData, err := m.createWebhookData(action, payload) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | @ -177,6 +186,9 @@ func (m Manager) createWebhookData(action WebhookEvent, payload any) (*WebhookDa | ||||||
| 	case domain.Interface: | 	case domain.Interface: | ||||||
| 		d.Entity = WebhookEntityInterface | 		d.Entity = WebhookEntityInterface | ||||||
| 		d.Identifier = string(v.Identifier) | 		d.Identifier = string(v.Identifier) | ||||||
|  | 	case domain.PeerStatus: | ||||||
|  | 		d.Entity = WebhookEntityPeer | ||||||
|  | 		d.Identifier = string(v.PeerId) | ||||||
| 	default: | 	default: | ||||||
| 		return nil, fmt.Errorf("unsupported payload type: %T", v) | 		return nil, fmt.Errorf("unsupported payload type: %T", v) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -42,7 +42,9 @@ const ( | ||||||
| type WebhookEvent = string | type WebhookEvent = string | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
| 	WebhookEventCreate WebhookEvent = "create" | 	WebhookEventCreate     WebhookEvent = "create" | ||||||
| 	WebhookEventUpdate WebhookEvent = "update" | 	WebhookEventUpdate     WebhookEvent = "update" | ||||||
| 	WebhookEventDelete WebhookEvent = "delete" | 	WebhookEventDelete     WebhookEvent = "delete" | ||||||
|  | 	WebhookEventConnect    WebhookEvent = "connect" | ||||||
|  | 	WebhookEventDisconnect WebhookEvent = "disconnect" | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | @ -43,6 +43,8 @@ type StatisticsMetricsServer interface { | ||||||
| type StatisticsEventBus interface { | type StatisticsEventBus interface { | ||||||
| 	// Subscribe subscribes to a topic
 | 	// Subscribe subscribes to a topic
 | ||||||
| 	Subscribe(topic string, fn interface{}) error | 	Subscribe(topic string, fn interface{}) error | ||||||
|  | 	// Publish sends a message to the message bus.
 | ||||||
|  | 	Publish(topic string, args ...any) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type StatisticsCollector struct { | type StatisticsCollector struct { | ||||||
|  | @ -55,6 +57,8 @@ type StatisticsCollector struct { | ||||||
| 	db StatisticsDatabaseRepo | 	db StatisticsDatabaseRepo | ||||||
| 	wg StatisticsInterfaceController | 	wg StatisticsInterfaceController | ||||||
| 	ms StatisticsMetricsServer | 	ms StatisticsMetricsServer | ||||||
|  | 
 | ||||||
|  | 	peerChangeEvent chan domain.PeerIdentifier | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewStatisticsCollector creates a new statistics collector.
 | // NewStatisticsCollector creates a new statistics collector.
 | ||||||
|  | @ -171,8 +175,12 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { | ||||||
| 					continue | 					continue | ||||||
| 				} | 				} | ||||||
| 				for _, peer := range peers { | 				for _, peer := range peers { | ||||||
|  | 					var connectionStateChanged bool | ||||||
|  | 					var newPeerStatus domain.PeerStatus | ||||||
| 					err = c.db.UpdatePeerStatus(ctx, peer.Identifier, | 					err = c.db.UpdatePeerStatus(ctx, peer.Identifier, | ||||||
| 						func(p *domain.PeerStatus) (*domain.PeerStatus, error) { | 						func(p *domain.PeerStatus) (*domain.PeerStatus, error) { | ||||||
|  | 							wasConnected := p.IsConnected | ||||||
|  | 
 | ||||||
| 							var lastHandshake *time.Time | 							var lastHandshake *time.Time | ||||||
| 							if !peer.LastHandshake.IsZero() { | 							if !peer.LastHandshake.IsZero() { | ||||||
| 								lastHandshake = &peer.LastHandshake | 								lastHandshake = &peer.LastHandshake | ||||||
|  | @ -186,6 +194,12 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { | ||||||
| 							p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server
 | 							p.BytesTransmitted = peer.BytesDownload // store bytes that where received from the peer and sent by the server
 | ||||||
| 							p.Endpoint = peer.Endpoint | 							p.Endpoint = peer.Endpoint | ||||||
| 							p.LastHandshake = lastHandshake | 							p.LastHandshake = lastHandshake | ||||||
|  | 							p.CalcConnected() | ||||||
|  | 
 | ||||||
|  | 							if wasConnected != p.IsConnected { | ||||||
|  | 								connectionStateChanged = true | ||||||
|  | 								newPeerStatus = *p // store new status for event publishing
 | ||||||
|  | 							} | ||||||
| 
 | 
 | ||||||
| 							// Update prometheus metrics
 | 							// Update prometheus metrics
 | ||||||
| 							go c.updatePeerMetrics(ctx, *p) | 							go c.updatePeerMetrics(ctx, *p) | ||||||
|  | @ -197,6 +211,11 @@ func (c *StatisticsCollector) collectPeerData(ctx context.Context) { | ||||||
| 					} else { | 					} else { | ||||||
| 						slog.Debug("updated peer status", "peer", peer.Identifier) | 						slog.Debug("updated peer status", "peer", peer.Identifier) | ||||||
| 					} | 					} | ||||||
|  | 
 | ||||||
|  | 					if connectionStateChanged { | ||||||
|  | 						// publish event if connection state changed
 | ||||||
|  | 						c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus) | ||||||
|  | 					} | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | @ -298,12 +317,17 @@ func (c *StatisticsCollector) enqueuePingChecks(ctx context.Context) { | ||||||
| func (c *StatisticsCollector) pingWorker(ctx context.Context) { | func (c *StatisticsCollector) pingWorker(ctx context.Context) { | ||||||
| 	defer c.pingWaitGroup.Done() | 	defer c.pingWaitGroup.Done() | ||||||
| 	for peer := range c.pingJobs { | 	for peer := range c.pingJobs { | ||||||
|  | 		var connectionStateChanged bool | ||||||
|  | 		var newPeerStatus domain.PeerStatus | ||||||
|  | 
 | ||||||
| 		peerPingable := c.isPeerPingable(ctx, peer) | 		peerPingable := c.isPeerPingable(ctx, peer) | ||||||
| 		slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable) | 		slog.Debug("peer ping check completed", "peer", peer.Identifier, "pingable", peerPingable) | ||||||
| 
 | 
 | ||||||
| 		now := time.Now() | 		now := time.Now() | ||||||
| 		err := c.db.UpdatePeerStatus(ctx, peer.Identifier, | 		err := c.db.UpdatePeerStatus(ctx, peer.Identifier, | ||||||
| 			func(p *domain.PeerStatus) (*domain.PeerStatus, error) { | 			func(p *domain.PeerStatus) (*domain.PeerStatus, error) { | ||||||
|  | 				wasConnected := p.IsConnected | ||||||
|  | 
 | ||||||
| 				if peerPingable { | 				if peerPingable { | ||||||
| 					p.IsPingable = true | 					p.IsPingable = true | ||||||
| 					p.LastPing = &now | 					p.LastPing = &now | ||||||
|  | @ -311,6 +335,13 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) { | ||||||
| 					p.IsPingable = false | 					p.IsPingable = false | ||||||
| 					p.LastPing = nil | 					p.LastPing = nil | ||||||
| 				} | 				} | ||||||
|  | 				p.UpdatedAt = time.Now() | ||||||
|  | 				p.CalcConnected() | ||||||
|  | 
 | ||||||
|  | 				if wasConnected != p.IsConnected { | ||||||
|  | 					connectionStateChanged = true | ||||||
|  | 					newPeerStatus = *p // store new status for event publishing
 | ||||||
|  | 				} | ||||||
| 
 | 
 | ||||||
| 				// Update prometheus metrics
 | 				// Update prometheus metrics
 | ||||||
| 				go c.updatePeerMetrics(ctx, *p) | 				go c.updatePeerMetrics(ctx, *p) | ||||||
|  | @ -322,6 +353,11 @@ func (c *StatisticsCollector) pingWorker(ctx context.Context) { | ||||||
| 		} else { | 		} else { | ||||||
| 			slog.Debug("updated peer ping status", "peer", peer.Identifier) | 			slog.Debug("updated peer ping status", "peer", peer.Identifier) | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		if connectionStateChanged { | ||||||
|  | 			// publish event if connection state changed
 | ||||||
|  | 			c.bus.Publish(app.TopicPeerStateChanged, newPeerStatus) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3,21 +3,23 @@ package domain | ||||||
| import "time" | import "time" | ||||||
| 
 | 
 | ||||||
| type PeerStatus struct { | type PeerStatus struct { | ||||||
| 	PeerId    PeerIdentifier `gorm:"primaryKey;column:identifier"` | 	PeerId    PeerIdentifier `gorm:"primaryKey;column:identifier" json:"PeerId"` | ||||||
| 	UpdatedAt time.Time      `gorm:"column:updated_at"` | 	UpdatedAt time.Time      `gorm:"column:updated_at" json:"-"` | ||||||
| 
 | 
 | ||||||
| 	IsPingable bool       `gorm:"column:pingable"` | 	IsConnected bool `gorm:"column:connected" json:"IsConnected"` // indicates if the peer is connected based on the last handshake or ping
 | ||||||
| 	LastPing   *time.Time `gorm:"column:last_ping"` |  | ||||||
| 
 | 
 | ||||||
| 	BytesReceived    uint64 `gorm:"column:received"` | 	IsPingable bool       `gorm:"column:pingable" json:"IsPingable"` | ||||||
| 	BytesTransmitted uint64 `gorm:"column:transmitted"` | 	LastPing   *time.Time `gorm:"column:last_ping" json:"LastPing"` | ||||||
| 
 | 
 | ||||||
| 	LastHandshake    *time.Time `gorm:"column:last_handshake"` | 	BytesReceived    uint64 `gorm:"column:received" json:"BytesReceived"` | ||||||
| 	Endpoint         string     `gorm:"column:endpoint"` | 	BytesTransmitted uint64 `gorm:"column:transmitted" json:"BytesTransmitted"` | ||||||
| 	LastSessionStart *time.Time `gorm:"column:last_session_start"` | 
 | ||||||
|  | 	LastHandshake    *time.Time `gorm:"column:last_handshake" json:"LastHandshake"` | ||||||
|  | 	Endpoint         string     `gorm:"column:endpoint" json:"Endpoint"` | ||||||
|  | 	LastSessionStart *time.Time `gorm:"column:last_session_start" json:"LastSessionStart"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s PeerStatus) IsConnected() bool { | func (s *PeerStatus) CalcConnected() { | ||||||
| 	oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected
 | 	oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected
 | ||||||
| 
 | 
 | ||||||
| 	handshakeValid := false | 	handshakeValid := false | ||||||
|  | @ -25,7 +27,7 @@ func (s PeerStatus) IsConnected() bool { | ||||||
| 		handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime) | 		handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return s.IsPingable || handshakeValid | 	s.IsConnected = s.IsPingable || handshakeValid | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type InterfaceStatus struct { | type InterfaceStatus struct { | ||||||
|  |  | ||||||
|  | @ -66,8 +66,9 @@ func TestPeerStatus_IsConnected(t *testing.T) { | ||||||
| 	} | 	} | ||||||
| 	for _, tt := range tests { | 	for _, tt := range tests { | ||||||
| 		t.Run(tt.name, func(t *testing.T) { | 		t.Run(tt.name, func(t *testing.T) { | ||||||
| 			if got := tt.status.IsConnected(); got != tt.want { | 			tt.status.CalcConnected() | ||||||
| 				t.Errorf("IsConnected() = %v, want %v", got, tt.want) | 			if got := tt.status.IsConnected; got != tt.want { | ||||||
|  | 				t.Errorf("IsConnected = %v, want %v", got, tt.want) | ||||||
| 			} | 			} | ||||||
| 		}) | 		}) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue