Initial commit

This commit is contained in:
2025-12-31 15:36:54 +01:00
commit f95e0edd32
19 changed files with 2432 additions and 0 deletions

816
cmd/bird-exporter/main.go Normal file
View File

@@ -0,0 +1,816 @@
package main
import (
"bufio"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const Version = "0.1.0"
var (
listenAddr = flag.String("web.listen-address", ":9324", "Address to listen on for web interface and telemetry.")
birdSocket = flag.String("bird.socket", "/var/run/bird/bird.ctl", "Path to BIRD control socket.")
scrapePeriod = flag.Duration("period", 60*time.Second, "Period between scrapes of BIRD data.")
debug = flag.Bool("debug", false, "Enable debug logging.")
)
var (
protocolsMutex sync.RWMutex
cachedProtocols []Protocol
)
type Protocol struct {
Name string
Proto string
Table string
State string
Since string
Info string
BGPInfo *BGPInfo
Channels []Channel
}
type BGPInfo struct {
BGPState string
NeighborAddr string
NeighborAS string
LocalAS string
NeighborID string
HoldTimer float64
KeepaliveTimer float64
SendHoldTimer float64
}
type Channel struct {
Name string
State string
Table string
Preference int
InputFilter string
OutputFilter string
Routes RouteStats
ImportStats ChangeStats
ExportStats ChangeStats
}
type RouteStats struct {
Imported int
Filtered int
Exported int
Preferred int
}
type ChangeStats struct {
Updates int
Withdraws int
Rejected int
Filtered int
Ignored int
Accepted int
}
type BirdCollector struct {
protocolUp *prometheus.Desc
bgpState *prometheus.Desc
routeImported *prometheus.Desc
routeFiltered *prometheus.Desc
routeExported *prometheus.Desc
routePreferred *prometheus.Desc
importUpdates *prometheus.Desc
importWithdraws *prometheus.Desc
importRejected *prometheus.Desc
importFiltered *prometheus.Desc
importIgnored *prometheus.Desc
importAccepted *prometheus.Desc
exportUpdates *prometheus.Desc
exportWithdraws *prometheus.Desc
exportRejected *prometheus.Desc
exportFiltered *prometheus.Desc
exportAccepted *prometheus.Desc
bgpHoldTimer *prometheus.Desc
bgpKeepaliveTimer *prometheus.Desc
bgpSendHoldTimer *prometheus.Desc
}
func NewBirdCollector() *BirdCollector {
return &BirdCollector{
protocolUp: prometheus.NewDesc(
"bird_protocol_up",
"Protocol status (1 = up, 0 = down)",
[]string{"name", "proto", "table", "info"},
nil,
),
bgpState: prometheus.NewDesc(
"bird_bgp_state",
"BGP session state (1 = Established, 0 = other)",
[]string{"name", "neighbor_address", "neighbor_as", "local_as", "neighbor_id"},
nil,
),
routeImported: prometheus.NewDesc(
"bird_route_imported",
"Number of imported routes",
[]string{"name", "proto", "channel", "table"},
nil,
),
routeFiltered: prometheus.NewDesc(
"bird_route_filtered",
"Number of filtered routes",
[]string{"name", "proto", "channel", "table"},
nil,
),
routeExported: prometheus.NewDesc(
"bird_route_exported",
"Number of exported routes",
[]string{"name", "proto", "channel", "table"},
nil,
),
routePreferred: prometheus.NewDesc(
"bird_route_preferred",
"Number of preferred routes",
[]string{"name", "proto", "channel", "table"},
nil,
),
importUpdates: prometheus.NewDesc(
"bird_route_import_updates_total",
"Total number of import updates",
[]string{"name", "proto", "channel", "table"},
nil,
),
importWithdraws: prometheus.NewDesc(
"bird_route_import_withdraws_total",
"Total number of import withdraws",
[]string{"name", "proto", "channel", "table"},
nil,
),
importRejected: prometheus.NewDesc(
"bird_route_import_rejected_total",
"Total number of rejected imports",
[]string{"name", "proto", "channel", "table"},
nil,
),
importFiltered: prometheus.NewDesc(
"bird_route_import_filtered_total",
"Total number of filtered imports",
[]string{"name", "proto", "channel", "table"},
nil,
),
importIgnored: prometheus.NewDesc(
"bird_route_import_ignored_total",
"Total number of ignored imports",
[]string{"name", "proto", "channel", "table"},
nil,
),
importAccepted: prometheus.NewDesc(
"bird_route_import_accepted_total",
"Total number of accepted imports",
[]string{"name", "proto", "channel", "table"},
nil,
),
exportUpdates: prometheus.NewDesc(
"bird_route_export_updates_total",
"Total number of export updates",
[]string{"name", "proto", "channel", "table"},
nil,
),
exportWithdraws: prometheus.NewDesc(
"bird_route_export_withdraws_total",
"Total number of export withdraws",
[]string{"name", "proto", "channel", "table"},
nil,
),
exportRejected: prometheus.NewDesc(
"bird_route_export_rejected_total",
"Total number of rejected exports",
[]string{"name", "proto", "channel", "table"},
nil,
),
exportFiltered: prometheus.NewDesc(
"bird_route_export_filtered_total",
"Total number of filtered exports",
[]string{"name", "proto", "channel", "table"},
nil,
),
exportAccepted: prometheus.NewDesc(
"bird_route_export_accepted_total",
"Total number of accepted exports",
[]string{"name", "proto", "channel", "table"},
nil,
),
bgpHoldTimer: prometheus.NewDesc(
"bird_bgp_hold_timer_seconds",
"BGP hold timer in seconds",
[]string{"name", "neighbor_address"},
nil,
),
bgpKeepaliveTimer: prometheus.NewDesc(
"bird_bgp_keepalive_timer_seconds",
"BGP keepalive timer in seconds",
[]string{"name", "neighbor_address"},
nil,
),
bgpSendHoldTimer: prometheus.NewDesc(
"bird_bgp_send_hold_timer_seconds",
"BGP send hold timer in seconds",
[]string{"name", "neighbor_address"},
nil,
),
}
}
func (c *BirdCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.protocolUp
ch <- c.bgpState
ch <- c.routeImported
ch <- c.routeFiltered
ch <- c.routeExported
ch <- c.routePreferred
ch <- c.importUpdates
ch <- c.importWithdraws
ch <- c.importRejected
ch <- c.importFiltered
ch <- c.importIgnored
ch <- c.importAccepted
ch <- c.exportUpdates
ch <- c.exportWithdraws
ch <- c.exportRejected
ch <- c.exportFiltered
ch <- c.exportAccepted
ch <- c.bgpHoldTimer
ch <- c.bgpKeepaliveTimer
ch <- c.bgpSendHoldTimer
}
func (c *BirdCollector) Collect(ch chan<- prometheus.Metric) {
protocolsMutex.RLock()
protocols := cachedProtocols
protocolsMutex.RUnlock()
for _, p := range protocols {
// Protocol status
upValue := 0.0
if strings.ToLower(p.State) == "up" {
upValue = 1.0
}
ch <- prometheus.MustNewConstMetric(
c.protocolUp,
prometheus.GaugeValue,
upValue,
p.Name, p.Proto, p.Table, p.Info,
)
// BGP specific metrics
if p.BGPInfo != nil {
bgpEstablished := 0.0
if p.BGPInfo.BGPState == "Established" {
bgpEstablished = 1.0
}
ch <- prometheus.MustNewConstMetric(
c.bgpState,
prometheus.GaugeValue,
bgpEstablished,
p.Name, p.BGPInfo.NeighborAddr, p.BGPInfo.NeighborAS, p.BGPInfo.LocalAS, p.BGPInfo.NeighborID,
)
if p.BGPInfo.HoldTimer > 0 {
ch <- prometheus.MustNewConstMetric(
c.bgpHoldTimer,
prometheus.GaugeValue,
p.BGPInfo.HoldTimer,
p.Name, p.BGPInfo.NeighborAddr,
)
}
if p.BGPInfo.KeepaliveTimer > 0 {
ch <- prometheus.MustNewConstMetric(
c.bgpKeepaliveTimer,
prometheus.GaugeValue,
p.BGPInfo.KeepaliveTimer,
p.Name, p.BGPInfo.NeighborAddr,
)
}
if p.BGPInfo.SendHoldTimer > 0 {
ch <- prometheus.MustNewConstMetric(
c.bgpSendHoldTimer,
prometheus.GaugeValue,
p.BGPInfo.SendHoldTimer,
p.Name, p.BGPInfo.NeighborAddr,
)
}
}
// Channel metrics
for _, channel := range p.Channels {
labels := []string{p.Name, p.Proto, channel.Name, channel.Table}
ch <- prometheus.MustNewConstMetric(
c.routeImported,
prometheus.GaugeValue,
float64(channel.Routes.Imported),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.routeFiltered,
prometheus.GaugeValue,
float64(channel.Routes.Filtered),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.routeExported,
prometheus.GaugeValue,
float64(channel.Routes.Exported),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.routePreferred,
prometheus.GaugeValue,
float64(channel.Routes.Preferred),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.importUpdates,
prometheus.CounterValue,
float64(channel.ImportStats.Updates),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.importWithdraws,
prometheus.CounterValue,
float64(channel.ImportStats.Withdraws),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.importRejected,
prometheus.CounterValue,
float64(channel.ImportStats.Rejected),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.importFiltered,
prometheus.CounterValue,
float64(channel.ImportStats.Filtered),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.importIgnored,
prometheus.CounterValue,
float64(channel.ImportStats.Ignored),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.importAccepted,
prometheus.CounterValue,
float64(channel.ImportStats.Accepted),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.exportUpdates,
prometheus.CounterValue,
float64(channel.ExportStats.Updates),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.exportWithdraws,
prometheus.CounterValue,
float64(channel.ExportStats.Withdraws),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.exportRejected,
prometheus.CounterValue,
float64(channel.ExportStats.Rejected),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.exportFiltered,
prometheus.CounterValue,
float64(channel.ExportStats.Filtered),
labels...,
)
ch <- prometheus.MustNewConstMetric(
c.exportAccepted,
prometheus.CounterValue,
float64(channel.ExportStats.Accepted),
labels...,
)
}
}
}
func getBirdOutput() (io.ReadCloser, error) {
// Connect to BIRD socket
conn, err := net.Dial("unix", *birdSocket)
if err != nil {
return nil, fmt.Errorf("failed to connect to BIRD socket: %w", err)
}
reader := bufio.NewReader(conn)
// Read welcome message - BIRD sends "0001 BIRD x.y.z ready."
// Lines ending with space (not dash) are the last line of a response
for {
line, err := reader.ReadString('\n')
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to read welcome message: %w", err)
}
// Welcome message ends with "0001 " (code + space)
// Check if line starts with 4 digits followed by space
if len(line) >= 5 &&
line[0] >= '0' && line[0] <= '9' &&
line[1] >= '0' && line[1] <= '9' &&
line[2] >= '0' && line[2] <= '9' &&
line[3] >= '0' && line[3] <= '9' &&
line[4] == ' ' {
break
}
}
// Send the command
command := "show protocols all"
if *debug {
log.Printf("BIRD: %s", command)
}
_, err = fmt.Fprintf(conn, "%s\n", command)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to send command: %w", err)
}
// Read and strip BIRD protocol codes from the response
var output strings.Builder
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
conn.Close()
return nil, fmt.Errorf("failed to read response: %w", err)
}
// BIRD protocol format: "CODE-text" or "CODE text" or "CODE"
// CODE is 4 digits, dash means more lines follow, space means last line
if len(line) >= 4 &&
line[0] >= '0' && line[0] <= '9' &&
line[1] >= '0' && line[1] <= '9' &&
line[2] >= '0' && line[2] <= '9' &&
line[3] >= '0' && line[3] <= '9' {
// 0000 indicates end of output
if line[0:4] == "0000" {
break
}
// Skip lines that are just status codes (header/footer)
if len(line) == 5 && (line[4] == '\n' || line[4] == ' ') {
continue
}
// If line has content after code and separator, strip the code
if len(line) > 5 && (line[4] == '-' || line[4] == ' ') {
// Remove the "CODE-" or "CODE " prefix
output.WriteString(line[5:])
continue
}
}
// Lines without codes (continuation lines) - write as-is
output.WriteString(line)
}
conn.Close()
// Return a reader for the stripped output
return io.NopCloser(strings.NewReader(output.String())), nil
}
func parseBirdOutput(input io.Reader) ([]Protocol, error) {
var protocols []Protocol
scanner := bufio.NewScanner(input)
var currentProto *Protocol
var currentChannel *Channel
var inChannelStats bool
// Regex patterns
// Match protocol line - Since field can be "YYYY-MM-DD HH:MM:SS" or just "HH:MM:SS.xxx"
protoLineRe := regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+(?:\s+\S+)?)\s*(.*)$`)
channelRe := regexp.MustCompile(`^\s+Channel (\S+)`)
bgpStateRe := regexp.MustCompile(`^\s+BGP state:\s+(.+)`)
neighborAddrRe := regexp.MustCompile(`^\s+Neighbor address:\s+(.+)`)
neighborASRe := regexp.MustCompile(`^\s+Neighbor AS:\s+(\d+)`)
localASRe := regexp.MustCompile(`^\s+Local AS:\s+(\d+)`)
neighborIDRe := regexp.MustCompile(`^\s+Neighbor ID:\s+(.+)`)
holdTimerRe := regexp.MustCompile(`^\s+Hold timer:\s+([\d.]+)/([\d.]+)`)
keepaliveTimerRe := regexp.MustCompile(`^\s+Keepalive timer:\s+([\d.]+)/([\d.]+)`)
sendHoldTimerRe := regexp.MustCompile(`^\s+Send hold timer:\s+([\d.]+)/([\d.]+)`)
stateRe := regexp.MustCompile(`^\s+State:\s+(.+)`)
tableRe := regexp.MustCompile(`^\s+Table:\s+(.+)`)
preferenceRe := regexp.MustCompile(`^\s+Preference:\s+(\d+)`)
inputFilterRe := regexp.MustCompile(`^\s+Input filter:\s+(.+)`)
outputFilterRe := regexp.MustCompile(`^\s+Output filter:\s+(.+)`)
// Routes line can be either:
// "Routes: X imported, Y exported, Z preferred"
// "Routes: X imported, Y filtered, Z exported, W preferred"
routesRe := regexp.MustCompile(`^\s+Routes:\s+(\d+) imported,(?:\s+(\d+) filtered,)?\s+(\d+) exported,\s+(\d+) preferred`)
importStatsRe := regexp.MustCompile(`^\s+Import updates:\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)`)
importWithdrawsRe := regexp.MustCompile(`^\s+Import withdraws:\s+(\d+)\s+(\d+)\s+---\s+(\d+)\s+(\d+)`)
exportStatsRe := regexp.MustCompile(`^\s+Export updates:\s+(\d+)\s+(\d+)\s+(\d+)\s+---\s+(\d+)`)
exportWithdrawsRe := regexp.MustCompile(`^\s+Export withdraws:\s+(\d+)\s+---\s+---\s+---\s+(\d+)`)
for scanner.Scan() {
line := scanner.Text()
// Skip header lines
if strings.HasPrefix(line, "BIRD") || strings.HasPrefix(line, "Name") {
continue
}
// Empty line often signals end of protocol block
if strings.TrimSpace(line) == "" {
if currentProto != nil && currentChannel != nil {
currentProto.Channels = append(currentProto.Channels, *currentChannel)
currentChannel = nil
}
inChannelStats = false
continue
}
// Check for new protocol
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
// Save previous protocol
if currentProto != nil {
if currentChannel != nil {
currentProto.Channels = append(currentProto.Channels, *currentChannel)
currentChannel = nil
}
protocols = append(protocols, *currentProto)
}
// Parse new protocol line
matches := protoLineRe.FindStringSubmatch(line)
if len(matches) > 6 {
currentProto = &Protocol{
Name: matches[1],
Proto: matches[2],
Table: matches[3],
State: matches[4],
Since: matches[5],
Info: strings.TrimSpace(matches[6]),
}
if currentProto.Proto == "BGP" {
currentProto.BGPInfo = &BGPInfo{}
}
}
inChannelStats = false
continue
}
if currentProto == nil {
continue
}
// Parse BGP specific info
if matches := bgpStateRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
currentProto.BGPInfo.BGPState = strings.TrimSpace(matches[1])
}
} else if matches := neighborAddrRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
currentProto.BGPInfo.NeighborAddr = strings.TrimSpace(matches[1])
}
} else if matches := neighborASRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
currentProto.BGPInfo.NeighborAS = matches[1]
}
} else if matches := localASRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
currentProto.BGPInfo.LocalAS = matches[1]
}
} else if matches := neighborIDRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
currentProto.BGPInfo.NeighborID = strings.TrimSpace(matches[1])
}
} else if matches := holdTimerRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
currentProto.BGPInfo.HoldTimer = val
}
}
} else if matches := keepaliveTimerRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
currentProto.BGPInfo.KeepaliveTimer = val
}
}
} else if matches := sendHoldTimerRe.FindStringSubmatch(line); matches != nil {
if currentProto.BGPInfo != nil {
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
currentProto.BGPInfo.SendHoldTimer = val
}
}
}
// Check for channel start
if matches := channelRe.FindStringSubmatch(line); matches != nil {
if currentChannel != nil {
currentProto.Channels = append(currentProto.Channels, *currentChannel)
}
currentChannel = &Channel{
Name: matches[1],
}
inChannelStats = false
continue
}
if currentChannel == nil {
continue
}
// Parse channel properties
if matches := stateRe.FindStringSubmatch(line); matches != nil {
currentChannel.State = strings.TrimSpace(matches[1])
} else if matches := tableRe.FindStringSubmatch(line); matches != nil {
currentChannel.Table = strings.TrimSpace(matches[1])
} else if matches := preferenceRe.FindStringSubmatch(line); matches != nil {
if val, err := strconv.Atoi(matches[1]); err == nil {
currentChannel.Preference = val
}
} else if matches := inputFilterRe.FindStringSubmatch(line); matches != nil {
currentChannel.InputFilter = strings.TrimSpace(matches[1])
} else if matches := outputFilterRe.FindStringSubmatch(line); matches != nil {
currentChannel.OutputFilter = strings.TrimSpace(matches[1])
} else if matches := routesRe.FindStringSubmatch(line); matches != nil {
// matches[1] = imported
// matches[2] = filtered (optional, may be empty)
// matches[3] = exported
// matches[4] = preferred
if imported, err := strconv.Atoi(matches[1]); err == nil {
currentChannel.Routes.Imported = imported
}
if matches[2] != "" {
if filtered, err := strconv.Atoi(matches[2]); err == nil {
currentChannel.Routes.Filtered = filtered
}
}
if exported, err := strconv.Atoi(matches[3]); err == nil {
currentChannel.Routes.Exported = exported
}
if preferred, err := strconv.Atoi(matches[4]); err == nil {
currentChannel.Routes.Preferred = preferred
}
} else if strings.Contains(line, "Route change stats:") {
inChannelStats = true
} else if inChannelStats {
if matches := importStatsRe.FindStringSubmatch(line); matches != nil {
if val, err := strconv.Atoi(matches[1]); err == nil {
currentChannel.ImportStats.Updates = val
}
if val, err := strconv.Atoi(matches[2]); err == nil {
currentChannel.ImportStats.Rejected = val
}
if val, err := strconv.Atoi(matches[3]); err == nil {
currentChannel.ImportStats.Filtered = val
}
if val, err := strconv.Atoi(matches[4]); err == nil {
currentChannel.ImportStats.Ignored = val
}
if val, err := strconv.Atoi(matches[5]); err == nil {
currentChannel.ImportStats.Accepted = val
}
} else if matches := importWithdrawsRe.FindStringSubmatch(line); matches != nil {
if val, err := strconv.Atoi(matches[1]); err == nil {
currentChannel.ImportStats.Withdraws = val
}
} else if matches := exportStatsRe.FindStringSubmatch(line); matches != nil {
if val, err := strconv.Atoi(matches[1]); err == nil {
currentChannel.ExportStats.Updates = val
}
if val, err := strconv.Atoi(matches[2]); err == nil {
currentChannel.ExportStats.Rejected = val
}
if val, err := strconv.Atoi(matches[3]); err == nil {
currentChannel.ExportStats.Filtered = val
}
if val, err := strconv.Atoi(matches[4]); err == nil {
currentChannel.ExportStats.Accepted = val
}
} else if matches := exportWithdrawsRe.FindStringSubmatch(line); matches != nil {
if val, err := strconv.Atoi(matches[1]); err == nil {
currentChannel.ExportStats.Withdraws = val
}
}
}
}
// Don't forget the last protocol
if currentProto != nil {
if currentChannel != nil {
currentProto.Channels = append(currentProto.Channels, *currentChannel)
}
protocols = append(protocols, *currentProto)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading input: %w", err)
}
return protocols, nil
}
func scrapeOnce() error {
input, err := getBirdOutput()
if err != nil {
return fmt.Errorf("failed to get BIRD output: %w", err)
}
defer input.Close()
protocols, err := parseBirdOutput(input)
if err != nil {
return fmt.Errorf("failed to parse BIRD output: %w", err)
}
protocolsMutex.Lock()
cachedProtocols = protocols
protocolsMutex.Unlock()
if *debug {
log.Printf("Successfully scraped %d protocols", len(protocols))
}
return nil
}
func startPeriodicScraper() {
ticker := time.NewTicker(*scrapePeriod)
defer ticker.Stop()
for range ticker.C {
if err := scrapeOnce(); err != nil {
log.Printf("Error scraping BIRD data: %v", err)
}
}
}
func main() {
flag.Parse()
log.Printf("BIRD Exporter version %s", Version)
// Perform initial scrape
if *debug {
log.Printf("Performing initial scrape...")
}
if err := scrapeOnce(); err != nil {
log.Fatalf("Initial scrape failed: %v", err)
}
// Start periodic scraper in background
log.Printf("Starting periodic scraper with period %s", *scrapePeriod)
go startPeriodicScraper()
collector := NewBirdCollector()
prometheus.MustRegister(collector)
http.Handle("/metrics", promhttp.Handler())
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<html>
<head><title>BIRD Exporter</title></head>
<body>
<h1>BIRD Exporter</h1>
<p><a href="/metrics">Metrics</a></p>
</body>
</html>`))
})
log.Printf("Starting BIRD exporter on %s", *listenAddr)
log.Fatal(http.ListenAndServe(*listenAddr, nil))
}

View File

@@ -0,0 +1,416 @@
package main
import (
"flag"
"os"
"testing"
)
func TestParseBirdOutput(t *testing.T) {
file, err := os.Open("testdata/sample_output.txt")
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer file.Close()
protocols, err := parseBirdOutput(file)
if err != nil {
t.Fatalf("parseBirdOutput() failed: %v", err)
}
// Check we got the expected number of protocols
expectedProtocols := 8 // device1, direct1, kernel4, static4, ospf4, fogixp_47498_ipv4_3, fogixp_47498_ipv6_1, fogixp_47498_ipv6_2
if len(protocols) != expectedProtocols {
t.Errorf("Expected %d protocols, got %d", expectedProtocols, len(protocols))
}
// Test Device protocol
device := findProtocol(protocols, "device1")
if device == nil {
t.Fatal("device1 protocol not found")
}
if device.Proto != "Device" {
t.Errorf("Expected proto 'Device', got '%s'", device.Proto)
}
if device.State != "up" {
t.Errorf("Expected state 'up', got '%s'", device.State)
}
if device.Table != "---" {
t.Errorf("Expected table '---', got '%s'", device.Table)
}
// Test Direct protocol with channels
direct := findProtocol(protocols, "direct1")
if direct == nil {
t.Fatal("direct1 protocol not found")
}
if direct.Proto != "Direct" {
t.Errorf("Expected proto 'Direct', got '%s'", direct.Proto)
}
if len(direct.Channels) != 2 {
t.Errorf("Expected 2 channels, got %d", len(direct.Channels))
}
// Test direct1 ipv4 channel
ipv4Chan := findChannel(direct.Channels, "ipv4")
if ipv4Chan == nil {
t.Fatal("ipv4 channel not found in direct1")
}
if ipv4Chan.State != "UP" {
t.Errorf("Expected channel state 'UP', got '%s'", ipv4Chan.State)
}
if ipv4Chan.Table != "master4" {
t.Errorf("Expected table 'master4', got '%s'", ipv4Chan.Table)
}
if ipv4Chan.Preference != 240 {
t.Errorf("Expected preference 240, got %d", ipv4Chan.Preference)
}
if ipv4Chan.InputFilter != "ACCEPT" {
t.Errorf("Expected input filter 'ACCEPT', got '%s'", ipv4Chan.InputFilter)
}
if ipv4Chan.OutputFilter != "REJECT" {
t.Errorf("Expected output filter 'REJECT', got '%s'", ipv4Chan.OutputFilter)
}
// Test route stats
if ipv4Chan.Routes.Imported != 10 {
t.Errorf("Expected 10 imported routes, got %d", ipv4Chan.Routes.Imported)
}
if ipv4Chan.Routes.Exported != 0 {
t.Errorf("Expected 0 exported routes, got %d", ipv4Chan.Routes.Exported)
}
if ipv4Chan.Routes.Preferred != 10 {
t.Errorf("Expected 10 preferred routes, got %d", ipv4Chan.Routes.Preferred)
}
// Test import stats
if ipv4Chan.ImportStats.Updates != 155 {
t.Errorf("Expected 155 import updates, got %d", ipv4Chan.ImportStats.Updates)
}
if ipv4Chan.ImportStats.Rejected != 0 {
t.Errorf("Expected 0 import rejected, got %d", ipv4Chan.ImportStats.Rejected)
}
if ipv4Chan.ImportStats.Filtered != 0 {
t.Errorf("Expected 0 import filtered, got %d", ipv4Chan.ImportStats.Filtered)
}
if ipv4Chan.ImportStats.Ignored != 0 {
t.Errorf("Expected 0 import ignored, got %d", ipv4Chan.ImportStats.Ignored)
}
if ipv4Chan.ImportStats.Accepted != 155 {
t.Errorf("Expected 155 import accepted, got %d", ipv4Chan.ImportStats.Accepted)
}
if ipv4Chan.ImportStats.Withdraws != 153 {
t.Errorf("Expected 153 import withdraws, got %d", ipv4Chan.ImportStats.Withdraws)
}
// Test export stats
if ipv4Chan.ExportStats.Updates != 0 {
t.Errorf("Expected 0 export updates, got %d", ipv4Chan.ExportStats.Updates)
}
if ipv4Chan.ExportStats.Withdraws != 0 {
t.Errorf("Expected 0 export withdraws, got %d", ipv4Chan.ExportStats.Withdraws)
}
// Test direct1 ipv6 channel
ipv6Chan := findChannel(direct.Channels, "ipv6")
if ipv6Chan == nil {
t.Fatal("ipv6 channel not found in direct1")
}
if ipv6Chan.Routes.Imported != 5 {
t.Errorf("Expected 5 imported routes, got %d", ipv6Chan.Routes.Imported)
}
}
func TestParseBGPProtocol(t *testing.T) {
file, err := os.Open("testdata/sample_output.txt")
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer file.Close()
protocols, err := parseBirdOutput(file)
if err != nil {
t.Fatalf("parseBirdOutput() failed: %v", err)
}
// Test BGP protocol (established)
bgp := findProtocol(protocols, "fogixp_47498_ipv4_3")
if bgp == nil {
t.Fatal("fogixp_47498_ipv4_3 protocol not found")
}
if bgp.Proto != "BGP" {
t.Errorf("Expected proto 'BGP', got '%s'", bgp.Proto)
}
if bgp.State != "up" {
t.Errorf("Expected state 'up', got '%s'", bgp.State)
}
if bgp.Info != "Established" {
t.Errorf("Expected info 'Established', got '%s'", bgp.Info)
}
// Test BGP info
if bgp.BGPInfo == nil {
t.Fatal("BGP info is nil")
}
if bgp.BGPInfo.BGPState != "Established" {
t.Errorf("Expected BGP state 'Established', got '%s'", bgp.BGPInfo.BGPState)
}
if bgp.BGPInfo.NeighborAddr != "185.1.147.3" {
t.Errorf("Expected neighbor address '185.1.147.3', got '%s'", bgp.BGPInfo.NeighborAddr)
}
if bgp.BGPInfo.NeighborAS != "47498" {
t.Errorf("Expected neighbor AS '47498', got '%s'", bgp.BGPInfo.NeighborAS)
}
if bgp.BGPInfo.LocalAS != "8298" {
t.Errorf("Expected local AS '8298', got '%s'", bgp.BGPInfo.LocalAS)
}
if bgp.BGPInfo.NeighborID != "185.1.147.3" {
t.Errorf("Expected neighbor ID '185.1.147.3', got '%s'", bgp.BGPInfo.NeighborID)
}
// Test BGP timers
if bgp.BGPInfo.HoldTimer != 174.765 {
t.Errorf("Expected hold timer 174.765, got %f", bgp.BGPInfo.HoldTimer)
}
if bgp.BGPInfo.KeepaliveTimer != 30.587 {
t.Errorf("Expected keepalive timer 30.587, got %f", bgp.BGPInfo.KeepaliveTimer)
}
if bgp.BGPInfo.SendHoldTimer != 425.866 {
t.Errorf("Expected send hold timer 425.866, got %f", bgp.BGPInfo.SendHoldTimer)
}
// Test BGP channel
if len(bgp.Channels) != 1 {
t.Errorf("Expected 1 channel, got %d", len(bgp.Channels))
}
bgpChan := findChannel(bgp.Channels, "ipv4")
if bgpChan == nil {
t.Fatal("ipv4 channel not found in fogixp_47498_ipv4_3")
}
if bgpChan.Routes.Imported != 44589 {
t.Errorf("Expected 44589 imported routes, got %d", bgpChan.Routes.Imported)
}
if bgpChan.Routes.Filtered != 6 {
t.Errorf("Expected 6 filtered routes, got %d", bgpChan.Routes.Filtered)
}
if bgpChan.Routes.Exported != 27 {
t.Errorf("Expected 27 exported routes, got %d", bgpChan.Routes.Exported)
}
if bgpChan.Routes.Preferred != 4173 {
t.Errorf("Expected 4173 preferred routes, got %d", bgpChan.Routes.Preferred)
}
}
func TestParseKernelProtocol(t *testing.T) {
file, err := os.Open("testdata/sample_output.txt")
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer file.Close()
protocols, err := parseBirdOutput(file)
if err != nil {
t.Fatalf("parseBirdOutput() failed: %v", err)
}
kernel := findProtocol(protocols, "kernel4")
if kernel == nil {
t.Fatal("kernel4 protocol not found")
}
if len(kernel.Channels) != 1 {
t.Fatalf("Expected 1 channel, got %d", len(kernel.Channels))
}
ipv4Chan := kernel.Channels[0]
if ipv4Chan.Routes.Exported != 1044496 {
t.Errorf("Expected 1044496 exported routes, got %d", ipv4Chan.Routes.Exported)
}
if ipv4Chan.ExportStats.Updates != 1684718679 {
t.Errorf("Expected 1684718679 export updates, got %d", ipv4Chan.ExportStats.Updates)
}
if ipv4Chan.ExportStats.Filtered != 143 {
t.Errorf("Expected 143 export filtered, got %d", ipv4Chan.ExportStats.Filtered)
}
if ipv4Chan.ExportStats.Accepted != 1684718536 {
t.Errorf("Expected 1684718536 export accepted, got %d", ipv4Chan.ExportStats.Accepted)
}
if ipv4Chan.ExportStats.Withdraws != 37968736 {
t.Errorf("Expected 37968736 export withdraws, got %d", ipv4Chan.ExportStats.Withdraws)
}
}
func TestParseOSPFProtocol(t *testing.T) {
file, err := os.Open("testdata/sample_output.txt")
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer file.Close()
protocols, err := parseBirdOutput(file)
if err != nil {
t.Fatalf("parseBirdOutput() failed: %v", err)
}
ospf := findProtocol(protocols, "ospf4")
if ospf == nil {
t.Fatal("ospf4 protocol not found")
}
if ospf.Proto != "OSPF" {
t.Errorf("Expected proto 'OSPF', got '%s'", ospf.Proto)
}
if ospf.Info != "Running" {
t.Errorf("Expected info 'Running', got '%s'", ospf.Info)
}
if len(ospf.Channels) != 1 {
t.Fatalf("Expected 1 channel, got %d", len(ospf.Channels))
}
ipv4Chan := ospf.Channels[0]
if ipv4Chan.InputFilter != "f_ospf" {
t.Errorf("Expected input filter 'f_ospf', got '%s'", ipv4Chan.InputFilter)
}
if ipv4Chan.Routes.Imported != 26 {
t.Errorf("Expected 26 imported routes, got %d", ipv4Chan.Routes.Imported)
}
if ipv4Chan.Routes.Exported != 4 {
t.Errorf("Expected 4 exported routes, got %d", ipv4Chan.Routes.Exported)
}
if ipv4Chan.Routes.Preferred != 25 {
t.Errorf("Expected 25 preferred routes, got %d", ipv4Chan.Routes.Preferred)
}
}
func TestParseStaticProtocol(t *testing.T) {
file, err := os.Open("testdata/sample_output.txt")
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer file.Close()
protocols, err := parseBirdOutput(file)
if err != nil {
t.Fatalf("parseBirdOutput() failed: %v", err)
}
static := findProtocol(protocols, "static4")
if static == nil {
t.Fatal("static4 protocol not found")
}
if static.Proto != "Static" {
t.Errorf("Expected proto 'Static', got '%s'", static.Proto)
}
if len(static.Channels) != 1 {
t.Fatalf("Expected 1 channel, got %d", len(static.Channels))
}
ipv4Chan := static.Channels[0]
if ipv4Chan.Preference != 200 {
t.Errorf("Expected preference 200, got %d", ipv4Chan.Preference)
}
if ipv4Chan.Routes.Imported != 3 {
t.Errorf("Expected 3 imported routes, got %d", ipv4Chan.Routes.Imported)
}
}
func TestParseBirdOutputWithRealFile(t *testing.T) {
// Test with the actual birdc.show.proto.all file if it exists
if _, err := os.Stat("../../birdc.show.proto.all"); os.IsNotExist(err) {
t.Skip("Real birdc.show.proto.all file not found, skipping")
}
file, err := os.Open("../../birdc.show.proto.all")
if err != nil {
t.Fatalf("Failed to open real file: %v", err)
}
defer file.Close()
protocols, err := parseBirdOutput(file)
if err != nil {
t.Fatalf("parseBirdOutput() failed on real file: %v", err)
}
if len(protocols) == 0 {
t.Error("Expected at least one protocol from real file")
}
// Check that we have various protocol types
hasDevice := false
hasBGP := false
for _, p := range protocols {
if p.Proto == "Device" {
hasDevice = true
}
if p.Proto == "BGP" {
hasBGP = true
}
}
if !hasDevice {
t.Error("Expected to find at least one Device protocol")
}
if !hasBGP {
t.Error("Expected to find at least one BGP protocol")
}
}
func TestParseEmptyFile(t *testing.T) {
// Create a temporary empty file
tmpFile, err := os.CreateTemp("", "empty-*.txt")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
tmpFile.Close()
file, err := os.Open(tmpFile.Name())
if err != nil {
t.Fatalf("Failed to open temp file: %v", err)
}
defer file.Close()
protocols, err := parseBirdOutput(file)
if err != nil {
t.Fatalf("parseBirdOutput() failed: %v", err)
}
if len(protocols) != 0 {
t.Errorf("Expected 0 protocols from empty file, got %d", len(protocols))
}
}
func TestParseFileNotFound(t *testing.T) {
_, err := os.Open("/tmp/does-not-exist-bird-test-12345.txt")
if err == nil {
t.Error("Expected error when opening non-existent file")
}
}
// Helper functions
func findProtocol(protocols []Protocol, name string) *Protocol {
for i := range protocols {
if protocols[i].Name == name {
return &protocols[i]
}
}
return nil
}
func findChannel(channels []Channel, name string) *Channel {
for i := range channels {
if channels[i].Name == name {
return &channels[i]
}
}
return nil
}
// TestMain to handle flag parsing for tests
func TestMain(m *testing.M) {
flag.Parse()
os.Exit(m.Run())
}

View File

@@ -0,0 +1,204 @@
BIRD v2.15.1-4-g280daed5-x ready.
Name Proto Table State Since Info
device1 Device --- up 2024-04-16 21:05:38
direct1 Direct --- up 2024-04-16 21:05:38
Channel ipv4
State: UP
Table: master4
Preference: 240
Input filter: ACCEPT
Output filter: REJECT
Routes: 10 imported, 0 exported, 10 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 155 0 0 0 155
Import withdraws: 153 0 --- 8 145
Export updates: 0 0 0 --- 0
Export withdraws: 0 --- --- --- 0
Channel ipv6
State: UP
Table: master6
Preference: 240
Input filter: ACCEPT
Output filter: REJECT
Routes: 5 imported, 0 exported, 5 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 140 0 0 0 140
Import withdraws: 138 0 --- 8 130
Export updates: 0 0 0 --- 0
Export withdraws: 0 --- --- --- 0
kernel4 Kernel master4 up 2024-04-16 21:05:38
Channel ipv4
State: UP
Table: master4
Preference: 10
Input filter: REJECT
Output filter: (unnamed)
Routes: 0 imported, 1044496 exported, 0 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 0 0 0 0 0
Import withdraws: 0 0 --- 0 0
Export updates: 1684718679 0 143 --- 1684718536
Export withdraws: 37968736 --- --- --- 37968651
static4 Static master4 up 2024-04-16 21:05:38
Channel ipv4
State: UP
Table: master4
Preference: 200
Input filter: ACCEPT
Output filter: ACCEPT
Routes: 3 imported, 0 exported, 3 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 4 0 0 0 4
Import withdraws: 1 0 --- 0 1
Export updates: 0 0 0 --- 0
Export withdraws: 0 --- --- --- 0
ospf4 OSPF master4 up 2024-06-19 19:34:13 Running
Channel ipv4
State: UP
Table: master4
Preference: 150
Input filter: f_ospf
Output filter: f_ospf
Routes: 26 imported, 4 exported, 25 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 11565 0 0 0 11565
Import withdraws: 912 0 --- 0 912
Export updates: 1521957177 10903 1521945947 --- 327
Export withdraws: 35569447 --- --- --- 72
fogixp_47498_ipv4_3 BGP --- up 2025-12-29 15:33:26 Established
Description: FogIXP Route Servers (FogIXP Route Servers)
BGP state: Established
Neighbor address: 185.1.147.3
Neighbor AS: 47498
Local AS: 8298
Neighbor ID: 185.1.147.3
Local capabilities
Multiprotocol
AF announced: ipv4
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Neighbor capabilities
Multiprotocol
AF announced: ipv4
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Role: rs_server
Session: external AS4
Source address: 185.1.147.44
Hold timer: 174.765/240
Keepalive timer: 30.587/80
Send hold timer: 425.866/480
Channel ipv4
State: UP
Table: master4
Preference: 100
Input filter: ebgp_fogixp_47498_import
Output filter: ebgp_fogixp_47498_export
Receive limit: 50000
Action: restart
Routes: 44589 imported, 6 filtered, 27 exported, 4173 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 87409 0 17 2 87390
Import withdraws: 22781 0 --- 158 22640
Export updates: 5998808 25441 5973153 --- 214
Export withdraws: 99952 --- --- --- 46
BGP Next hop: 185.1.147.44
fogixp_47498_ipv6_1 BGP --- up 2025-08-20 22:33:06 Established
Description: FogIXP Route Servers (FogIXP Route Servers)
BGP state: Established
Neighbor address: 2001:7f8:ca:1::111
Neighbor AS: 47498
Local AS: 8298
Neighbor ID: 185.1.147.111
Local capabilities
Multiprotocol
AF announced: ipv6
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Neighbor capabilities
Multiprotocol
AF announced: ipv6
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Session: external AS4
Source address: 2001:7f8:ca:1::44
Hold timer: 160.527/240
Keepalive timer: 22.845/80
Send hold timer: 355.702/480
Channel ipv6
State: UP
Table: master6
Preference: 100
Input filter: ebgp_fogixp_47498_import
Output filter: ebgp_fogixp_47498_export
Receive limit: 50000
Action: restart
Routes: 3510 imported, 6 filtered, 47 exported, 668 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 33247408 0 11645 198185 33037578
Import withdraws: 10638021 0 --- 162699 10486967
Export updates: 648072150 14962764 633024154 --- 85232
Export withdraws: 76736402 --- --- --- 19602
BGP Next hop: 2001:7f8:ca:1::44 fe80::6a05:caff:fe32:4617
fogixp_47498_ipv6_2 BGP --- up 2025-08-20 22:33:05 Established
Description: FogIXP Route Servers (FogIXP Route Servers)
BGP state: Established
Neighbor address: 2001:7f8:ca:1::222
Neighbor AS: 47498
Local AS: 8298
Neighbor ID: 185.1.147.222
Local capabilities
Multiprotocol
AF announced: ipv6
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Neighbor capabilities
Multiprotocol
AF announced: ipv6
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Session: external AS4
Source address: 2001:7f8:ca:1::44
Hold timer: 196.907/240
Keepalive timer: 2.909/80
Send hold timer: 295.173/480
Channel ipv6
State: UP
Table: master6
Preference: 100
Input filter: ebgp_fogixp_47498_import
Output filter: ebgp_fogixp_47498_export
Receive limit: 50000
Action: restart
Routes: 3493 imported, 5 filtered, 47 exported, 2 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 34265572 0 10111 198814 34056647
Import withdraws: 10602791 0 --- 164049 10448853
Export updates: 648071805 7560692 640425882 --- 85231
Export withdraws: 76736402 --- --- --- 19602
BGP Next hop: 2001:7f8:ca:1::44 fe80::6a05:caff:fe32:4617