feat(network): implement peer discovery features and UI integration
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s
Added functionality for peer discovery, including new API endpoints for listing, approving, and rejecting discovered peers. Updated the network queries and settings to support peer discovery configurations. Enhanced the UI to display discovered peers and integrated related settings in the tenant settings component. Updated OpenAPI documentation to reflect the new endpoints and parameters. This improves the network management capabilities by allowing dynamic peer discovery and management.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerPeerDiscoveryRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /peers/discovered", s.handleListPeerDiscoveries)
|
||||
m.HandleFunc("POST /peers/discovered/{id}/approve", s.handleApprovePeerDiscovery)
|
||||
m.HandleFunc("POST /peers/discovered/{id}/reject", s.handleRejectPeerDiscovery)
|
||||
}
|
||||
|
||||
func peerDiscoveryJSON(d *store.BGPPeerDiscovery) map[string]any {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]any{
|
||||
"id": d.ID,
|
||||
"neighbor_id": d.NeighborID,
|
||||
"neighbor": d.Neighbor,
|
||||
"remote_asn": d.RemoteASN,
|
||||
"protocol_name": d.ProtocolName,
|
||||
"session_state": d.SessionState,
|
||||
"status": d.Status,
|
||||
"first_seen_at": d.FirstSeenAt.UTC().Format(time.RFC3339Nano),
|
||||
"last_seen_at": d.LastSeenAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if d.SpeakerID != "" {
|
||||
m["speaker_id"] = d.SpeakerID
|
||||
} else {
|
||||
m["speaker_id"] = nil
|
||||
}
|
||||
if d.ApprovedPeerID != "" {
|
||||
m["approved_peer_id"] = d.ApprovedPeerID
|
||||
} else {
|
||||
m["approved_peer_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// syncPeerDiscoveriesFromLive upserts pending discoveries from dynamic BGP sessions.
|
||||
func (s *Server) syncPeerDiscoveriesFromLive(tenantID string, views []speakerBGPLive) {
|
||||
if s.store == nil || tenantID == "" {
|
||||
return
|
||||
}
|
||||
knownNeighbors := map[string]struct{}{}
|
||||
for _, p := range s.store.ListPeers(tenantID) {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if n, ok := store.NormalizePeerNeighborString(p.Neighbor); ok {
|
||||
knownNeighbors[n] = struct{}{}
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, v := range views {
|
||||
for _, sess := range v.Sessions {
|
||||
if !birdfmt.IsDynamicDiscoverySession(sess.Name) {
|
||||
continue
|
||||
}
|
||||
neighbor, ok := store.NormalizePeerNeighborString(sess.Neighbor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, known := knownNeighbors[neighbor]; known {
|
||||
continue
|
||||
}
|
||||
_, _ = s.store.UpsertPeerDiscovery(tenantID, &store.PeerDiscoveryUpsert{
|
||||
SpeakerID: v.SpeakerID,
|
||||
NeighborID: strings.TrimSpace(sess.NeighborID),
|
||||
Neighbor: neighbor,
|
||||
RemoteASN: sess.NeighborAS,
|
||||
ProtocolName: sess.Name,
|
||||
SessionState: sess.State,
|
||||
SeenAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListPeerDiscoveries(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
if status == "" {
|
||||
status = store.PeerDiscoveryPending
|
||||
}
|
||||
// Refresh live discovery when listing pending.
|
||||
if status == store.PeerDiscoveryPending || status == "all" {
|
||||
views := s.collectSpeakerBGPLive(r.Context(), a.TenantID, true)
|
||||
s.syncPeerDiscoveriesFromLive(a.TenantID, views)
|
||||
}
|
||||
listStatus := status
|
||||
if status == "all" {
|
||||
listStatus = ""
|
||||
}
|
||||
items, err := s.store.ListPeerDiscoveries(a.TenantID, listStatus)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, d := range items {
|
||||
out = append(out, peerDiscoveryJSON(d))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleApprovePeerDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
BGPSpeakerID *string `json:"bgp_speaker_id"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
peer, disc, err := s.store.ApprovePeerDiscovery(a.TenantID, r.PathValue("id"), &store.PeerDiscoveryApproveInput{
|
||||
Name: body.Name,
|
||||
SpeakerID: body.BGPSpeakerID,
|
||||
Enabled: body.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.enqueuePeerReconcile(a.TenantID, "peer_discovery_approve")
|
||||
s.recordCRUDAudit(r, a, "bgp.peer.discovery.approve", "Approved discovered peer "+peer.Neighbor, peer.ID, map[string]any{
|
||||
"peer_id": peer.ID,
|
||||
"discovery_id": disc.ID,
|
||||
"neighbor": peer.Neighbor,
|
||||
"neighbor_id": disc.NeighborID,
|
||||
"remote_asn": peer.RemoteASN,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"peer": peerJSON(peer),
|
||||
"discovery": peerDiscoveryJSON(disc),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRejectPeerDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
disc, err := s.store.RejectPeerDiscovery(a.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.peer.discovery.reject", "Rejected discovered peer "+disc.Neighbor, disc.ID, map[string]any{
|
||||
"discovery_id": disc.ID,
|
||||
"neighbor": disc.Neighbor,
|
||||
"neighbor_id": disc.NeighborID,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, peerDiscoveryJSON(disc))
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func TestPeerDiscoveryApproveReject(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, speaker := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|operator")
|
||||
h := srv.Handler()
|
||||
|
||||
disc, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
|
||||
SpeakerID: speaker,
|
||||
NeighborID: "203.0.113.10",
|
||||
Neighbor: "203.0.113.10",
|
||||
RemoteASN: 65099,
|
||||
ProtocolName: "evobgp_dyn_0001",
|
||||
SessionState: "Established",
|
||||
SeenAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/v1/peers/discovered?status=pending", nil)
|
||||
listReq.Header.Set("Authorization", "Bearer edkey")
|
||||
listRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status %d body %s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
|
||||
approveBody := `{"name":"client-a"}`
|
||||
approveReq := httptest.NewRequest(http.MethodPost, "/v1/peers/discovered/"+disc.ID+"/approve", strings.NewReader(approveBody))
|
||||
approveReq.Header.Set("Authorization", "Bearer edkey")
|
||||
approveReq.Header.Set("Content-Type", "application/json")
|
||||
approveRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(approveRec, approveReq)
|
||||
if approveRec.Code != http.StatusOK {
|
||||
t.Fatalf("approve status %d body %s", approveRec.Code, approveRec.Body.String())
|
||||
}
|
||||
var approveOut struct {
|
||||
Peer struct {
|
||||
ID string `json:"id"`
|
||||
Neighbor string `json:"neighbor"`
|
||||
Name string `json:"name"`
|
||||
} `json:"peer"`
|
||||
}
|
||||
if err := json.Unmarshal(approveRec.Body.Bytes(), &approveOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approveOut.Peer.Neighbor != "203.0.113.10" || approveOut.Peer.Name != "client-a" {
|
||||
t.Fatalf("unexpected peer: %+v", approveOut.Peer)
|
||||
}
|
||||
|
||||
peers := srv.Store().ListPeers(tenant)
|
||||
found := false
|
||||
for _, p := range peers {
|
||||
if p.ID == approveOut.Peer.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("approved peer not in ListPeers")
|
||||
}
|
||||
|
||||
disc2, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
|
||||
SpeakerID: speaker,
|
||||
NeighborID: "198.51.100.99",
|
||||
Neighbor: "198.51.100.99",
|
||||
RemoteASN: 65100,
|
||||
ProtocolName: "evobgp_dyn_0002",
|
||||
SessionState: "Active",
|
||||
SeenAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rejReq := httptest.NewRequest(http.MethodPost, "/v1/peers/discovered/"+disc2.ID+"/reject", nil)
|
||||
rejReq.Header.Set("Authorization", "Bearer edkey")
|
||||
rejRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rejRec, rejReq)
|
||||
if rejRec.Code != http.StatusOK {
|
||||
t.Fatalf("reject status %d body %s", rejRec.Code, rejRec.Body.String())
|
||||
}
|
||||
again, err := srv.Store().UpsertPeerDiscovery(tenant, &store.PeerDiscoveryUpsert{
|
||||
NeighborID: "198.51.100.99",
|
||||
Neighbor: "198.51.100.99",
|
||||
RemoteASN: 65100,
|
||||
ProtocolName: "evobgp_dyn_0002",
|
||||
SessionState: "Established",
|
||||
SeenAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Status != store.PeerDiscoveryRejected {
|
||||
t.Fatalf("expected rejected, got %s", again.Status)
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,9 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh)
|
||||
if fresh {
|
||||
s.syncPeerDiscoveriesFromLive(a.TenantID, liveViews)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, p := range page {
|
||||
row := peerJSON(p)
|
||||
|
||||
@@ -66,6 +66,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
|
||||
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
|
||||
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
|
||||
s.registerPeerDiscoveryRoutes(m)
|
||||
|
||||
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
||||
@@ -1259,10 +1260,27 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if peerDiscoverySettingsChanged(body) {
|
||||
s.enqueuePeerReconcile(a.TenantID, "peer_discovery_settings")
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.settings.update", "Updated tenant settings", a.TenantID, map[string]any{"keys": settingsAuditKeys(body)})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func peerDiscoverySettingsChanged(body map[string]any) bool {
|
||||
for _, k := range []string{
|
||||
"peer_discovery_enabled",
|
||||
"peer_discovery_ranges_v4",
|
||||
"peer_discovery_ranges_v6",
|
||||
"peer_discovery_require_external",
|
||||
} {
|
||||
if _, ok := body[k]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func settingsAuditKeys(body map[string]any) []string {
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user