From f95e0edd32e460f49067451858ac57daa2c8afb3 Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Wed, 31 Dec 2025 15:36:54 +0100 Subject: [PATCH] Initial commit --- .gitignore | 10 + LICENSE | 202 +++++ Makefile | 30 + README.md | 47 ++ cmd/bird-exporter/main.go | 816 +++++++++++++++++++ cmd/bird-exporter/main_test.go | 416 ++++++++++ cmd/bird-exporter/testdata/sample_output.txt | 204 +++++ debian/bird-exporter.default | 8 + debian/bird-exporter.service | 24 + debian/changelog | 10 + debian/control | 27 + debian/copyright | 31 + debian/install | 1 + debian/rules | 33 + debian/source/format | 1 + doc/DETAILS.md | 373 +++++++++ docs/bird-exporter.1 | 164 ++++ go.mod | 15 + go.sum | 20 + 19 files changed, 2432 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/bird-exporter/main.go create mode 100644 cmd/bird-exporter/main_test.go create mode 100644 cmd/bird-exporter/testdata/sample_output.txt create mode 100644 debian/bird-exporter.default create mode 100644 debian/bird-exporter.service create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/install create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 doc/DETAILS.md create mode 100644 docs/bird-exporter.1 create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397a515 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +birdc.* +/bird-exporter +debian/.debhelper/ +debian/.gocache/ +debian/go/ +debian/bird-exporter/ +debian/files +debian/*.substvars +debian/debhelper-build-stamp +debian/*.debhelper diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e013016 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025, IPng Networks GmbH, Pim van Pelt + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c486709 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +.PHONY: all build test clean install sync-version pkg-deb + +BINARY_NAME=bird-exporter +INSTALL_PATH=/usr/local/bin + +all: test build + +build: + go build -o $(BINARY_NAME) ./cmd/bird-exporter/ + +test: + go test -v ./cmd/bird-exporter/ + +clean: + rm -f $(BINARY_NAME) + [ -d debian/go ] && chmod -R +w debian/go || true + rm -rf debian/.debhelper debian/.gocache debian/go debian/$(BINARY_NAME) debian/files debian/*.substvars debian/debhelper-build-stamp + rm -f ../$(BINARY_NAME)_*.deb ../$(BINARY_NAME)_*.changes ../$(BINARY_NAME)_*.buildinfo + +install: build + install -m 755 $(BINARY_NAME) $(INSTALL_PATH)/$(BINARY_NAME) + +sync-version: + @echo "Syncing version from debian/changelog to main.go..." + @VERSION=$$(head -1 debian/changelog | sed -n 's/.*(\([^)]*\)).*/\1/p'); \ + sed -i 's/^const Version = ".*"/const Version = "'"$$VERSION"'"/' cmd/bird-exporter/main.go; \ + echo "Updated Version const to: $$VERSION" + +pkg-deb: sync-version + fakeroot dpkg-buildpackage -us -uc -b diff --git a/README.md b/README.md new file mode 100644 index 0000000..4683b33 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# BIRD Exporter + +Prometheus exporter for BIRD routing daemon. + +## Quick Start + +```bash +# Build +make build + +# Test +make test + +# Create Debian package +make pkg-deb + +# Install package +sudo dpkg -i ../bird-exporter_*.deb +``` + +## Configuration + +The Debian package installs a systemd service that reads configuration from `/etc/default/bird-exporter`: + +```bash +# Edit service configuration +sudo nano /etc/default/bird-exporter + +# Start service +sudo systemctl start bird-exporter +``` + +Default configuration: +``` +BIRD_RUN_USER=bird +BIRD_RUN_GROUP=bird +BIRD_EXPORTER_ARGS="-period=60s -bird.socket=/var/run/bird/bird.ctl" +``` + +## Documentation + +- **Manual page**: `man bird-exporter` (after package installation) +- **Detailed documentation**: [doc/DETAILS.md](doc/DETAILS.md) + +## License + +See [LICENSE](LICENSE). diff --git a/cmd/bird-exporter/main.go b/cmd/bird-exporter/main.go new file mode 100644 index 0000000..114de50 --- /dev/null +++ b/cmd/bird-exporter/main.go @@ -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(` + BIRD Exporter + +

BIRD Exporter

+

Metrics

+ + `)) + }) + + log.Printf("Starting BIRD exporter on %s", *listenAddr) + log.Fatal(http.ListenAndServe(*listenAddr, nil)) +} diff --git a/cmd/bird-exporter/main_test.go b/cmd/bird-exporter/main_test.go new file mode 100644 index 0000000..1118fb6 --- /dev/null +++ b/cmd/bird-exporter/main_test.go @@ -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()) +} diff --git a/cmd/bird-exporter/testdata/sample_output.txt b/cmd/bird-exporter/testdata/sample_output.txt new file mode 100644 index 0000000..33e11f0 --- /dev/null +++ b/cmd/bird-exporter/testdata/sample_output.txt @@ -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 diff --git a/debian/bird-exporter.default b/debian/bird-exporter.default new file mode 100644 index 0000000..9b97e6c --- /dev/null +++ b/debian/bird-exporter.default @@ -0,0 +1,8 @@ +# Default settings for bird-exporter + +# User and group to run bird-exporter as +BIRD_RUN_USER=bird +BIRD_RUN_GROUP=bird + +# Command line arguments to pass to bird-exporter +BIRD_EXPORTER_ARGS="-period=60s -bird.socket=/var/run/bird/bird.ctl" diff --git a/debian/bird-exporter.service b/debian/bird-exporter.service new file mode 100644 index 0000000..dfb09a4 --- /dev/null +++ b/debian/bird-exporter.service @@ -0,0 +1,24 @@ +[Unit] +Description=BIRD Exporter for Prometheus +Documentation=https://git.ipng.ch/ipng/bird-exporter +After=network.target bird.service bird6.service +Wants=bird.service + +[Service] +Type=simple +EnvironmentFile=-/etc/default/bird-exporter +User=${BIRD_RUN_USER} +Group=${BIRD_RUN_GROUP} +ExecStart=/usr/bin/bird-exporter $BIRD_EXPORTER_ARGS +Restart=always +RestartSec=5 + +# Security settings +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/run/bird + +[Install] +WantedBy=multi-user.target diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..b6474a7 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,10 @@ +bird-exporter (0.1.0) unstable; urgency=medium + + * Initial release + * BIRD socket communication with periodic scraping + * Prometheus metrics export for protocols, routes, and BGP + * Support for Device, Direct, Kernel, Static, OSPF, and BGP protocols + * Route statistics including imported, filtered, exported, and preferred + * BGP-specific metrics including timers and session state + + -- Pim van Pelt Tue, 31 Dec 2024 14:00:00 +0000 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..37fc37d --- /dev/null +++ b/debian/control @@ -0,0 +1,27 @@ +Source: bird-exporter +Section: net +Priority: optional +Maintainer: Pim van Pelt +Build-Depends: debhelper-compat (= 13), + golang-go (>= 1.21) +Standards-Version: 4.6.0 +Homepage: https://git.ipng.ch/ipng/bird-exporter +Vcs-Git: https://git.ipng.ch/ipng/bird-exporter.git +Vcs-Browser: https://git.ipng.ch/ipng/bird-exporter + +Package: bird-exporter +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends} +Recommends: bird2 +Description: Prometheus exporter for BIRD routing daemon + bird-exporter is a Prometheus metrics exporter for the BIRD routing daemon. + It connects to BIRD's control socket and exports metrics about protocols, + routes, and BGP sessions. + . + Features: + * Real-time metrics via BIRD control socket + * Support for all major BIRD protocol types (BGP, OSPF, Kernel, Static, etc.) + * Detailed route statistics (imported, filtered, exported, preferred) + * BGP-specific metrics including session state and timers + * Configurable scrape interval + * Thread-safe periodic updates with caching diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..19bc423 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,31 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: bird-exporter +Upstream-Contact: Pim van Pelt +Source: https://git.ipng.ch/ipng/bird-exporter + +Files: * +Copyright: 2024 BIRD Exporter Authors +License: MIT + +Files: debian/* +Copyright: 2024 BIRD Exporter Authors +License: MIT + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..f2b9f63 --- /dev/null +++ b/debian/install @@ -0,0 +1 @@ +bird-exporter usr/bin/ diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..300956e --- /dev/null +++ b/debian/rules @@ -0,0 +1,33 @@ +#!/usr/bin/make -f + +export DH_VERBOSE = 1 +export GO111MODULE = on +export GOPROXY = direct +export GOCACHE = $(CURDIR)/debian/.gocache +export GOPATH = $(CURDIR)/debian/go + +%: + dh $@ + +override_dh_auto_build: + go build -v -ldflags="-s -w" -o bird-exporter ./cmd/bird-exporter/ + +override_dh_auto_install: + install -D -m 0755 bird-exporter debian/bird-exporter/usr/bin/bird-exporter + install -D -m 0644 debian/bird-exporter.service debian/bird-exporter/lib/systemd/system/bird-exporter.service + install -D -m 0644 debian/bird-exporter.default debian/bird-exporter/etc/default/bird-exporter + install -D -m 0644 docs/bird-exporter.1 debian/bird-exporter/usr/share/man/man1/bird-exporter.1 + +override_dh_auto_configure: + # Skip auto configure + +override_dh_auto_test: + go test -v ./cmd/bird-exporter/ + +override_dh_auto_clean: + rm -f bird-exporter + [ -d debian/go ] && chmod -R +w debian/go || true + rm -rf debian/.gocache debian/go obj-* + +override_dh_dwz: + # Skip dwz compression for Go binaries diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/doc/DETAILS.md b/doc/DETAILS.md new file mode 100644 index 0000000..b22f38a --- /dev/null +++ b/doc/DETAILS.md @@ -0,0 +1,373 @@ +# BIRD Exporter - Detailed Documentation + +## Overview + +bird-exporter is a Prometheus exporter for the BIRD routing daemon. It parses +the output of `birdc show proto all` and exposes metrics in Prometheus format. + +## Architecture + +The exporter consists of three main components: + +1. **Parser** - Parses BIRD protocol output into structured data +2. **Collector** - Implements Prometheus collector interface +3. **HTTP Server** - Serves metrics on /metrics endpoint + +## Input Format + +The exporter reads output from `birdc show proto all`, which provides detailed +information about all configured protocols. Each protocol block contains: + +- Protocol name, type, table, state, and timestamp +- Protocol-specific information (BGP state, OSPF status, etc.) +- One or more channels (typically ipv4 and/or ipv6) +- Route statistics per channel +- Import/export statistics per channel + +### Supported Protocol Types + +- **Device** - Interface tracking +- **Direct** - Directly connected routes +- **Kernel** - Kernel routing table synchronization +- **Static** - Static routes +- **OSPF** - OSPF routing protocol +- **BGP** - Border Gateway Protocol + +## Exported Metrics + +### Protocol Status Metrics + +**bird_protocol_up** +- Type: Gauge +- Description: Protocol operational status (1=up, 0=down) +- Labels: name, proto, table, info + +### BGP-Specific Metrics + +**bird_bgp_state** +- Type: Gauge +- Description: BGP session state (1=Established, 0=other) +- Labels: name, neighbor_address, neighbor_as, local_as, neighbor_id + +**bird_bgp_hold_timer_seconds** +- Type: Gauge +- Description: Current BGP hold timer value in seconds +- Labels: name, neighbor_address + +**bird_bgp_keepalive_timer_seconds** +- Type: Gauge +- Description: Current BGP keepalive timer value in seconds +- Labels: name, neighbor_address + +**bird_bgp_send_hold_timer_seconds** +- Type: Gauge +- Description: Current BGP send hold timer value in seconds +- Labels: name, neighbor_address + +### Route Metrics + +All route metrics include labels: name, proto, channel, table + +**bird_route_imported** +- Type: Gauge +- Description: Number of routes currently imported + +**bird_route_exported** +- Type: Gauge +- Description: Number of routes currently exported + +**bird_route_preferred** +- Type: Gauge +- Description: Number of preferred routes + +### Route Change Statistics + +All statistics are counters with labels: name, proto, channel, table + +**Import Statistics:** +- bird_route_import_updates_total +- bird_route_import_withdraws_total +- bird_route_import_rejected_total +- bird_route_import_filtered_total +- bird_route_import_ignored_total +- bird_route_import_accepted_total + +**Export Statistics:** +- bird_route_export_updates_total +- bird_route_export_withdraws_total +- bird_route_export_rejected_total +- bird_route_export_filtered_total +- bird_route_export_accepted_total + +## Configuration + +### Command-Line Flags + +**-web.listen-address** (default: `:9324`) +The address and port to listen on for HTTP requests. + +Example: +```sh +./bird-exporter -web.listen-address=:9100 +``` + +**-bird.socket** (default: `/var/run/bird/bird.ctl`) +Path to the BIRD control socket for communication with the BIRD daemon. + +Example: +```sh +./bird-exporter -bird.socket=/run/bird.ctl +``` + +**-period** (default: `60s`) +Time interval between BIRD data scrapes. The exporter caches BIRD data and +updates it periodically at this interval. + +Example: +```sh +./bird-exporter -period=30s +``` + +**-debug** (default: `false`) +Enable debug logging. When enabled, additional log messages are printed including: +- "Performing initial scrape..." +- "BIRD: show protocols all" +- "Successfully scraped N protocols" + +Without debug mode, only essential startup and operational messages are logged. + +Example: +```sh +./bird-exporter -debug +``` + +## Building + +### Prerequisites + +- Go 1.21 or later +- Make (optional) + +### Build Commands + +Using Make: +```sh +make build +``` + +Using Go directly: +```sh +go build -o bird-exporter ./cmd/bird-exporter/ +``` + +### Build Output + +The binary will be created in the project root directory: +- Binary name: `bird-exporter` +- Size: ~12MB (uncompressed) + +## Testing + +### Running Tests + +Using Make: +```sh +make test +``` + +Using Go directly: +```sh +go test -v ./cmd/bird-exporter/ +``` + +### Test Coverage + +The test suite includes: +- Protocol parsing tests (Device, Direct, Kernel, Static, OSPF, BGP) +- Channel parsing tests +- Route statistics parsing tests +- BGP-specific field parsing tests +- Error handling tests (missing files, empty files) +- Integration test with real birdc output + +### Test Fixtures + +Test fixtures are located in `cmd/bird-exporter/testdata/`: +- `sample_output.txt` - Minimal test fixture with all protocol types + +## Deployment + +### Running Locally + +For development or testing: +```sh +./bird-exporter +curl http://localhost:9324/metrics +``` + +### Running in Production + +The exporter communicates with BIRD via its control socket. Ensure the exporter +has permission to access the BIRD socket (typically `/var/run/bird/bird.ctl`). + +**Systemd Service** + +Create `/etc/systemd/system/bird-exporter.service`: +```ini +[Unit] +Description=BIRD Exporter +After=network.target bird.service + +[Service] +Type=simple +User=bird +ExecStart=/usr/local/bin/bird-exporter -period=60s +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: +```sh +systemctl enable bird-exporter +systemctl start bird-exporter +``` + +### Prometheus Configuration + +Add to prometheus.yml: +```yaml +scrape_configs: + - job_name: 'bird' + static_configs: + - targets: ['localhost:9324'] +``` + +## Prometheus Queries + +### Example Queries + +Count protocols by state: +```promql +count by (state) (bird_protocol_up) +``` + +Count established BGP sessions: +```promql +sum(bird_bgp_state) +``` + +Total imported routes by protocol type: +```promql +sum by (proto) (bird_route_imported) +``` + +BGP sessions with low hold timer (potential flapping): +```promql +bird_bgp_hold_timer_seconds < 30 +``` + +Import reject rate: +```promql +rate(bird_route_import_rejected_total[5m]) +``` + +## Limitations + +1. **BIRD v2 Only** - The parser is designed for BIRD v2 output format. BIRD + v1 compatibility is not guaranteed. + +2. **Periodic Updates** - Metrics are updated at the configured scrape interval + (default 60s). Changes in BIRD state may not be immediately reflected in metrics. + +## Troubleshooting + +### Debugging Connection Issues + +Enable debug logging to see detailed information about BIRD communication: +```sh +./bird-exporter -debug +``` + +This will show: +- When the exporter queries BIRD +- What command is being sent +- How many protocols were successfully parsed + +### No Metrics Appearing + +Check that the BIRD socket is accessible: +```sh +ls -la /var/run/bird/bird.ctl +``` + +Verify BIRD is running: +```sh +birdc show status +``` + +Check exporter logs for connection errors: +```sh +journalctl -u bird-exporter -f +``` + +### Incorrect Metric Values + +Check exporter logs for parse errors: +```sh +journalctl -u bird-exporter | grep -i error +``` + +### High Memory Usage + +The exporter loads the entire BIRD output into memory. For routers with many +BGP sessions and large routing tables, this can consume significant memory. +Monitor memory usage and consider resource limits. + +## Development + +### Project Structure + +``` +bird-exporter/ +├── cmd/ +│ └── bird-exporter/ +│ ├── main.go # Main program +│ ├── main_test.go # Tests +│ └── testdata/ # Test fixtures +├── doc/ +│ └── DETAILS.md # This file +├── go.mod # Go module definition +├── Makefile # Build automation +└── README.md # Quick start guide +``` + +### Adding New Metrics + +1. Define metric descriptor in `NewBirdCollector()` +2. Add metric to `Describe()` method +3. Extract data in parser if needed +4. Export metric in `Collect()` method +5. Add test coverage + +### Parser Architecture + +The parser uses a state machine approach: +- Tracks current protocol being parsed +- Tracks current channel within protocol +- Uses regex patterns to match and extract fields +- Handles hierarchical structure through indentation + +## Future Enhancements + +1. **Additional Protocols** - Support for RIP, Babel, BFD details, etc. +2. **Neighbor Details** - More detailed BGP neighbor information +3. **Route Details** - Export information about specific routes +4. **Real-time Updates** - Push updates instead of polling +5. **Configuration Reload** - Support SIGHUP for config reload +6. **Multiple BIRD Instances** - Support monitoring multiple BIRD instances + +## License + +MIT License - See LICENSE file for details diff --git a/docs/bird-exporter.1 b/docs/bird-exporter.1 new file mode 100644 index 0000000..d388cb7 --- /dev/null +++ b/docs/bird-exporter.1 @@ -0,0 +1,164 @@ +.TH BIRD-EXPORTER 1 "December 2024" "bird-exporter 0.1.0" "User Commands" +.SH NAME +bird-exporter \- Prometheus exporter for BIRD routing daemon +.SH SYNOPSIS +.B bird-exporter +[\fIOPTIONS\fR] +.SH DESCRIPTION +.B bird-exporter +is a Prometheus metrics exporter for the BIRD routing daemon. It connects to +BIRD's control socket and exports metrics about protocols, routes, and BGP +sessions in a format that can be scraped by Prometheus. +.PP +The exporter periodically queries BIRD for protocol information and caches the +results to minimize load on the BIRD daemon. The cache is updated at a +configurable interval (default: 60 seconds) and served to Prometheus on each +scrape request. +.SH OPTIONS +.TP +.B \-web.listen\-address \fIstring\fR +Address to listen on for web interface and telemetry (default: ":9324") +.TP +.B \-bird.socket \fIstring\fR +Path to BIRD control socket (default: "/var/run/bird/bird.ctl") +.TP +.B \-period \fIduration\fR +Period between scrapes of BIRD data (default: 60s) +.TP +.B \-debug +Enable debug logging (default: false) +.SH METRICS +The exporter provides the following metrics: +.TP +.B bird_protocol_up +Protocol status (1 = up, 0 = down). Labels: name, proto, table, info +.TP +.B bird_bgp_state +BGP session state (1 = Established, 0 = other). Labels: name, neighbor_address, neighbor_as, local_as, neighbor_id +.TP +.B bird_route_imported +Number of imported routes. Labels: name, proto, channel, table +.TP +.B bird_route_filtered +Number of filtered routes. Labels: name, proto, channel, table +.TP +.B bird_route_exported +Number of exported routes. Labels: name, proto, channel, table +.TP +.B bird_route_preferred +Number of preferred routes. Labels: name, proto, channel, table +.TP +.B bird_route_import_updates_total +Total number of import updates. Labels: name, proto, channel, table +.TP +.B bird_route_import_withdraws_total +Total number of import withdraws. Labels: name, proto, channel, table +.TP +.B bird_route_import_rejected_total +Total number of rejected imports. Labels: name, proto, channel, table +.TP +.B bird_route_import_filtered_total +Total number of filtered imports. Labels: name, proto, channel, table +.TP +.B bird_route_import_ignored_total +Total number of ignored imports. Labels: name, proto, channel, table +.TP +.B bird_route_import_accepted_total +Total number of accepted imports. Labels: name, proto, channel, table +.TP +.B bird_route_export_updates_total +Total number of export updates. Labels: name, proto, channel, table +.TP +.B bird_route_export_withdraws_total +Total number of export withdraws. Labels: name, proto, channel, table +.TP +.B bird_route_export_rejected_total +Total number of rejected exports. Labels: name, proto, channel, table +.TP +.B bird_route_export_filtered_total +Total number of filtered exports. Labels: name, proto, channel, table +.TP +.B bird_route_export_accepted_total +Total number of accepted exports. Labels: name, proto, channel, table +.TP +.B bird_bgp_hold_timer_seconds +BGP hold timer in seconds. Labels: name, neighbor_address +.TP +.B bird_bgp_keepalive_timer_seconds +BGP keepalive timer in seconds. Labels: name, neighbor_address +.TP +.B bird_bgp_send_hold_timer_seconds +BGP send hold timer in seconds. Labels: name, neighbor_address +.SH CONFIGURATION +Default configuration can be set in \fB/etc/default/bird-exporter\fR. The file +should contain a BIRD_EXPORTER_ARGS variable with command line arguments. +.PP +Example: +.PP +.nf +.RS +BIRD_EXPORTER_ARGS="-period=60s -bird.socket=/var/run/bird/bird.ctl" +.RE +.fi +.SH EXAMPLES +Start the exporter with default settings: +.PP +.nf +.RS +bird-exporter +.RE +.fi +.PP +Start with custom BIRD socket path: +.PP +.nf +.RS +bird-exporter -bird.socket=/run/bird.ctl +.RE +.fi +.PP +Start with custom scrape interval and listen address: +.PP +.nf +.RS +bird-exporter -period=30s -web.listen-address=:9999 +.RE +.fi +.PP +Start with debug logging enabled: +.PP +.nf +.RS +bird-exporter -debug +.RE +.fi +.PP +Query metrics using curl: +.PP +.nf +.RS +curl http://localhost:9324/metrics +.RE +.fi +.SH FILES +.TP +.B /etc/default/bird-exporter +Default configuration file +.TP +.B /var/run/bird/bird.ctl +Default BIRD control socket path +.TP +.B /lib/systemd/system/bird-exporter.service +Systemd service unit file +.SH PERMISSIONS +The exporter needs read/write access to the BIRD control socket. When running +as a systemd service, it runs as the \fBbird\fR user which should have +appropriate permissions. +.SH SEE ALSO +.BR bird (1), +.BR birdc (1), +.BR prometheus (1) +.PP +Project homepage: https://git.ipng.ch/ipng/bird-exporter +.SH AUTHOR +BIRD Exporter Authors diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..be06243 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/pim/bird-exporter + +go 1.21 + +require github.com/prometheus/client_golang v1.19.0 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/sys v0.16.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..827eb22 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=