diff --git a/internal/database/database.go b/internal/database/database.go index 8b361a5..7d4bb8f 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -11,6 +11,7 @@ import ( "runtime" "time" + "git.eeqj.de/sneak/routewatch/pkg/asinfo" "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" // CGO SQLite driver ) @@ -174,12 +175,15 @@ func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error) var asn ASN var idStr string - err = tx.QueryRow("SELECT id, number, first_seen, last_seen FROM asns WHERE number = ?", number). - Scan(&idStr, &asn.Number, &asn.FirstSeen, &asn.LastSeen) + var handle, description sql.NullString + err = tx.QueryRow("SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?", number). + Scan(&idStr, &asn.Number, &handle, &description, &asn.FirstSeen, &asn.LastSeen) if err == nil { // ASN exists, update last_seen asn.ID, _ = uuid.Parse(idStr) + asn.Handle = handle.String + asn.Description = description.String _, err = tx.Exec("UPDATE asns SET last_seen = ? WHERE id = ?", timestamp, asn.ID.String()) if err != nil { return nil, err @@ -199,15 +203,22 @@ func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error) return nil, err } - // ASN doesn't exist, create it + // ASN doesn't exist, create it with ASN info lookup asn = ASN{ ID: generateUUID(), Number: number, FirstSeen: timestamp, LastSeen: timestamp, } - _, err = tx.Exec("INSERT INTO asns (id, number, first_seen, last_seen) VALUES (?, ?, ?, ?)", - asn.ID.String(), asn.Number, asn.FirstSeen, asn.LastSeen) + + // Look up ASN info + if info, ok := asinfo.Get(number); ok { + asn.Handle = info.Handle + asn.Description = info.Description + } + + _, err = tx.Exec("INSERT INTO asns (id, number, handle, description, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?)", + asn.ID.String(), asn.Number, asn.Handle, asn.Description, asn.FirstSeen, asn.LastSeen) if err != nil { return nil, err } diff --git a/internal/database/models.go b/internal/database/models.go index 89b9051..022c7df 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -8,10 +8,12 @@ import ( // ASN represents an Autonomous System Number type ASN struct { - ID uuid.UUID `json:"id"` - Number int `json:"number"` - FirstSeen time.Time `json:"first_seen"` - LastSeen time.Time `json:"last_seen"` + ID uuid.UUID `json:"id"` + Number int `json:"number"` + Handle string `json:"handle"` + Description string `json:"description"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` } // Prefix represents an IP prefix (CIDR block) diff --git a/internal/database/schema.sql b/internal/database/schema.sql index 6b6941c..6a40e90 100644 --- a/internal/database/schema.sql +++ b/internal/database/schema.sql @@ -1,6 +1,8 @@ CREATE TABLE IF NOT EXISTS asns ( id TEXT PRIMARY KEY, number INTEGER UNIQUE NOT NULL, + handle TEXT, + description TEXT, first_seen DATETIME NOT NULL, last_seen DATETIME NOT NULL ); diff --git a/internal/routewatch/app.go b/internal/routewatch/app.go index 7a95214..3a0b626 100644 --- a/internal/routewatch/app.go +++ b/internal/routewatch/app.go @@ -4,6 +4,7 @@ package routewatch import ( "context" + "fmt" "log/slog" "os" "strings" @@ -23,6 +24,11 @@ type Config struct { MaxRuntime time.Duration // Maximum runtime (0 = run forever) } +const ( + // routingTableStatsInterval is how often we log routing table statistics + routingTableStatsInterval = 15 * time.Second +) + // NewConfig provides default configuration func NewConfig() Config { return Config{ @@ -88,6 +94,9 @@ func (rw *RouteWatch) Run(ctx context.Context) error { peerHandler := NewPeerHandler(rw.db, rw.logger) rw.streamer.RegisterHandler(peerHandler) + // Start periodic routing table stats logging + go rw.logRoutingTableStats(ctx) + // Start streaming if err := rw.streamer.Start(); err != nil { return err @@ -125,6 +134,32 @@ func (rw *RouteWatch) Run(ctx context.Context) error { return nil } +// logRoutingTableStats periodically logs routing table statistics +func (rw *RouteWatch) logRoutingTableStats(ctx context.Context) { + // Log stats periodically + ticker := time.NewTicker(routingTableStatsInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + stats := rw.routingTable.GetDetailedStats() + rw.logger.Info("Routing table statistics", + "ipv4_routes", stats.IPv4Routes, + "ipv6_routes", stats.IPv6Routes, + "ipv4_updates_per_sec", fmt.Sprintf("%.2f", stats.IPv4UpdatesRate), + "ipv6_updates_per_sec", fmt.Sprintf("%.2f", stats.IPv6UpdatesRate), + "total_routes", stats.TotalRoutes, + "unique_prefixes", stats.UniquePrefixes, + "unique_origins", stats.UniqueOrigins, + "unique_peers", stats.UniquePeers, + ) + } + } +} + // NewLogger creates a structured logger func NewLogger() *slog.Logger { level := slog.LevelInfo diff --git a/internal/routewatch/routingtablehandler.go b/internal/routewatch/routingtablehandler.go index f1c679b..61f9fe4 100644 --- a/internal/routewatch/routingtablehandler.go +++ b/internal/routewatch/routingtablehandler.go @@ -70,13 +70,6 @@ func (h *RoutingTableHandler) HandleMessage(msg *ristypes.RISMessage) { // Add route to routing table h.rt.AddRoute(route) - - h.logger.Debug("Added route to routing table", - "prefix", prefix, - "origin_asn", originASN, - "peer_asn", peerASN, - "path", msg.Path, - ) } } @@ -86,13 +79,7 @@ func (h *RoutingTableHandler) HandleMessage(msg *ristypes.RISMessage) { prefixID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(prefix)) // Withdraw all routes for this prefix from this peer - count := h.rt.WithdrawRoutesByPrefixAndPeer(prefixID, peerASN) - - h.logger.Debug("Withdrew routes from routing table", - "prefix", prefix, - "peer_asn", peerASN, - "routes_removed", count, - ) + h.rt.WithdrawRoutesByPrefixAndPeer(prefixID, peerASN) } } diff --git a/internal/routingtable/routingtable.go b/internal/routingtable/routingtable.go index aad6cb1..e32f1d9 100644 --- a/internal/routingtable/routingtable.go +++ b/internal/routingtable/routingtable.go @@ -3,7 +3,9 @@ package routingtable import ( "fmt" + "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -37,15 +39,23 @@ type RoutingTable struct { byPrefix map[uuid.UUID]map[RouteKey]*Route // Routes indexed by prefix ID byOriginASN map[uuid.UUID]map[RouteKey]*Route // Routes indexed by origin ASN ID byPeerASN map[int]map[RouteKey]*Route // Routes indexed by peer ASN + + // Metrics tracking + ipv4Routes int + ipv6Routes int + ipv4Updates uint64 // Updates counter for rate calculation + ipv6Updates uint64 // Updates counter for rate calculation + lastMetricsReset time.Time } // New creates a new empty routing table func New() *RoutingTable { return &RoutingTable{ - routes: make(map[RouteKey]*Route), - byPrefix: make(map[uuid.UUID]map[RouteKey]*Route), - byOriginASN: make(map[uuid.UUID]map[RouteKey]*Route), - byPeerASN: make(map[int]map[RouteKey]*Route), + routes: make(map[RouteKey]*Route), + byPrefix: make(map[uuid.UUID]map[RouteKey]*Route), + byOriginASN: make(map[uuid.UUID]map[RouteKey]*Route), + byPeerASN: make(map[int]map[RouteKey]*Route), + lastMetricsReset: time.Now(), } } @@ -63,6 +73,12 @@ func (rt *RoutingTable) AddRoute(route *Route) { // If route already exists, remove it from indexes first if existingRoute, exists := rt.routes[key]; exists { rt.removeFromIndexes(key, existingRoute) + // Decrement counter for existing route + if isIPv6(existingRoute.Prefix) { + rt.ipv6Routes-- + } else { + rt.ipv4Routes-- + } } // Add to main map @@ -70,6 +86,15 @@ func (rt *RoutingTable) AddRoute(route *Route) { // Update indexes rt.addToIndexes(key, route) + + // Update metrics + if isIPv6(route.Prefix) { + rt.ipv6Routes++ + atomic.AddUint64(&rt.ipv6Updates, 1) + } else { + rt.ipv4Routes++ + atomic.AddUint64(&rt.ipv4Updates, 1) + } } // RemoveRoute removes a route from the routing table @@ -94,6 +119,15 @@ func (rt *RoutingTable) RemoveRoute(prefixID, originASNID uuid.UUID, peerASN int // Remove from main map delete(rt.routes, key) + // Update metrics + if isIPv6(route.Prefix) { + rt.ipv6Routes-- + atomic.AddUint64(&rt.ipv6Updates, 1) + } else { + rt.ipv4Routes-- + atomic.AddUint64(&rt.ipv4Updates, 1) + } + return true } @@ -102,25 +136,38 @@ func (rt *RoutingTable) WithdrawRoutesByPrefixAndPeer(prefixID uuid.UUID, peerAS rt.mu.Lock() defer rt.mu.Unlock() - count := 0 + prefixRoutes, exists := rt.byPrefix[prefixID] + if !exists { + return 0 + } - // Find all routes for this prefix - if prefixRoutes, exists := rt.byPrefix[prefixID]; exists { - // Collect keys to delete (can't delete while iterating) - var keysToDelete []RouteKey - for key, route := range prefixRoutes { - if route.PeerASN == peerASN { - keysToDelete = append(keysToDelete, key) - } + // Collect keys to delete (can't delete while iterating) + var keysToDelete []RouteKey + for key, route := range prefixRoutes { + if route.PeerASN == peerASN { + keysToDelete = append(keysToDelete, key) + } + } + + // Delete the routes + count := 0 + for _, key := range keysToDelete { + route, exists := rt.routes[key] + if !exists { + continue } - // Delete the routes - for _, key := range keysToDelete { - if route, exists := rt.routes[key]; exists { - rt.removeFromIndexes(key, route) - delete(rt.routes, key) - count++ - } + rt.removeFromIndexes(key, route) + delete(rt.routes, key) + count++ + + // Update metrics + if isIPv6(route.Prefix) { + rt.ipv6Routes-- + atomic.AddUint64(&rt.ipv6Updates, 1) + } else { + rt.ipv4Routes-- + atomic.AddUint64(&rt.ipv4Updates, 1) } } @@ -234,6 +281,47 @@ func (rt *RoutingTable) Stats() map[string]int { return stats } +// DetailedStats contains detailed routing table statistics +type DetailedStats struct { + IPv4Routes int + IPv6Routes int + IPv4UpdatesRate float64 + IPv6UpdatesRate float64 + TotalRoutes int + UniquePrefixes int + UniqueOrigins int + UniquePeers int +} + +// GetDetailedStats returns detailed statistics including IPv4/IPv6 breakdown and update rates +func (rt *RoutingTable) GetDetailedStats() DetailedStats { + rt.mu.Lock() + defer rt.mu.Unlock() + + // Calculate update rates + elapsed := time.Since(rt.lastMetricsReset).Seconds() + ipv4Updates := atomic.LoadUint64(&rt.ipv4Updates) + ipv6Updates := atomic.LoadUint64(&rt.ipv6Updates) + + stats := DetailedStats{ + IPv4Routes: rt.ipv4Routes, + IPv6Routes: rt.ipv6Routes, + IPv4UpdatesRate: float64(ipv4Updates) / elapsed, + IPv6UpdatesRate: float64(ipv6Updates) / elapsed, + TotalRoutes: len(rt.routes), + UniquePrefixes: len(rt.byPrefix), + UniqueOrigins: len(rt.byOriginASN), + UniquePeers: len(rt.byPeerASN), + } + + // Reset counters for next period + atomic.StoreUint64(&rt.ipv4Updates, 0) + atomic.StoreUint64(&rt.ipv6Updates, 0) + rt.lastMetricsReset = time.Now() + + return stats +} + // Clear removes all routes from the routing table func (rt *RoutingTable) Clear() { rt.mu.Lock() @@ -243,6 +331,11 @@ func (rt *RoutingTable) Clear() { rt.byPrefix = make(map[uuid.UUID]map[RouteKey]*Route) rt.byOriginASN = make(map[uuid.UUID]map[RouteKey]*Route) rt.byPeerASN = make(map[int]map[RouteKey]*Route) + rt.ipv4Routes = 0 + rt.ipv6Routes = 0 + atomic.StoreUint64(&rt.ipv4Updates, 0) + atomic.StoreUint64(&rt.ipv6Updates, 0) + rt.lastMetricsReset = time.Now() } // Helper methods for index management @@ -297,3 +390,8 @@ func (rt *RoutingTable) removeFromIndexes(key RouteKey, route *Route) { func (k RouteKey) String() string { return fmt.Sprintf("%s/%s/%d", k.PrefixID, k.OriginASNID, k.PeerASN) } + +// isIPv6 returns true if the prefix is an IPv6 address +func isIPv6(prefix string) bool { + return strings.Contains(prefix, ":") +}