check / check (push) Waiting to run
Takes .golangci.yml, .dockerignore, .gitignore, .editorconfig, the workflow and REPO_POLICIES.md from sneak/prompts dd4027b, keeping this repository's own entries after the shared content. Lint (golangci-lint v2.14.0) and test become Dockerfile phases that script/lint and script/test build alone and uncached; the image build depends on both, and script/cibuild bootstraps, runs script/check, then builds the image. The agent guidance file moves to AGENTS.md. The Go code is fixed for what the new configuration finds; reads that iterate rows now also report an iteration error. Model: opus-5-5
369 lines
9.3 KiB
Go
369 lines
9.3 KiB
Go
package routewatch
|
|
|
|
import (
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
// prefixHandlerQueueSize is the queue capacity for prefix tracking
|
|
// operations, about 4 seconds of feed at peak. The streamer drops rather
|
|
// than blocks when a queue is full, so this bounds memory. Batches still
|
|
// flush on a timer (prefixBatchTimeout).
|
|
prefixHandlerQueueSize = 20000
|
|
|
|
// prefixBatchSize is the number of prefix updates to batch together
|
|
prefixBatchSize = 25000
|
|
|
|
// prefixBatchTimeout is the maximum time to wait before flushing a batch
|
|
// DO NOT reduce this timeout - larger batches are more efficient
|
|
prefixBatchTimeout = 1 * time.Second
|
|
|
|
// IP version constants
|
|
ipv4Version = 4
|
|
ipv6Version = 6
|
|
)
|
|
|
|
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the
|
|
// database. Routes are added on announcement and deleted on withdrawal.
|
|
type PrefixHandler struct {
|
|
db database.Store
|
|
logger *logger.Logger
|
|
metrics *metrics.Tracker
|
|
|
|
// Batching
|
|
mu sync.Mutex
|
|
batch []prefixUpdate
|
|
lastFlush time.Time
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
type prefixUpdate struct {
|
|
prefix string
|
|
originASN int
|
|
peer string
|
|
messageType string
|
|
timestamp time.Time
|
|
path []int
|
|
}
|
|
|
|
// NewPrefixHandler creates a new batched prefix tracking handler
|
|
func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler {
|
|
h := &PrefixHandler{
|
|
db: db,
|
|
logger: logger,
|
|
batch: make([]prefixUpdate, 0, prefixBatchSize),
|
|
lastFlush: time.Now(),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
|
|
// Start the flush timer goroutine
|
|
h.wg.Add(1)
|
|
go h.flushLoop()
|
|
|
|
return h
|
|
}
|
|
|
|
// SetMetricsTracker sets the metrics tracker for recording route updates
|
|
func (h *PrefixHandler) SetMetricsTracker(metrics *metrics.Tracker) {
|
|
h.metrics = metrics
|
|
}
|
|
|
|
// WantsMessage returns true if this handler wants to process messages of the
|
|
// given type
|
|
func (h *PrefixHandler) WantsMessage(messageType string) bool {
|
|
// We only care about UPDATE messages for the routing table
|
|
return messageType == updateMessageType
|
|
}
|
|
|
|
// QueueCapacity returns the desired queue capacity for this handler
|
|
func (h *PrefixHandler) QueueCapacity() int {
|
|
// Batching allows us to use a larger queue
|
|
return prefixHandlerQueueSize
|
|
}
|
|
|
|
// HandleMessage processes a message to track prefix information
|
|
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|
// Use the pre-parsed timestamp
|
|
timestamp := msg.ParsedTimestamp
|
|
|
|
// Get origin ASN from path (last element)
|
|
var originASN int
|
|
if len(msg.Path) > 0 {
|
|
originASN = msg.Path[len(msg.Path)-1]
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
// Process announcements
|
|
for _, announcement := range msg.Announcements {
|
|
for _, prefix := range announcement.Prefixes {
|
|
h.batch = append(h.batch, prefixUpdate{
|
|
prefix: canonicalPrefix(prefix),
|
|
originASN: originASN,
|
|
peer: msg.Peer,
|
|
messageType: "announcement",
|
|
timestamp: timestamp,
|
|
path: msg.Path,
|
|
})
|
|
// Record announcement in metrics
|
|
if h.metrics != nil {
|
|
h.metrics.RecordAnnouncement()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process withdrawals
|
|
for _, prefix := range msg.Withdrawals {
|
|
h.batch = append(h.batch, prefixUpdate{
|
|
prefix: canonicalPrefix(prefix),
|
|
originASN: originASN, // Use the originASN from path if available
|
|
peer: msg.Peer,
|
|
messageType: "withdrawal",
|
|
timestamp: timestamp,
|
|
path: msg.Path,
|
|
})
|
|
// Record withdrawal in metrics
|
|
if h.metrics != nil {
|
|
h.metrics.RecordWithdrawal()
|
|
}
|
|
}
|
|
|
|
// Check if we need to flush
|
|
if len(h.batch) >= prefixBatchSize {
|
|
h.flushBatchLocked()
|
|
}
|
|
}
|
|
|
|
// Stop gracefully stops the handler and flushes remaining batches
|
|
func (h *PrefixHandler) Stop() {
|
|
close(h.stopCh)
|
|
h.wg.Wait()
|
|
}
|
|
|
|
// flushLoop runs in a goroutine and periodically flushes batches
|
|
func (h *PrefixHandler) flushLoop() {
|
|
defer h.wg.Done()
|
|
|
|
ticker := time.NewTicker(prefixBatchTimeout)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
h.mu.Lock()
|
|
if time.Since(h.lastFlush) >= prefixBatchTimeout {
|
|
h.flushBatchLocked()
|
|
}
|
|
h.mu.Unlock()
|
|
case <-h.stopCh:
|
|
// Final flush
|
|
h.mu.Lock()
|
|
h.flushBatchLocked()
|
|
h.mu.Unlock()
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// flushBatchLocked flushes the prefix batch to the database (must be called
|
|
// with mutex held)
|
|
func (h *PrefixHandler) flushBatchLocked() {
|
|
if len(h.batch) == 0 {
|
|
return
|
|
}
|
|
|
|
startTime := time.Now()
|
|
batchSize := len(h.batch)
|
|
|
|
// Group updates by prefix to deduplicate
|
|
// For each prefix, keep the latest update
|
|
prefixMap := make(map[string]prefixUpdate)
|
|
|
|
for _, update := range h.batch {
|
|
key := update.prefix
|
|
|
|
existing, ok := prefixMap[key]
|
|
if !ok || update.timestamp.After(existing.timestamp) {
|
|
prefixMap[key] = update
|
|
}
|
|
}
|
|
|
|
// Collect routes to upsert and delete
|
|
var (
|
|
routesToUpsert []*database.LiveRoute
|
|
routesToDelete []database.LiveRouteDeletion
|
|
)
|
|
|
|
// Collect unique prefixes to update
|
|
prefixesToUpdate := make(map[string]time.Time)
|
|
|
|
for _, update := range prefixMap {
|
|
// Track prefix for both announcements and withdrawals
|
|
seen, exists := prefixesToUpdate[update.prefix]
|
|
if !exists || update.timestamp.After(seen) {
|
|
prefixesToUpdate[update.prefix] = update.timestamp
|
|
}
|
|
|
|
if update.messageType == "announcement" && update.originASN > 0 {
|
|
// Create live route for batch upsert
|
|
route := h.createLiveRoute(update)
|
|
if route != nil {
|
|
routesToUpsert = append(routesToUpsert, route)
|
|
}
|
|
} else if update.messageType == "withdrawal" {
|
|
// Parse CIDR to get IP version
|
|
_, ipVersion, err := parseCIDR(update.prefix)
|
|
if err != nil {
|
|
h.logger.Error("Failed to parse CIDR for withdrawal",
|
|
"prefix", update.prefix, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
// Create deletion record for batch delete
|
|
routesToDelete = append(routesToDelete, database.LiveRouteDeletion{
|
|
Prefix: update.prefix,
|
|
OriginASN: update.originASN,
|
|
PeerIP: update.peer,
|
|
IPVersion: ipVersion,
|
|
})
|
|
}
|
|
}
|
|
|
|
successCount := h.writeBatch(routesToUpsert, routesToDelete, prefixesToUpdate)
|
|
|
|
elapsed := time.Since(startTime)
|
|
h.logger.Debug("Flushed prefix batch",
|
|
"batch_size", batchSize,
|
|
"unique_prefixes", len(prefixMap),
|
|
"success", successCount,
|
|
"duration_ms", elapsed.Milliseconds(),
|
|
)
|
|
|
|
// Clear batch
|
|
h.batch = h.batch[:0]
|
|
h.lastFlush = time.Now()
|
|
}
|
|
|
|
// writeBatch writes the collected route upserts, route deletions and prefix
|
|
// updates to the database and returns how many routes it wrote.
|
|
func (h *PrefixHandler) writeBatch(
|
|
routesToUpsert []*database.LiveRoute,
|
|
routesToDelete []database.LiveRouteDeletion,
|
|
prefixesToUpdate map[string]time.Time,
|
|
) int {
|
|
successCount := 0
|
|
|
|
if len(routesToUpsert) > 0 {
|
|
err := h.db.UpsertLiveRouteBatch(routesToUpsert)
|
|
if err != nil {
|
|
h.logger.Error("Failed to upsert route batch",
|
|
"error", err, "count", len(routesToUpsert))
|
|
} else {
|
|
successCount += len(routesToUpsert)
|
|
}
|
|
}
|
|
|
|
if len(routesToDelete) > 0 {
|
|
err := h.db.DeleteLiveRouteBatch(routesToDelete)
|
|
if err != nil {
|
|
h.logger.Error("Failed to delete route batch",
|
|
"error", err, "count", len(routesToDelete))
|
|
} else {
|
|
successCount += len(routesToDelete)
|
|
}
|
|
}
|
|
|
|
// Update prefix tables
|
|
if len(prefixesToUpdate) > 0 {
|
|
err := h.db.UpdatePrefixesBatch(prefixesToUpdate)
|
|
if err != nil {
|
|
h.logger.Error("Failed to update prefix batch",
|
|
"error", err, "count", len(prefixesToUpdate))
|
|
}
|
|
}
|
|
|
|
return successCount
|
|
}
|
|
|
|
// canonicalPrefix returns prefix in the text form net/netip prints, the form
|
|
// the IP lookup builds when it looks a prefix up. The feed sends IPv6
|
|
// announcements compressed ("2001:db8::/32") but IPv6 withdrawals uncompressed
|
|
// ("2001:db8:0:0:0:0:0:0/32"); stored as received, a withdrawal would not match
|
|
// the route its announcement stored. A prefix that does not parse is returned
|
|
// unchanged, and the batch flush reports it.
|
|
func canonicalPrefix(prefix string) string {
|
|
p, err := netip.ParsePrefix(prefix)
|
|
if err != nil {
|
|
return prefix
|
|
}
|
|
|
|
return p.Masked().String()
|
|
}
|
|
|
|
// parseCIDR returns the mask length and the IP version of a prefix string
|
|
func parseCIDR(prefix string) (int, int, error) {
|
|
_, ipNet, err := net.ParseCIDR(prefix)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
ones, _ := ipNet.Mask.Size()
|
|
if strings.Contains(prefix, ":") {
|
|
return ones, ipv6Version, nil
|
|
}
|
|
|
|
return ones, ipv4Version, nil
|
|
}
|
|
|
|
// createLiveRoute creates a LiveRoute from a prefix update
|
|
func (h *PrefixHandler) createLiveRoute(update prefixUpdate) *database.LiveRoute {
|
|
// Parse CIDR to get mask length
|
|
maskLength, ipVersion, err := parseCIDR(update.prefix)
|
|
if err != nil {
|
|
h.logger.Error("Failed to parse CIDR",
|
|
"prefix", update.prefix,
|
|
"error", err,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Track route update metrics
|
|
if h.metrics != nil {
|
|
if ipVersion == ipv4Version {
|
|
h.metrics.RecordIPv4Update()
|
|
} else {
|
|
h.metrics.RecordIPv6Update()
|
|
}
|
|
}
|
|
|
|
// Create live route record
|
|
liveRoute := &database.LiveRoute{
|
|
ID: uuid.New(),
|
|
Prefix: update.prefix,
|
|
MaskLength: maskLength,
|
|
IPVersion: ipVersion,
|
|
OriginASN: update.originASN,
|
|
PeerIP: update.peer,
|
|
ASPath: update.path,
|
|
NextHop: update.peer, // Using peer as next hop
|
|
LastUpdated: update.timestamp,
|
|
}
|
|
|
|
return liveRoute
|
|
}
|