Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a19c2a3f4 | ||
|
|
1c0d78b552 |
@@ -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
|
||||
|
||||
@@ -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: Успешно.
|
||||
|
||||
+15
-1
@@ -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 |
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -152,6 +152,12 @@ export type BgpCommunityPatch = Partial<BgpCommunityCreate>;
|
||||
export type CommunitiesResponse = Page<BgpCommunity>;
|
||||
|
||||
// ---- 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<PeerRow>;
|
||||
export type BgpPeerCreate = {
|
||||
|
||||
@@ -77,12 +77,50 @@
|
||||
{ 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 +241,22 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if column.id === 'session_state'}
|
||||
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<Badge variant={sessionBadge(p.session_state, p)}>{p.session_state || '—'}</Badge>
|
||||
<span
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
title={peerSessionHint(p) ?? peerConnectedLabel(p)}
|
||||
>
|
||||
{peerConnectedLabel(p)}
|
||||
</span>
|
||||
{#if peerSessionHint(p)}
|
||||
<span class="truncate text-xs text-destructive">{peerSessionHint(p)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.id === 'speaker'}
|
||||
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
|
||||
<span class="text-xs text-muted-foreground" title="Привязка в конфиге CP">
|
||||
{p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
|
||||
@@ -251,7 +302,7 @@
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="p-speaker" class="w-full">
|
||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
|
||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Все спикеры'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Не выбрано</SelectItem>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
async function loadPeers() {
|
||||
peersLoading = true;
|
||||
try {
|
||||
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200');
|
||||
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200&live=1');
|
||||
peers = pr.items;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
|
||||
Reference in New Issue
Block a user