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

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:
Denozordec
2026-07-30 22:31:59 +07:00
parent fd3a217cbe
commit 5fbe9c9b56
29 changed files with 1831 additions and 26 deletions
+121
View File
@@ -0,0 +1,121 @@
package birdfmt
import (
"fmt"
"net/netip"
"strings"
)
// Parent protocol names for discovery listeners (not spawned session names).
const (
DiscoveryProtocolV4 = "evobgp_discover_v4"
DiscoveryProtocolV6 = "evobgp_discover_v6"
)
// DynamicBGPDiscoveryOptions configures quarantine dynamic BGP listeners.
type DynamicBGPDiscoveryOptions struct {
RangesV4 []string // CIDR prefixes
RangesV6 []string
RequireExternal bool // neighbor range … external (default true)
DynamicNameDigits int // default 4
}
// RenderDynamicBGPDiscovery renders passive dynamic BGP quarantine listeners.
// Spawned sessions use DynamicPeerProtocolPrefix ("evobgp_dyn_").
// Channel policy is always import none / export none (no announcements until approve).
func RenderDynamicBGPDiscovery(opts DynamicBGPDiscoveryOptions) (string, error) {
digits := opts.DynamicNameDigits
if digits <= 0 {
digits = 4
}
var parts []string
if len(opts.RangesV4) > 0 {
s, err := renderDynamicDiscoveryAF(DiscoveryProtocolV4, BGPTemplateNameV4, "ipv4", opts.RangesV4, opts.RequireExternal, digits)
if err != nil {
return "", err
}
parts = append(parts, s)
}
if len(opts.RangesV6) > 0 {
s, err := renderDynamicDiscoveryAF(DiscoveryProtocolV6, BGPTemplateNameV6, "ipv6", opts.RangesV6, opts.RequireExternal, digits)
if err != nil {
return "", err
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
return strings.Join(parts, "\n"), nil
}
func renderDynamicDiscoveryAF(protoName, templateName, af string, ranges []string, external bool, digits int) (string, error) {
var cleaned []string
for _, r := range ranges {
r = strings.TrimSpace(r)
if r == "" {
continue
}
pfx, err := netip.ParsePrefix(r)
if err != nil {
return "", fmt.Errorf("birdfmt: invalid discovery range %q: %w", r, err)
}
if af == "ipv4" && !pfx.Addr().Is4() {
return "", fmt.Errorf("birdfmt: discovery range %q is not IPv4", r)
}
if af == "ipv6" && !pfx.Addr().Is6() {
return "", fmt.Errorf("birdfmt: discovery range %q is not IPv6", r)
}
cleaned = append(cleaned, pfx.Masked().String())
}
if len(cleaned) == 0 {
return "", nil
}
var b strings.Builder
fmt.Fprintf(&b, "protocol bgp %s from %s {\n", protoName, templateName)
for _, cidr := range cleaned {
b.WriteString(" neighbor range ")
b.WriteString(cidr)
if external {
b.WriteString(" external")
}
b.WriteString(";\n")
}
fmt.Fprintf(&b, " dynamic name \"%s\";\n", DynamicPeerProtocolPrefix)
fmt.Fprintf(&b, " dynamic name digits %d;\n", digits)
b.WriteString(" multihop;\n")
b.WriteString(" passive;\n")
fmt.Fprintf(&b, " %s {\n", af)
b.WriteString(" import none;\n")
b.WriteString(" export none;\n")
b.WriteString(" };\n")
b.WriteString("}\n")
return b.String(), nil
}
// ParseDiscoveryRanges splits a settings string (comma / newline / space separated) into CIDRs.
func ParseDiscoveryRanges(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
raw = strings.ReplaceAll(raw, ",", " ")
raw = strings.ReplaceAll(raw, "\n", " ")
raw = strings.ReplaceAll(raw, ";", " ")
fields := strings.Fields(raw)
out := make([]string, 0, len(fields))
seen := map[string]struct{}{}
for _, f := range fields {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
out = append(out, f)
}
return out
}
+47
View File
@@ -0,0 +1,47 @@
package birdfmt
import (
"strings"
"testing"
)
func TestRenderDynamicBGPDiscovery_v4(t *testing.T) {
out, err := RenderDynamicBGPDiscovery(DynamicBGPDiscoveryOptions{
RangesV4: []string{"198.51.100.0/24", "203.0.113.0/24"},
RequireExternal: true,
})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"protocol bgp evobgp_discover_v4 from bgp_template",
"neighbor range 198.51.100.0/24 external;",
"neighbor range 203.0.113.0/24 external;",
`dynamic name "evobgp_dyn_";`,
"dynamic name digits 4;",
"multihop;",
"passive;",
"import none;",
"export none;",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
}
func TestRenderDynamicBGPDiscovery_invalid(t *testing.T) {
_, err := RenderDynamicBGPDiscovery(DynamicBGPDiscoveryOptions{
RangesV4: []string{"not-a-cidr"},
})
if err == nil {
t.Fatal("expected error")
}
}
func TestParseDiscoveryRanges(t *testing.T) {
got := ParseDiscoveryRanges("198.51.100.0/24, 203.0.113.0/24\n198.51.100.0/24")
if len(got) != 2 {
t.Fatalf("got %v", got)
}
}
+50 -10
View File
@@ -1,17 +1,28 @@
package birdfmt
import (
"strconv"
"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"`
Name string `json:"name"`
Neighbor string `json:"neighbor,omitempty"`
NeighborAS int64 `json:"neighbor_as,omitempty"`
NeighborID string `json:"neighbor_id,omitempty"`
State string `json:"state"`
}
// ParseBGPSessions extracts BGP protocol name, state, and neighbor (if present) from birdc output.
// DynamicPeerProtocolPrefix is the BIRD protocol name prefix for discovery-spawned sessions.
const DynamicPeerProtocolPrefix = "evobgp_dyn_"
// IsDynamicDiscoverySession reports whether the protocol was spawned by the discovery listener.
func IsDynamicDiscoverySession(name string) bool {
return strings.HasPrefix(strings.TrimSpace(name), DynamicPeerProtocolPrefix)
}
// ParseBGPSessions extracts BGP protocol name, state, neighbor, Neighbor AS, and Neighbor ID from birdc output.
func ParseBGPSessions(output string) []BGPSession {
var out []BGPSession
var cur *BGPSession
@@ -43,12 +54,41 @@ func ParseBGPSessions(output string) []BGPSession {
if cur == nil {
continue
}
for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
if idx := strings.Index(trim, prefix); idx >= 0 {
cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
break
}
}
parseBGPSessionDetailLine(cur, trim)
}
return out
}
func parseBGPSessionDetailLine(cur *BGPSession, trim string) {
for _, prefix := range []string{"Neighbor address:", "Neighbor Address:", "Neighbor:"} {
if idx := strings.Index(trim, prefix); idx >= 0 {
// Avoid matching "Neighbor AS:" / "Neighbor ID:" via bare "Neighbor:"
if prefix == "Neighbor:" {
rest := strings.TrimSpace(trim[idx+len(prefix):])
if strings.HasPrefix(strings.ToLower(rest), "as:") || strings.HasPrefix(strings.ToLower(rest), "id:") {
continue
}
if strings.Contains(strings.ToLower(trim), "neighbor as:") || strings.Contains(strings.ToLower(trim), "neighbor id:") {
continue
}
}
cur.Neighbor = strings.TrimSpace(trim[idx+len(prefix):])
return
}
}
for _, prefix := range []string{"Neighbor AS:", "Neighbor As:", "Neighbor as:"} {
if idx := strings.Index(trim, prefix); idx >= 0 {
raw := strings.TrimSpace(trim[idx+len(prefix):])
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
cur.NeighborAS = n
}
return
}
}
for _, prefix := range []string{"Neighbor ID:", "Neighbor Id:", "Neighbor id:", "BGP Identifier:", "BGP identifier:"} {
if idx := strings.Index(trim, prefix); idx >= 0 {
cur.NeighborID = strings.TrimSpace(trim[idx+len(prefix):])
return
}
}
}
+25 -2
View File
@@ -10,17 +10,40 @@ 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
Neighbor ID: 192.0.2.50
evobgp_p_def456 BGP master4 up 10:00:06 Active
Neighbor address: 2001:db8::2
evobgp_dyn_0001 BGP master4 up 10:00:07 Established
Neighbor address: 203.0.113.10
Neighbor AS: 65099
Neighbor ID: 203.0.113.10
`
sessions := ParseBGPSessions(sample)
if len(sessions) != 2 {
t.Fatalf("got %d sessions want 2", len(sessions))
if len(sessions) != 3 {
t.Fatalf("got %d sessions want 3", 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[0].NeighborAS != 65001 || sessions[0].NeighborID != "192.0.2.50" {
t.Fatalf("session0 ids: as=%d id=%q", sessions[0].NeighborAS, sessions[0].NeighborID)
}
if sessions[1].Neighbor != "2001:db8::2" || sessions[1].State != "Active" {
t.Fatalf("session1: %+v", sessions[1])
}
if !IsDynamicDiscoverySession(sessions[2].Name) {
t.Fatalf("session2 should be dynamic: %+v", sessions[2])
}
if sessions[2].NeighborAS != 65099 || sessions[2].NeighborID != "203.0.113.10" {
t.Fatalf("session2 ids: %+v", sessions[2])
}
}
func TestIsDynamicDiscoverySession(t *testing.T) {
if !IsDynamicDiscoverySession("evobgp_dyn_0001") {
t.Fatal("expected true")
}
if IsDynamicDiscoverySession("evobgp_p_abc") {
t.Fatal("expected false")
}
}
+1
View File
@@ -18,6 +18,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
"bgp_ipv4_peer",
"bgp_ipv6_peer",
"domains_resolved",
"dynamic_discovery",
"empty_static",
"filter_export",
"large_prefix_list",
@@ -0,0 +1,53 @@
# tags: dynamic, discovery, bgp
# Dynamic BGP discovery quarantine listener (neighbor range + import/export none).
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
filter evobgp_export_v4 {
if net ~ [ 203.0.113.0/24 ] then accept;
reject;
}
filter evobgp_export_v6 {
reject;
}
template bgp bgp_template {
local as 65001;
ipv4 {
import none;
export filter evobgp_export_v4;
};
hold time 90;
keepalive time 30;
}
template bgp bgp_template_v6 {
local as 65001;
ipv6 {
import none;
export filter evobgp_export_v6;
};
hold time 90;
keepalive time 30;
}
protocol bgp evobgp_discover_v4 from bgp_template {
neighbor range 198.51.100.0/24 external;
dynamic name "evobgp_dyn_";
dynamic name digits 4;
multihop;
passive;
ipv4 {
import none;
export none;
};
}
+171
View File
@@ -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))
}
+113
View File
@@ -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)
}
}
+3
View File
@@ -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)
+18
View File
@@ -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
+49
View File
@@ -940,6 +940,50 @@ func uint32FromSettingsMap(m map[string]any, key string) uint32 {
return 0
}
func boolFromSettingsMap(m map[string]any, key string, defaultVal bool) bool {
v, ok := m[key]
if !ok || v == nil {
return defaultVal
}
switch x := v.(type) {
case bool:
return x
case float64:
return x != 0
case int:
return x != 0
case string:
s := strings.ToLower(strings.TrimSpace(x))
if s == "true" || s == "1" || s == "yes" {
return true
}
if s == "false" || s == "0" || s == "no" {
return false
}
}
return defaultVal
}
func renderPeerDiscoveryBirdFragment(st store.Backend, tenantID string) (string, error) {
settings, err := st.ListGlobalSettings(tenantID)
if err != nil || settings == nil {
return "", nil
}
if !boolFromSettingsMap(settings, "peer_discovery_enabled", false) {
return "", nil
}
rangesV4 := birdfmt.ParseDiscoveryRanges(stringFromSettingsMap(settings, "peer_discovery_ranges_v4"))
rangesV6 := birdfmt.ParseDiscoveryRanges(stringFromSettingsMap(settings, "peer_discovery_ranges_v6"))
if len(rangesV4) == 0 && len(rangesV6) == 0 {
return "", nil
}
return birdfmt.RenderDynamicBGPDiscovery(birdfmt.DynamicBGPDiscoveryOptions{
RangesV4: rangesV4,
RangesV6: rangesV6,
RequireExternal: boolFromSettingsMap(settings, "peer_discovery_require_external", true),
})
}
func intFromSettingsMap(m map[string]any, key string) int {
v, ok := m[key]
if !ok || v == nil {
@@ -1004,6 +1048,11 @@ func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals)
peers := st.ListPeers(tenantID)
var parts []string
parts = append(parts, birdfmt.ManagedBanner("peers"))
if disc, err := renderPeerDiscoveryBirdFragment(st, tenantID); err != nil {
return "", err
} else if disc != "" {
parts = append(parts, disc)
}
for _, p := range peers {
if p == nil || !p.Enabled {
continue
@@ -0,0 +1,274 @@
package repository
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (p *Postgres) ListPeerDiscoveries(tenantID, status string) ([]*store.BGPPeerDiscovery, error) {
ctx := context.Background()
status = strings.TrimSpace(strings.ToLower(status))
q := `
SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
FROM bgp_peer_discovery WHERE tenant_id=$1`
args := []any{tenantID}
if status != "" {
q += ` AND status=$2`
args = append(args, status)
}
q += ` ORDER BY last_seen_at DESC`
rows, err := p.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*store.BGPPeerDiscovery
for rows.Next() {
d, err := scanPeerDiscovery(rows)
if err != nil {
continue
}
out = append(out, d)
}
return out, nil
}
func (p *Postgres) GetPeerDiscovery(tenantID, id string) (*store.BGPPeerDiscovery, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
FROM bgp_peer_discovery WHERE id=$1 AND tenant_id=$2`, id, tenantID)
d, err := scanPeerDiscovery(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return d, nil
}
type peerDiscoveryScanner interface {
Scan(dest ...any) error
}
func scanPeerDiscovery(row peerDiscoveryScanner) (*store.BGPPeerDiscovery, error) {
var d store.BGPPeerDiscovery
var first, last time.Time
err := row.Scan(
&d.ID, &d.TenantID, &d.SpeakerID, &d.NeighborID,
&d.Neighbor, &d.RemoteASN, &d.ProtocolName, &d.SessionState,
&d.Status, &first, &last, &d.ApprovedPeerID,
)
if err != nil {
return nil, err
}
d.FirstSeenAt = first.UTC()
d.LastSeenAt = last.UTC()
return &d, nil
}
func (p *Postgres) UpsertPeerDiscovery(tenantID string, in *store.PeerDiscoveryUpsert) (*store.BGPPeerDiscovery, error) {
if in == nil {
return nil, store.ErrInvalidInput
}
neighbor, ok := store.NormalizePeerNeighborString(in.Neighbor)
if !ok {
return nil, store.ErrInvalidInput
}
neighborID := strings.TrimSpace(in.NeighborID)
seenAt := in.SeenAt
if seenAt.IsZero() {
seenAt = time.Now().UTC()
}
ctx := context.Background()
var existingID string
if neighborID != "" {
_ = p.pool.QueryRow(ctx, `
SELECT id::text FROM bgp_peer_discovery
WHERE tenant_id=$1 AND neighbor_id=$2 LIMIT 1`, tenantID, neighborID).Scan(&existingID)
}
if existingID == "" {
_ = p.pool.QueryRow(ctx, `
SELECT id::text FROM bgp_peer_discovery
WHERE tenant_id=$1 AND neighbor=$2::inet AND remote_asn=$3 AND neighbor_id='' LIMIT 1`,
tenantID, neighbor, in.RemoteASN).Scan(&existingID)
}
if existingID != "" {
cur, err := p.GetPeerDiscovery(tenantID, existingID)
if err != nil {
return nil, err
}
if cur.Status == store.PeerDiscoveryRejected {
return cur, nil
}
var sp any
if s := strings.TrimSpace(in.SpeakerID); s != "" {
sp = s
}
_, err = p.pool.Exec(ctx, `
UPDATE bgp_peer_discovery SET
neighbor=$3::inet, remote_asn=$4,
neighbor_id=CASE WHEN $5 <> '' THEN $5 ELSE neighbor_id END,
protocol_name=$6, session_state=$7, last_seen_at=$8,
speaker_id=COALESCE($9::uuid, speaker_id),
updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND status <> 'rejected'`,
existingID, tenantID, neighbor, in.RemoteASN, neighborID,
strings.TrimSpace(in.ProtocolName), strings.TrimSpace(in.SessionState), seenAt, sp)
if err != nil {
return nil, err
}
return p.GetPeerDiscovery(tenantID, existingID)
}
id := uuid.NewString()
var sp any
if s := strings.TrimSpace(in.SpeakerID); s != "" {
sp = s
}
_, err := p.pool.Exec(ctx, `
INSERT INTO bgp_peer_discovery (
id, tenant_id, speaker_id, neighbor_id, neighbor, remote_asn,
protocol_name, session_state, status, first_seen_at, last_seen_at
) VALUES ($1,$2,$3::uuid,$4,$5::inet,$6,$7,$8,'pending',$9,$9)`,
id, tenantID, sp, neighborID, neighbor, in.RemoteASN,
strings.TrimSpace(in.ProtocolName), strings.TrimSpace(in.SessionState), seenAt)
if err != nil {
return nil, err
}
return p.GetPeerDiscovery(tenantID, id)
}
func (p *Postgres) ApprovePeerDiscovery(tenantID, id string, in *store.PeerDiscoveryApproveInput) (*store.BGPPeer, *store.BGPPeerDiscovery, error) {
ctx := context.Background()
tx, err := p.pool.Begin(ctx)
if err != nil {
return nil, nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
d, err := p.getPeerDiscoveryTx(ctx, tx, tenantID, id)
if err != nil {
return nil, nil, err
}
if d.Status != store.PeerDiscoveryPending {
return nil, nil, store.ErrInvalidInput
}
if d.RemoteASN == 0 {
return nil, nil, store.ErrInvalidInput
}
neighbor, ok := store.NormalizePeerNeighborString(d.Neighbor)
if !ok {
return nil, nil, store.ErrInvalidInput
}
name := ""
enabled := true
var speakerID *string
if in != nil {
name = strings.TrimSpace(in.Name)
if in.Enabled != nil {
enabled = *in.Enabled
}
if in.SpeakerID != nil {
v := strings.TrimSpace(*in.SpeakerID)
if v != "" {
speakerID = &v
}
}
}
if name == "" {
if d.NeighborID != "" {
name = "discovered-" + d.NeighborID
} else {
name = "discovered-" + neighbor
}
}
if speakerID == nil && strings.TrimSpace(d.SpeakerID) != "" {
sp := d.SpeakerID
speakerID = &sp
}
peerID := uuid.NewString()
meta, _ := json.Marshal(map[string]any{"name": name, "session_state": d.SessionState})
var sp any
if speakerID != nil {
sp = *speakerID
}
_, err = tx.Exec(ctx, `
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
VALUES ($1,$2,$3::uuid,$4::inet,$5,$6,'{}'::jsonb,$7::jsonb)`,
peerID, tenantID, sp, neighbor, d.RemoteASN, enabled, string(meta))
if err != nil {
return nil, nil, err
}
_, err = tx.Exec(ctx, `
UPDATE bgp_peer_discovery SET status='approved', approved_peer_id=$3::uuid, last_seen_at=now(), updated_at=now()
WHERE id=$1 AND tenant_id=$2`, id, tenantID, peerID)
if err != nil {
return nil, nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, nil, err
}
peer, err := p.GetPeer(tenantID, peerID)
if err != nil {
return nil, nil, err
}
disc, err := p.GetPeerDiscovery(tenantID, id)
if err != nil {
return nil, nil, err
}
return peer, disc, nil
}
func (p *Postgres) RejectPeerDiscovery(tenantID, id string) (*store.BGPPeerDiscovery, error) {
ctx := context.Background()
cur, err := p.GetPeerDiscovery(tenantID, id)
if err != nil {
return nil, err
}
if cur.Status == store.PeerDiscoveryApproved {
return nil, store.ErrInvalidInput
}
tag, err := p.pool.Exec(ctx, `
UPDATE bgp_peer_discovery SET status='rejected', last_seen_at=now(), updated_at=now()
WHERE id=$1 AND tenant_id=$2`, id, tenantID)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, store.ErrNotFound
}
return p.GetPeerDiscovery(tenantID, id)
}
func (p *Postgres) getPeerDiscoveryTx(ctx context.Context, tx pgx.Tx, tenantID, id string) (*store.BGPPeerDiscovery, error) {
row := tx.QueryRow(ctx, `
SELECT id::text, tenant_id::text, COALESCE(speaker_id::text,''), COALESCE(neighbor_id,''),
neighbor::text, remote_asn, COALESCE(protocol_name,''), COALESCE(session_state,''),
status, first_seen_at, last_seen_at, COALESCE(approved_peer_id::text,'')
FROM bgp_peer_discovery WHERE id=$1 AND tenant_id=$2 FOR UPDATE`, id, tenantID)
d, err := scanPeerDiscovery(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return d, nil
}
+7
View File
@@ -70,6 +70,13 @@ type Backend interface {
UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error)
DeletePeer(tenantID, id string) error
// Peer discovery (dynamic BGP quarantine → approve/reject).
ListPeerDiscoveries(tenantID, status string) ([]*BGPPeerDiscovery, error)
GetPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error)
UpsertPeerDiscovery(tenantID string, in *PeerDiscoveryUpsert) (*BGPPeerDiscovery, error)
ApprovePeerDiscovery(tenantID, id string, in *PeerDiscoveryApproveInput) (*BGPPeer, *BGPPeerDiscovery, error)
RejectPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error)
ListSpeakersForTenant(tenantID string) []*Speaker
GetSpeaker(tenantID, speakerID string) (*Speaker, error)
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
+3
View File
@@ -33,6 +33,8 @@ type Memory struct {
peers map[string]*BGPPeer
peerDiscoveries map[string]*BGPPeerDiscovery
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
@@ -143,6 +145,7 @@ func NewMemory() *Memory {
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
peerDiscoveries: make(map[string]*BGPPeerDiscovery),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
+213
View File
@@ -0,0 +1,213 @@
package store
import (
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) ListPeerDiscoveries(tenantID, status string) ([]*BGPPeerDiscovery, error) {
m.mu.RLock()
defer m.mu.RUnlock()
status = strings.TrimSpace(strings.ToLower(status))
var out []*BGPPeerDiscovery
for _, d := range m.peerDiscoveries {
if d == nil || d.TenantID != tenantID {
continue
}
if status != "" && !strings.EqualFold(d.Status, status) {
continue
}
out = append(out, copyPeerDiscovery(d))
}
sort.Slice(out, func(i, j int) bool {
return out[i].LastSeenAt.After(out[j].LastSeenAt)
})
return out, nil
}
func (m *Memory) GetPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error) {
m.mu.RLock()
defer m.mu.RUnlock()
d, ok := m.peerDiscoveries[id]
if !ok || d.TenantID != tenantID {
return nil, ErrNotFound
}
return copyPeerDiscovery(d), nil
}
func (m *Memory) UpsertPeerDiscovery(tenantID string, in *PeerDiscoveryUpsert) (*BGPPeerDiscovery, error) {
if in == nil {
return nil, ErrInvalidInput
}
neighbor, ok := NormalizePeerNeighborString(in.Neighbor)
if !ok {
return nil, ErrInvalidInput
}
neighborID := strings.TrimSpace(in.NeighborID)
seenAt := in.SeenAt
if seenAt.IsZero() {
seenAt = time.Now().UTC()
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
existing := m.findPeerDiscoveryLocked(tenantID, neighborID, neighbor, in.RemoteASN)
if existing != nil {
if existing.Status == PeerDiscoveryRejected {
return copyPeerDiscovery(existing), nil
}
if existing.Status == PeerDiscoveryApproved {
existing.SessionState = strings.TrimSpace(in.SessionState)
existing.ProtocolName = strings.TrimSpace(in.ProtocolName)
existing.LastSeenAt = seenAt
if sp := strings.TrimSpace(in.SpeakerID); sp != "" {
existing.SpeakerID = sp
}
return copyPeerDiscovery(existing), nil
}
existing.Neighbor = neighbor
existing.RemoteASN = in.RemoteASN
if neighborID != "" {
existing.NeighborID = neighborID
}
existing.ProtocolName = strings.TrimSpace(in.ProtocolName)
existing.SessionState = strings.TrimSpace(in.SessionState)
existing.LastSeenAt = seenAt
if sp := strings.TrimSpace(in.SpeakerID); sp != "" {
existing.SpeakerID = sp
}
return copyPeerDiscovery(existing), nil
}
id := uuid.NewString()
d := &BGPPeerDiscovery{
ID: id,
TenantID: tenantID,
SpeakerID: strings.TrimSpace(in.SpeakerID),
NeighborID: neighborID,
Neighbor: neighbor,
RemoteASN: in.RemoteASN,
ProtocolName: strings.TrimSpace(in.ProtocolName),
SessionState: strings.TrimSpace(in.SessionState),
Status: PeerDiscoveryPending,
FirstSeenAt: seenAt,
LastSeenAt: seenAt,
}
m.peerDiscoveries[id] = d
return copyPeerDiscovery(d), nil
}
func (m *Memory) ApprovePeerDiscovery(tenantID, id string, in *PeerDiscoveryApproveInput) (*BGPPeer, *BGPPeerDiscovery, error) {
m.mu.Lock()
defer m.mu.Unlock()
d, ok := m.peerDiscoveries[id]
if !ok || d.TenantID != tenantID {
return nil, nil, ErrNotFound
}
if d.Status != PeerDiscoveryPending {
return nil, nil, ErrInvalidInput
}
if d.RemoteASN == 0 {
return nil, nil, ErrInvalidInput
}
neighbor, okN := NormalizePeerNeighborString(d.Neighbor)
if !okN {
return nil, nil, ErrInvalidInput
}
name := ""
enabled := true
var speakerID *string
if in != nil {
name = strings.TrimSpace(in.Name)
if in.Enabled != nil {
enabled = *in.Enabled
}
if in.SpeakerID != nil {
v := strings.TrimSpace(*in.SpeakerID)
if v != "" {
speakerID = &v
}
}
}
if name == "" {
if d.NeighborID != "" {
name = "discovered-" + d.NeighborID
} else {
name = "discovered-" + neighbor
}
}
if speakerID == nil && strings.TrimSpace(d.SpeakerID) != "" {
sp := d.SpeakerID
speakerID = &sp
}
peerID := uuid.NewString()
peer := &BGPPeer{
ID: peerID,
TenantID: tenantID,
SpeakerID: speakerID,
Name: name,
Neighbor: neighbor,
RemoteASN: d.RemoteASN,
Enabled: enabled,
SessionState: d.SessionState,
PoliciesJSON: "{}",
}
m.peers[peerID] = peer
d.Status = PeerDiscoveryApproved
d.ApprovedPeerID = peerID
d.LastSeenAt = time.Now().UTC()
peerCopy := *peer
return &peerCopy, copyPeerDiscovery(d), nil
}
func (m *Memory) RejectPeerDiscovery(tenantID, id string) (*BGPPeerDiscovery, error) {
m.mu.Lock()
defer m.mu.Unlock()
d, ok := m.peerDiscoveries[id]
if !ok || d.TenantID != tenantID {
return nil, ErrNotFound
}
if d.Status == PeerDiscoveryApproved {
return nil, ErrInvalidInput
}
d.Status = PeerDiscoveryRejected
d.LastSeenAt = time.Now().UTC()
return copyPeerDiscovery(d), nil
}
func (m *Memory) findPeerDiscoveryLocked(tenantID, neighborID, neighbor string, asn int64) *BGPPeerDiscovery {
for _, d := range m.peerDiscoveries {
if d == nil || d.TenantID != tenantID {
continue
}
if neighborID != "" && d.NeighborID == neighborID {
return d
}
if neighborID == "" && d.NeighborID == "" && d.Neighbor == neighbor && d.RemoteASN == asn {
return d
}
if neighborID != "" && d.NeighborID == "" && d.Neighbor == neighbor && d.RemoteASN == asn {
return d
}
}
return nil
}
func copyPeerDiscovery(d *BGPPeerDiscovery) *BGPPeerDiscovery {
if d == nil {
return nil
}
cp := *d
return &cp
}
+44
View File
@@ -0,0 +1,44 @@
package store
import "time"
// PeerDiscoveryStatus values for bgp_peer_discovery.status.
const (
PeerDiscoveryPending = "pending"
PeerDiscoveryApproved = "approved"
PeerDiscoveryRejected = "rejected"
)
// BGPPeerDiscovery is a live-detected dynamic BGP session awaiting operator action.
type BGPPeerDiscovery struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
SpeakerID string `json:"speaker_id,omitempty"`
NeighborID string `json:"neighbor_id,omitempty"`
Neighbor string `json:"neighbor"`
RemoteASN int64 `json:"remote_asn"`
ProtocolName string `json:"protocol_name,omitempty"`
SessionState string `json:"session_state,omitempty"`
Status string `json:"status"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ApprovedPeerID string `json:"approved_peer_id,omitempty"`
}
// PeerDiscoveryUpsert is input for syncing a live dynamic session into the store.
type PeerDiscoveryUpsert struct {
SpeakerID string
NeighborID string
Neighbor string
RemoteASN int64
ProtocolName string
SessionState string
SeenAt time.Time
}
// PeerDiscoveryApproveInput optional fields when promoting a discovery to BGPPeer.
type PeerDiscoveryApproveInput struct {
Name string
SpeakerID *string
Enabled *bool
}