From 1c0d78b552ce469bca626c945993e2603061b7d9 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 21 May 2026 15:18:21 +0700 Subject: [PATCH] feat(api): enhance peer session management and documentation - Added new fields to the API for tracking connected speakers and session states across multiple nodes, including `connected_speaker_id`, `connected_speaker_label`, `session_on_speakers`, `established_on_speakers`, and `session_mismatch`. - Implemented a new endpoint for retrieving bird protocol sessions, enhancing the agent server functionality. - Updated the OpenAPI documentation to reflect the new fields and query parameters, improving clarity for API consumers. - Modified the frontend to display connected speaker information and session states, providing better visibility into peer connections. - Updated deployment documentation to clarify the configuration requirements for enabling IP forwarding on VPS. --- .../docker-compose.remote-speaker.yaml | 4 +- docs/openapi.yaml | 43 +++ docs/remote-speakers.md | 16 +- internal/agentserver/server.go | 26 ++ internal/birdfmt/bgp_sessions.go | 48 ++++ internal/birdfmt/bgp_sessions_test.go | 26 ++ internal/birdfmt/peer_protocol.go | 16 ++ internal/httpapi/peers_live.go | 263 ++++++++++++++++++ internal/httpapi/peers_live_test.go | 67 +++++ internal/httpapi/routes.go | 20 +- internal/nodedispatch/bird.go | 69 +++++ internal/pipeline/refresh.go | 13 +- internal/store/speaker_meta.go | 11 +- web/src/lib/api/types.ts | 11 + .../network/NetworkPeersCard.svelte | 62 ++++- web/src/routes/network/+page.svelte | 2 +- 16 files changed, 659 insertions(+), 38 deletions(-) create mode 100644 internal/birdfmt/bgp_sessions.go create mode 100644 internal/birdfmt/bgp_sessions_test.go create mode 100644 internal/birdfmt/peer_protocol.go create mode 100644 internal/httpapi/peers_live.go create mode 100644 internal/httpapi/peers_live_test.go create mode 100644 internal/nodedispatch/bird.go diff --git a/deploy/compose/docker-compose.remote-speaker.yaml b/deploy/compose/docker-compose.remote-speaker.yaml index fd336c7..30bd0e1 100644 --- a/deploy/compose/docker-compose.remote-speaker.yaml +++ b/deploy/compose/docker-compose.remote-speaker.yaml @@ -27,9 +27,7 @@ services: network_mode: host cap_add: - NET_ADMIN - sysctls: - net.ipv4.ip_forward: "1" - net.ipv6.conf.all.forwarding: "1" + # sysctls нельзя с network_mode: host — включите ip_forward на VPS (см. docs/remote-speakers.md) volumes: - bird_etc:/etc/bird - bird_run:/run/bird diff --git a/docs/openapi.yaml b/docs/openapi.yaml index fc8da57..30d3fdb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -755,6 +755,42 @@ components: bgp_speaker_id: type: ["string", "null"] description: "`null` - политика для всех спикеров." + connected_speaker_id: + type: ["string", "null"] + description: > + Live (GET /v1/peers?live=1): спикер, на котором сессия Established; опрос CP birdc + agent /v1/agent/bird/protocols. + connected_speaker_label: + type: string + description: Человекочитаемая метка ноды из live-опроса. + session_on_speakers: + type: array + description: Состояние протокола пира на каждой опрошенной ноде. + items: + type: object + properties: + speaker_id: + type: string + label: + type: string + state: + type: string + established_on_speakers: + type: array + description: Ноды, где сессия в состоянии Established (один пир может быть на нескольких). + items: + type: object + properties: + speaker_id: + type: string + label: + type: string + state: + type: string + session_mismatch: + type: boolean + description: > + true если bgp_speaker_id задан, но на этой ноде нет Established + (сессия может быть на других нодах — это не ошибка для tenant-wide пиров). policies_json: type: string description: > @@ -2156,6 +2192,13 @@ paths: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/SpeakerFilter" + - name: live + in: query + schema: + type: string + enum: ["1"] + description: > + Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*. responses: "200": description: Успешно. diff --git a/docs/remote-speakers.md b/docs/remote-speakers.md index e0d56df..5408aee 100644 --- a/docs/remote-speakers.md +++ b/docs/remote-speakers.md @@ -47,10 +47,22 @@ Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS | Порт | Кто | Зачем | |------|-----|-------| -| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch + health | +| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, **`GET /v1/agent/bird/protocols`** (live peer sessions) | | **179** | BGP peers | Data plane | | **80** | ACME | Traefik → 443 | +## Подготовка VPS (перед `docker compose up`) + +`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**: + +```bash +sysctl -w net.ipv4.ip_forward=1 +sysctl -w net.ipv6.conf.all.forwarding=1 +echo 'net.ipv4.ip_forward=1' | tee /etc/sysctl.d/99-evobgp-bird.conf +echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.d/99-evobgp-bird.conf +sysctl --system +``` + ## Безопасность (три участка) 1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`. @@ -82,6 +94,8 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес | Симптом | Проверка | |---------|----------| +| `sysctl net.ipv4.ip_forward not allowed in host network` | Уберите sysctls из compose (уже так в main); включите ip_forward на VPS (см. выше) | +| `no service selected` | `--profile production` или `COMPOSE_PROFILES=production` | | Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist | | dispatch error | CP logs job meta; firewall 443; `agent_secret` | | verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed | diff --git a/internal/agentserver/server.go b/internal/agentserver/server.go index f6dfa24..75d0b95 100644 --- a/internal/agentserver/server.go +++ b/internal/agentserver/server.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "evobgp/internal/birdfmt" "evobgp/internal/nodecli" ) @@ -41,6 +42,7 @@ type Server struct { func New(cfg Config) *Server { s := &Server{cfg: cfg, mux: http.NewServeMux()} s.mux.HandleFunc("GET /v1/agent/health", s.handleHealth) + s.mux.HandleFunc("GET /v1/agent/bird/protocols", s.handleBirdProtocols) s.mux.HandleFunc("POST /v1/agent/sync", s.handleSync) return s } @@ -68,6 +70,30 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, body) } +func (s *Server) handleBirdProtocols(w http.ResponseWriter, r *http.Request) { + if !s.authorize(r) { + writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization") + return + } + sock := strings.TrimSpace(s.cfg.Socket) + if sock == "" { + writeProblem(w, http.StatusServiceUnavailable, "EVOBGP_BIRDC_SOCKET not configured") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(s.cfg.BirdcBin)) + if err != nil { + log.Printf("agentserver: bird protocols: %v", err) + writeProblem(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "sessions": birdfmt.ParseBGPSessions(out), + }) +} + func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { if !s.authorize(r) { writeProblem(w, http.StatusUnauthorized, "missing or invalid Authorization") diff --git a/internal/birdfmt/bgp_sessions.go b/internal/birdfmt/bgp_sessions.go new file mode 100644 index 0000000..48a2540 --- /dev/null +++ b/internal/birdfmt/bgp_sessions.go @@ -0,0 +1,48 @@ +package birdfmt + +import ( + "strings" +) + +// BGPSession is one BGP protocol block from `birdc show protocols all`. +type BGPSession struct { + Name string `json:"name"` + Neighbor string `json:"neighbor,omitempty"` + State string `json:"state"` +} + +// ParseBGPSessions extracts BGP protocol name, state, and neighbor (if present) from birdc output. +func ParseBGPSessions(output string) []BGPSession { + var out []BGPSession + var cur *BGPSession + for _, raw := range strings.Split(output, "\n") { + line := strings.TrimRight(raw, "\r") + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + low := strings.ToLower(trim) + if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") { + continue + } + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && isBGPProtocolSummaryRow(trim) { + fields := strings.Fields(trim) + state := extractBGPSessionStateLine(trim) + if state == "" && len(fields) >= 4 { + state = fields[3] + } + cur = &BGPSession{Name: fields[0], State: state} + out = append(out, *cur) + cur = &out[len(out)-1] + continue + } + if cur == nil { + continue + } + const neighborPrefix = "Neighbor address:" + if idx := strings.Index(trim, neighborPrefix); idx >= 0 { + cur.Neighbor = strings.TrimSpace(trim[idx+len(neighborPrefix):]) + } + } + return out +} diff --git a/internal/birdfmt/bgp_sessions_test.go b/internal/birdfmt/bgp_sessions_test.go new file mode 100644 index 0000000..d954c9c --- /dev/null +++ b/internal/birdfmt/bgp_sessions_test.go @@ -0,0 +1,26 @@ +package birdfmt + +import "testing" + +func TestParseBGPSessions_neighborAndState(t *testing.T) { + sample := ` +BIRD 2.14 ready. +Name Proto Table State Since Info +device1 Device --- up 10:00:00 +evobgp_p_abc123 BGP master4 up 10:00:05 Established + Neighbor address: 198.51.100.2 + Neighbor AS: 65001 +evobgp_p_def456 BGP master4 up 10:00:06 Active + Neighbor address: 2001:db8::2 +` + sessions := ParseBGPSessions(sample) + if len(sessions) != 2 { + t.Fatalf("got %d sessions want 2", len(sessions)) + } + if sessions[0].Name != "evobgp_p_abc123" || sessions[0].State != "Established" || sessions[0].Neighbor != "198.51.100.2" { + t.Fatalf("session0: %+v", sessions[0]) + } + if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" { + t.Fatalf("session1: %+v", sessions[1]) + } +} diff --git a/internal/birdfmt/peer_protocol.go b/internal/birdfmt/peer_protocol.go new file mode 100644 index 0000000..451c1c9 --- /dev/null +++ b/internal/birdfmt/peer_protocol.go @@ -0,0 +1,16 @@ +package birdfmt + +import "strings" + +// PeerProtocolName returns the BIRD protocol name for a control-plane peer UUID. +// Must stay in sync with pipeline peer rendering. +func PeerProtocolName(peerID string) string { + s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "") + if len(s) > 16 { + s = s[:16] + } + if s == "" { + s = "x" + } + return "evobgp_p_" + s +} diff --git a/internal/httpapi/peers_live.go b/internal/httpapi/peers_live.go new file mode 100644 index 0000000..9bb4639 --- /dev/null +++ b/internal/httpapi/peers_live.go @@ -0,0 +1,263 @@ +package httpapi + +import ( + "context" + "net/netip" + "os" + "strings" + "sync" + "time" + + "evobgp/internal/birdfmt" + "evobgp/internal/nodedispatch" + "evobgp/internal/store" +) + +const peerLiveCacheTTL = 15 * time.Second + +type speakerBGPLive struct { + SpeakerID string + Label string + Sessions []birdfmt.BGPSession + Error string +} + +type peerLiveCacheEntry struct { + at time.Time + views []speakerBGPLive +} + +var peerLiveCache sync.Map // tenantID -> peerLiveCacheEntry + +type peerSessionOnSpeaker struct { + SpeakerID string `json:"speaker_id"` + Label string `json:"label"` + State string `json:"state"` +} + +func speakerDisplayLabel(sp *store.Speaker) string { + if sp == nil { + return "" + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + host := strings.TrimSpace(meta.AgentDomain) + if host == "" { + host = strings.TrimSpace(sp.Endpoint) + } + if strings.EqualFold(strings.TrimSpace(sp.Role), "master") { + if host != "" { + return "CP · " + host + } + return "CP (master)" + } + if host != "" { + return host + } + return sp.ID +} + +func masterSpeakerID(speakers []*store.Speaker) string { + for _, sp := range speakers { + if sp != nil && strings.EqualFold(strings.TrimSpace(sp.Role), "master") { + return sp.ID + } + } + return "" +} + +func (s *Server) collectSpeakerBGPLive(ctx context.Context, tenantID string, fresh bool) []speakerBGPLive { + if !fresh { + if v, ok := peerLiveCache.Load(tenantID); ok { + ent := v.(peerLiveCacheEntry) + if time.Since(ent.at) < peerLiveCacheTTL { + return ent.views + } + } + } + speakers := s.store.ListSpeakersForTenant(tenantID) + views := make([]speakerBGPLive, 0, len(speakers)+1) + + if sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")); sock != "" { + v := speakerBGPLive{Label: "CP (local BIRD)"} + if mid := masterSpeakerID(speakers); mid != "" { + v.SpeakerID = mid + for _, sp := range speakers { + if sp != nil && sp.ID == mid { + v.Label = speakerDisplayLabel(sp) + break + } + } + } + out, err := birdfmt.ShowProtocols(ctx, sock, strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN"))) + if err != nil { + v.Error = err.Error() + } else { + v.Sessions = birdfmt.ParseBGPSessions(out) + } + views = append(views, v) + } + + opts := nodedispatch.Options{Timeout: 8 * time.Second} + type resWrap struct { + sp *store.Speaker + res nodedispatch.BirdProtocolsResult + } + ch := make(chan resWrap, len(speakers)) + var wg sync.WaitGroup + for _, sp := range speakers { + if sp == nil { + continue + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) { + continue + } + wg.Add(1) + go func(speaker *store.Speaker) { + defer wg.Done() + ch <- resWrap{ + sp: speaker, + res: nodedispatch.FetchBirdProtocols(ctx, speaker, opts), + } + }(sp) + } + wg.Wait() + close(ch) + for rw := range ch { + views = append(views, speakerBGPLive{ + SpeakerID: rw.sp.ID, + Label: speakerDisplayLabel(rw.sp), + Sessions: rw.res.Sessions, + Error: rw.res.Error, + }) + } + + peerLiveCache.Store(tenantID, peerLiveCacheEntry{at: time.Now(), views: views}) + return views +} + +func matchPeerOnSpeakers(peer *store.BGPPeer, views []speakerBGPLive) ( + bestState string, + connectedID string, + connectedLabel string, + establishedOn []peerSessionOnSpeaker, + on []peerSessionOnSpeaker, + mismatch bool, +) { + if peer == nil { + return "", "", "", nil, nil, false + } + neighbor, hasNeighbor := store.ParsePeerNeighbor(peer.Neighbor) + protoName := birdfmt.PeerProtocolName(peer.ID) + + for _, v := range views { + if v.Error != "" && len(v.Sessions) == 0 { + continue + } + for _, sess := range v.Sessions { + if !peerSessionMatches(sess, protoName, neighbor, hasNeighbor) { + continue + } + hit := peerSessionOnSpeaker{ + SpeakerID: v.SpeakerID, + Label: v.Label, + State: sess.State, + } + on = append(on, hit) + if strings.EqualFold(strings.TrimSpace(sess.State), "Established") { + establishedOn = append(establishedOn, hit) + } + if bestState == "" || sessionStateRank(sess.State) > sessionStateRank(bestState) { + bestState = sess.State + } + } + } + + if len(establishedOn) > 0 { + bestState = "Established" + labels := make([]string, 0, len(establishedOn)) + for _, e := range establishedOn { + labels = append(labels, e.Label) + } + connectedLabel = strings.Join(labels, ", ") + if len(establishedOn) == 1 { + connectedID = establishedOn[0].SpeakerID + } + } else if len(on) == 1 { + connectedID = on[0].SpeakerID + connectedLabel = on[0].Label + } + + if peer.SpeakerID != nil && strings.TrimSpace(*peer.SpeakerID) != "" && len(establishedOn) > 0 { + want := strings.TrimSpace(*peer.SpeakerID) + found := false + for _, e := range establishedOn { + if strings.EqualFold(strings.TrimSpace(e.SpeakerID), want) { + found = true + break + } + } + mismatch = !found + } + return bestState, connectedID, connectedLabel, establishedOn, on, mismatch +} + +func peerSessionMatches(sess birdfmt.BGPSession, protoName string, neighbor netip.Addr, hasNeighbor bool) bool { + if strings.EqualFold(strings.TrimSpace(sess.Name), protoName) { + return true + } + if !hasNeighbor || strings.TrimSpace(sess.Neighbor) == "" { + return false + } + addr, err := netip.ParseAddr(strings.TrimSpace(sess.Neighbor)) + if err != nil { + return false + } + return addr == neighbor +} + +func sessionStateRank(state string) int { + switch strings.ToLower(strings.TrimSpace(state)) { + case "established": + return 100 + case "openconfirm", "opensent": + return 80 + case "active", "connect": + return 60 + case "idle": + return 20 + default: + return 10 + } +} + +func applyPeerLiveFields(row map[string]any, peer *store.BGPPeer, views []speakerBGPLive) { + state, connID, connLabel, establishedOn, on, mismatch := matchPeerOnSpeakers(peer, views) + if len(on) > 0 { + if state != "" { + row["session_state"] = state + } + row["connected_speaker_id"] = peerLiveSpeakerIDOrNull(connID) + row["connected_speaker_label"] = connLabel + row["session_on_speakers"] = on + if len(establishedOn) > 0 { + row["established_on_speakers"] = establishedOn + } else { + row["established_on_speakers"] = []peerSessionOnSpeaker{} + } + row["session_conflict"] = false + row["session_mismatch"] = mismatch + return + } + row["session_on_speakers"] = []peerSessionOnSpeaker{} + row["established_on_speakers"] = []peerSessionOnSpeaker{} + row["session_conflict"] = false + row["session_mismatch"] = false +} + +func peerLiveSpeakerIDOrNull(id string) any { + if strings.TrimSpace(id) == "" { + return nil + } + return id +} diff --git a/internal/httpapi/peers_live_test.go b/internal/httpapi/peers_live_test.go new file mode 100644 index 0000000..e3e1b27 --- /dev/null +++ b/internal/httpapi/peers_live_test.go @@ -0,0 +1,67 @@ +package httpapi + +import ( + "testing" + + "evobgp/internal/birdfmt" + "evobgp/internal/store" +) + +func TestMatchPeerOnSpeakers_establishedOnReplica(t *testing.T) { + peer := &store.BGPPeer{ + ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + Neighbor: "198.51.100.2", + } + views := []speakerBGPLive{ + { + SpeakerID: "master-id", + Label: "CP · bgp.shz.su", + Sessions: []birdfmt.BGPSession{ + {Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Idle"}, + }, + }, + { + SpeakerID: "replica-id", + Label: "bgp2.shz.su", + Sessions: []birdfmt.BGPSession{ + {Name: birdfmt.PeerProtocolName(peer.ID), Neighbor: "198.51.100.2", State: "Established"}, + }, + }, + } + state, connID, connLabel, established, on, mismatch := matchPeerOnSpeakers(peer, views) + if state != "Established" || connID != "replica-id" || connLabel != "bgp2.shz.su" { + t.Fatalf("got state=%q conn=%q label=%q", state, connID, connLabel) + } + if mismatch || len(on) != 2 || len(established) != 1 { + t.Fatalf("on=%+v established=%+v mismatch=%v", on, established, mismatch) + } +} + +func TestMatchPeerOnSpeakers_multipleEstablished(t *testing.T) { + peer := &store.BGPPeer{ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Neighbor: "198.51.100.2"} + views := []speakerBGPLive{ + {SpeakerID: "a", Label: "n1", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}}, + {SpeakerID: "b", Label: "n2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}}, + } + _, connID, label, established, _, mismatch := matchPeerOnSpeakers(peer, views) + if mismatch || connID != "" || label != "n1, n2" || len(established) != 2 { + t.Fatalf("connID=%q label=%q established=%+v mismatch=%v", connID, label, established, mismatch) + } +} + +func TestMatchPeerOnSpeakers_mismatchConfiguredSpeaker(t *testing.T) { + replica := "replica-id" + peer := &store.BGPPeer{ + ID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + Neighbor: "198.51.100.2", + SpeakerID: &replica, + } + views := []speakerBGPLive{ + {SpeakerID: "master-id", Label: "CP", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Established"}}}, + {SpeakerID: replica, Label: "bgp2", Sessions: []birdfmt.BGPSession{{Name: birdfmt.PeerProtocolName(peer.ID), State: "Idle"}}}, + } + _, _, _, _, _, mismatch := matchPeerOnSpeakers(peer, views) + if !mismatch { + t.Fatal("expected mismatch when configured replica has no Established") + } +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index c06d083..d9a57b4 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -283,13 +283,14 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) { } allPeers := s.store.ListPeers(a.TenantID) page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r)) - liveStates := s.liveBGPProtocolStates(r) + fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1") + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + liveViews := s.collectSpeakerBGPLive(ctx, a.TenantID, fresh) items := make([]map[string]any, 0, len(page)) for _, p := range page { row := peerJSON(p) - if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" { - row["session_state"] = strings.TrimSpace(st) - } + applyPeerLiveFields(row, p, liveViews) items = append(items, row) } writeJSON(w, http.StatusOK, map[string]any{ @@ -366,16 +367,9 @@ func extractBGPSessionState(line string) string { return "" } -// peerProtocolNameForID must stay in sync with pipeline peer protocol naming. +// peerProtocolNameForID forwards to birdfmt for tests and legacy callers. func peerProtocolNameForID(peerID string) string { - s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "") - if len(s) > 16 { - s = s[:16] - } - if s == "" { - s = "x" - } - return "evobgp_p_" + s + return birdfmt.PeerProtocolName(peerID) } func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) { diff --git a/internal/nodedispatch/bird.go b/internal/nodedispatch/bird.go new file mode 100644 index 0000000..a2271c1 --- /dev/null +++ b/internal/nodedispatch/bird.go @@ -0,0 +1,69 @@ +package nodedispatch + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "evobgp/internal/birdfmt" + "evobgp/internal/store" +) + +// BirdProtocolsResult is agent birdc scrape outcome. +type BirdProtocolsResult struct { + SpeakerID string `json:"speaker_id,omitempty"` + Sessions []birdfmt.BGPSession `json:"sessions"` + Error string `json:"error,omitempty"` +} + +// FetchBirdProtocols GETs /v1/agent/bird/protocols on a replica agent. +func FetchBirdProtocols(ctx context.Context, sp *store.Speaker, opts Options) BirdProtocolsResult { + res := BirdProtocolsResult{} + if sp != nil { + res.SpeakerID = sp.ID + } + if sp == nil { + res.Error = "nil speaker" + return res + } + meta := store.ParseSpeakerMeta(sp.MetaJSON) + url := store.AgentBirdProtocolsURL(meta) + if url == "" { + res.Error = "agent_domain not configured" + return res + } + secret := strings.TrimSpace(meta.AgentSecret) + if secret == "" { + res.Error = "agent_secret missing" + return res + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + res.Error = err.Error() + return res + } + req.Header.Set("Authorization", "Bearer "+secret) + resp, err := opts.client().Do(req) + if err != nil { + res.Error = err.Error() + return res + } + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + res.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + return res + } + var out struct { + Sessions []birdfmt.BGPSession `json:"sessions"` + } + if err := json.Unmarshal(b, &out); err != nil { + res.Error = err.Error() + return res + } + res.Sessions = out.Sessions + return res +} diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index 3c41c49..7b716b3 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -1029,7 +1029,7 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals) } pol := parsePeerPolicies(p.PoliciesJSON) lv4, lv6, asn := effectivePeerLocals(loc, pol) - proto := peerProtocolName(p.ID) + proto := birdfmt.PeerProtocolName(p.ID) ra := uint32(p.RemoteASN) if addr.Is4() { opts := birdfmt.BGPPeerFromTemplateOptions{ @@ -1083,17 +1083,6 @@ func parsePeerPolicies(raw string) peerPolicyJSON { return pol } -func peerProtocolName(peerID string) string { - s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "") - if len(s) > 16 { - s = s[:16] - } - if s == "" { - s = "x" - } - return "evobgp_p_" + s -} - // buildExpandedBirdText concatenates bird.conf and the contents of each standard include (for UI / preview). func buildExpandedBirdText(main string, frags map[string]string) string { var b strings.Builder diff --git a/internal/store/speaker_meta.go b/internal/store/speaker_meta.go index 7cf35ff..97bc226 100644 --- a/internal/store/speaker_meta.go +++ b/internal/store/speaker_meta.go @@ -124,11 +124,20 @@ func AgentSyncURL(meta SpeakerMeta) string { // AgentHealthURL returns HTTPS health URL for agent_domain. func AgentHealthURL(meta SpeakerMeta) string { + return agentHTTPSURL(meta, "/v1/agent/health") +} + +// AgentBirdProtocolsURL returns HTTPS bird protocols URL for agent_domain. +func AgentBirdProtocolsURL(meta SpeakerMeta) string { + return agentHTTPSURL(meta, "/v1/agent/bird/protocols") +} + +func agentHTTPSURL(meta SpeakerMeta, path string) string { domain := strings.TrimSpace(meta.AgentDomain) if domain == "" { return "" } - return "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/health" + return "https://" + strings.TrimSuffix(domain, "/") + path } // SpeakerNeedsRemoteDispatch reports whether deploy_apply should wake this speaker via agent HTTP. diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 513118e..e798e1d 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -152,6 +152,12 @@ export type BgpCommunityPatch = Partial; export type CommunitiesResponse = Page; // ---- Peers ---- +export type PeerSessionOnSpeaker = { + speaker_id: string; + label: string; + state: string; +}; + export type PeerRow = { id: string; name?: string; @@ -160,6 +166,11 @@ export type PeerRow = { enabled?: boolean; session_state: string; bgp_speaker_id: string | null; + connected_speaker_id?: string | null; + connected_speaker_label?: string; + session_on_speakers?: PeerSessionOnSpeaker[]; + established_on_speakers?: PeerSessionOnSpeaker[]; + session_mismatch?: boolean; }; export type PeersResponse = Page; export type BgpPeerCreate = { diff --git a/web/src/lib/components/network/NetworkPeersCard.svelte b/web/src/lib/components/network/NetworkPeersCard.svelte index 921b333..46f283f 100644 --- a/web/src/lib/components/network/NetworkPeersCard.svelte +++ b/web/src/lib/components/network/NetworkPeersCard.svelte @@ -77,12 +77,47 @@ { id: 'actions', label: '', class: 'w-20' } ] as const; - function speakerLabelById(id: string | null | undefined) { - if (!id) return '—'; - return speakerById.get(id)?.endpoint ?? id; + function peerConnectedLabel(p: PeerRow): string { + const established = p.established_on_speakers ?? []; + if (established.length > 0) { + const labels = established.map((s) => s.label).join(', '); + return established.length === 1 + ? `Подключён: ${labels}` + : `Подключён (${established.length} нод): ${labels}`; + } + if (p.connected_speaker_label?.trim()) { + return `На ноде: ${p.connected_speaker_label.trim()}`; + } + if (p.session_on_speakers && p.session_on_speakers.length > 0) { + const parts = p.session_on_speakers.map((s) => `${s.label} (${s.state})`); + return parts.join(', '); + } + return 'Не найден на опрошенных нодах'; } - function sessionBadge(state: string) { + function peerSessionHint(p: PeerRow): string | null { + if (!p.session_mismatch || !p.bgp_speaker_id) return null; + const expected = speakerLabelById(p.bgp_speaker_id); + const actual = + p.established_on_speakers?.map((s) => s.label).join(', ') || + p.connected_speaker_label?.trim() || + 'другие ноды'; + return `В конфиге: ${expected}; Established на: ${actual}`; + } + + function speakerLabelById(id: string | null | undefined) { + if (!id) return 'Все спикеры'; + const s = speakerById.get(id); + if (!s) return id.slice(0, 8) + '…'; + if (s.role === 'master') { + const host = s.agent_domain ?? s.endpoint; + return host ? `CP · ${host}` : 'CP (master)'; + } + return s.agent_domain ?? s.endpoint ?? id.slice(0, 8) + '…'; + } + + function sessionBadge(state: string, p: PeerRow): 'default' | 'secondary' | 'destructive' | 'outline' { + if (p.session_mismatch) return 'destructive'; if (state === 'Established') return 'default'; if (state === 'Active' || state === 'Connect') return 'secondary'; return 'outline'; @@ -203,9 +238,22 @@ /> {:else if column.id === 'session_state'} - {p.session_state || '—'} +
+ {p.session_state || '—'} + + {peerConnectedLabel(p)} + + {#if peerSessionHint(p)} + {peerSessionHint(p)} + {/if} +
{:else if column.id === 'speaker'} - {speakerLabelById(p.bgp_speaker_id)} + + {p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'} + {:else if column.id === 'actions'}