28 Commits

Author SHA1 Message Date
a78e5c6e92 Add log.txt to .gitignore 2025-07-28 18:13:07 +02:00
9ef2a22db3 Remove SQLite pragmas that set default values
- Remove page_size, wal_autocheckpoint, locking_mode, mmap_size
- Keep only pragmas that change behavior from defaults
- Increase cache size to 3GB (upper limit for 2.4GB database)
2025-07-28 18:12:25 +02:00
05805b8847 Optimize SQLite settings for better balance
- Reduce cache size from 8GB to 512MB (still plenty for 2.4GB DB)
- Reduce mmap_size from 10GB to 256MB (reasonable default)
- Use default page size (4KB) instead of 8KB
- Use default WAL checkpoint interval (1000 pages)
- Remove redundant pragmas (threads, cache_spill, read_uncommitted)
- Clean up connection string to only use _txlock parameter
- Keep synchronous=OFF for performance (since we have mutex protection)
2025-07-28 18:06:31 +02:00
ddb3cfa4f0 Add mutex to serialize database access
- Add internal mutex to Database struct with lock/unlock wrappers
- Add debug logging for lock acquisition and release with timing
- Wrap all write operations with database mutex
- Use _txlock=immediate in SQLite connection string

This works around apparent issues with SQLite's internal locking
not properly respecting busy_timeout in production environment.
2025-07-28 17:56:26 +02:00
3ef60459b2 Fix SQLite transaction deadlocks with immediate mode
- Add _txlock=immediate to SQLite connection string
- This prevents deadlocks by acquiring write locks at transaction start
- Multiple concurrent writers now queue properly instead of failing instantly
- Resolves 'database table is locked' errors in production
2025-07-28 17:26:42 +02:00
40d7f0185b Optimize database batch operations with prepared statements
- Add prepared statements to all batch operations for better performance
- Fix database lock contention by properly batching operations
- Update SQLite settings for extreme performance (8GB cache, sync OFF)
- Add proper error handling for statement closing
- Update tests to properly track batch operations
2025-07-28 17:21:40 +02:00
b9b0792df9 Fix shutdown handling and optimize SQLite settings
- Fix Ctrl-C shutdown by using fx.Shutdowner instead of just canceling context
- Pass context from fx lifecycle to rw.Run() for proper cancellation
- Adjust WAL settings: checkpoint at 50MB, max size 100MB
- Reduce busy timeout from 30s to 2s to fail fast on lock contention

This should fix the issue where Ctrl-C doesn't cause shutdown and improve
database responsiveness under heavy load.
2025-07-28 16:52:52 +02:00
21921a170c Optimize database performance to fix slow queries
- Add VACUUM on startup to defragment database
- Increase cache size from 256MB to 2GB for better performance
- Increase mmap_size from 256MB to 512MB
- Add PRAGMA analysis_limit=0 to disable automatic ANALYZE
- Remove PRAGMA optimize which could trigger slow ANALYZE

These changes should dramatically improve query performance and prevent
the 5+ second query times seen in production.
2025-07-28 16:47:59 +02:00
78d6e17c76 Add debug logging and optimize SQLite performance
- Add debug logging for goroutines and memory usage (enabled via DEBUG=routewatch)
- Increase SQLite connection pool from 1 to 10 connections for better concurrency
- Optimize SQLite pragmas for balanced performance and safety
- Add proper shutdown handling for peering handler
- Define constants to avoid magic numbers in code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 15:45:06 +02:00
9b649c98c9 Fix AS detail view and add prefix sorting
- Fix GetASDetails to properly handle timestamp from MAX(last_updated)
- Parse timestamp string from SQLite aggregate function result
- Add natural sorting of prefixes by IP address in AS detail view
- Sort IPv4 and IPv6 prefixes separately by network address
- Remove SQL ORDER BY since we're sorting in Go
- This fixes the issue where AS detail pages showed no prefixes
2025-07-28 04:42:10 +02:00
48db8b9edf Fix AS detail view to show unique prefixes
- Update GetASDetails query to GROUP BY prefix instead of using DISTINCT
- Use MAX(last_updated) to get the most recent update time for each prefix
- This prevents duplicate prefixes from appearing when announced by multiple peers
- Maintains the same prefix count and ordering
2025-07-28 04:36:22 +02:00
df31cf880a Fix prefix URL routing by using URL encoding
- Replace slash-to-dash conversion with proper URL encoding
- Update handlePrefixDetail and handlePrefixDetailJSON to URL decode prefix parameter
- Update handleIPRedirect to URL encode the prefix in the redirect
- Add urlEncode template function for use in templates
- Update AS detail template to URL encode prefix links
- This properly handles the slash in CIDR notation (e.g., /prefix/192.168.1.0%2F24)
2025-07-28 04:34:34 +02:00
af9ff258b1 Add /ip/<ip> route that redirects to prefix detail page
- Implement handleIPRedirect handler that looks up the prefix containing an IP
- Add /ip/{ip} route to routes.go
- Reuse existing GetASInfoForIP database method which returns prefix info
- Redirect to /prefix/<prefix> page with HTTP 303 See Other status
- Handle invalid IPs (400) and IPs with no route (404)
2025-07-28 04:31:22 +02:00
aeeb5e7d7d Implement AS and prefix detail pages
- Implement handleASDetail() and handlePrefixDetail() HTML handlers
- Create AS detail HTML template with prefix listings
- Create prefix detail HTML template with route information
- Add timeSince template function for human-readable durations
- Update templates.go to include new templates
- Server-side rendered pages as requested (no client-side API calls)
2025-07-28 04:26:20 +02:00
27ae80ea2e Refactor server package: split handlers and routes into separate files
- Move all handler functions to handlers.go
- Move setupRoutes to routes.go
- Clean up server.go to only contain core server logic
- Add missing GetASDetails and GetPrefixDetails to mockStore for tests
- Fix linter errors (magic numbers, unused parameters, blank lines)
2025-07-28 04:00:12 +02:00
2fc24bb937 Add route age information to IP lookup API
- Add last_updated timestamp and age fields to ASInfo
- Include route's last_updated time from live_routes table
- Calculate and display age as human-readable duration
- Update both IPv4 and IPv6 queries to fetch timestamp
- Fix error handling to return 400 for invalid IPs
2025-07-28 03:44:19 +02:00
691710bc7c Add /api/v1/ip/<ip> endpoint for IP to AS lookups
- Add handleIPLookup handler that uses GetASInfoForIP
- Create writeJSONError and writeJSONSuccess helper functions
- Refactor all JSON error responses to use the helpers
- Add GetASInfoForIP to Store interface
- Add mock implementation for tests
- Fix all linter warnings
2025-07-28 03:31:53 +02:00
afb916036c Fix handler processing time display for sub-millisecond values
- Add formatProcessingTime function to display microseconds for values < 1ms
- Show 0 µs for times < 0.001ms, X.X µs for times < 0.01ms
- Show X.XXX ms for times < 1ms, X.XX ms for times >= 1ms
- Apply formatting to both average and min/max time displays
2025-07-28 03:26:16 +02:00
13047b5cb9 Add IPv4 range optimization for IP to AS lookups
- Add v4_ip_start and v4_ip_end columns to live_routes table
- Calculate IPv4 CIDR ranges as 32-bit integers for fast lookups
- Update PrefixHandler to populate IPv4 range fields
- Add GetASInfoForIP method with optimized IPv4 queries
- Add comprehensive tests for IP conversion functions
2025-07-28 03:23:25 +02:00
ae89468a1b Remove routing table and snapshotter packages, update status page
- Remove routingtable package entirely as database handles all routing data
- Remove snapshotter package as database contains all information
- Rename 'Connection Status' box to 'RouteWatch' and add Go version, goroutines, memory usage
- Move IPv4/IPv6 prefix counts from Database Statistics to Routing Table box
- Add Peers count to Database Statistics box
- Add go-humanize dependency for memory formatting
- Update server to include new metrics in API responses
2025-07-28 03:11:36 +02:00
d929f24f80 Remove RoutingTableHandler and snapshotter, use database for route stats
- Remove RoutingTableHandler as PrefixHandler maintains live_routes table
- Update server to get route counts from database instead of in-memory routing table
- Add GetLiveRouteCounts method to database for IPv4/IPv6 route counts
- Use metrics tracker in PrefixHandler for route update rates
- Remove snapshotter entirely as database contains all information
- Update tests to work without routing table
2025-07-28 03:02:44 +02:00
cb1f4d9052 Add route update metrics tracking to PrefixHandler
- Add RecordIPv4Update and RecordIPv6Update to metrics package
- Add SetMetricsTracker method to PrefixHandler
- Track IPv4/IPv6 route updates when processing announcements
- Add GetMetricsTracker method to Streamer to expose metrics
2025-07-28 02:55:27 +02:00
bc640b0b37 Reduce all handler queue sizes to 100,000 2025-07-28 02:50:05 +02:00
7d814c9d2d Optimize SQLite and PrefixHandler for better performance
- Increase PrefixHandler queue size to 500k and batch size to 25k
- Set SQLite PRAGMA synchronous=OFF for faster writes (trades durability)
- Increase SQLite cache to 1GB and mmap to 512MB
- Increase WAL checkpoint interval to 10000 pages
- Set page size to 8KB for better performance
- Increase busy timeout to 30 seconds
- Keep single connection to avoid SQLite locking issues
2025-07-28 02:40:17 +02:00
54bb0ba1cb Simplify peerings table to store AS numbers directly
- Rename asn_peerings table to peerings
- Change columns from from_asn_id/to_asn_id to as_a/as_b (integers)
- Remove foreign key constraints to asns table
- Update RecordPeering to use AS numbers directly
- Add validation in RecordPeering to ensure:
  - Both AS numbers are > 0
  - AS numbers are different
  - as_a is always lower than as_b (normalized)
- Update PeeringHandler to no longer need ASN cache
- Simplify the code by removing unnecessary ASN lookups
2025-07-28 02:36:15 +02:00
1157003db7 Refactor database handlers and optimize PeeringHandler
- Create PeeringHandler for asn_peerings table maintenance
- Rename DBHandler to ASHandler (now only handles asns table)
- Move prefixes table maintenance to PrefixHandler
- Optimize PeeringHandler with in-memory AS path tracking:
  - Stores AS paths in memory with timestamps
  - Processes peerings in batch every 2 minutes
  - Prunes old paths (>30 minutes) every 5 minutes
  - Normalizes peerings with lower AS number first
- Each handler now has a single responsibility:
  - ASHandler: asns table
  - PeerHandler: bgp_peers table
  - PrefixHandler: prefixes and live_routes tables
  - PeeringHandler: asn_peerings table
2025-07-28 02:31:04 +02:00
eaa11b5f8d Move prefixes table maintenance from DBHandler to PrefixHandler
- DBHandler now only maintains asns and asn_peerings tables
- PrefixHandler maintains both prefixes and live_routes tables
- This consolidates all prefix-related operations in one handler
2025-07-28 02:16:12 +02:00
8b43882526 Increase batch sizes to 10000 and queue sizes to 200000, reduce timeout to 2s 2025-07-28 02:11:05 +02:00
31 changed files with 274293 additions and 2044 deletions

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ pkg/asinfo/asdata.json
# Debug output files
out
log.txt

1
go.mod
View File

@@ -11,6 +11,7 @@ require (
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect

2
go.sum
View File

@@ -1,5 +1,7 @@
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@@ -5,9 +5,13 @@ import (
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
@@ -20,13 +24,32 @@ import (
//go:embed schema.sql
var dbSchema string
const dirPermissions = 0750 // rwxr-x---
const (
dirPermissions = 0750 // rwxr-x---
ipVersionV4 = 4
ipVersionV6 = 6
ipv6Length = 16
ipv4Offset = 12
ipv4Bits = 32
maxIPv4 = 0xFFFFFFFF
)
// Common errors
var (
// ErrInvalidIP is returned when an IP address is malformed
ErrInvalidIP = errors.New("invalid IP address")
// ErrNoRoute is returned when no route is found for an IP
ErrNoRoute = errors.New("no route found")
)
// Database manages the SQLite database connection and operations.
type Database struct {
db *sql.DB
logger *logger.Logger
path string
mu sync.Mutex
lockedAt time.Time
lockedBy string
}
// New creates a new database connection and initializes the schema.
@@ -43,8 +66,11 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
}
// Add connection parameters for go-sqlite3
// Enable WAL mode and other performance optimizations
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&cache=shared", dbPath)
// Configure SQLite connection parameters
dsn := fmt.Sprintf(
"file:%s",
dbPath,
)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
@@ -55,8 +81,10 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
}
// Set connection pool parameters
db.SetMaxOpenConns(1) // Force serialization since SQLite doesn't handle true concurrency well
db.SetMaxIdleConns(1)
// Multiple connections allow concurrent reads while writes are serialized
const maxConns = 10
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(maxConns)
db.SetConnMaxLifetime(0)
database := &Database{db: db, logger: logger, path: dbPath}
@@ -70,16 +98,15 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
// Initialize creates the database schema if it doesn't exist.
func (d *Database) Initialize() error {
// Set SQLite pragmas for better performance
// Set SQLite pragmas for performance
pragmas := []string{
"PRAGMA journal_mode=WAL", // Already set in connection string
"PRAGMA synchronous=NORMAL", // Faster than FULL, still safe
"PRAGMA cache_size=-524288", // 512MB cache (negative = KB)
"PRAGMA journal_mode=WAL", // Write-Ahead Logging
"PRAGMA synchronous=OFF", // Don't wait for disk writes
"PRAGMA cache_size=-3145728", // 3GB cache (upper limit for 2.4GB DB)
"PRAGMA temp_store=MEMORY", // Use memory for temp tables
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
"PRAGMA wal_autocheckpoint=1000", // Checkpoint every 1000 pages
"PRAGMA wal_checkpoint(PASSIVE)", // Checkpoint now
"PRAGMA optimize", // Run optimizer
"PRAGMA wal_checkpoint(TRUNCATE)", // Checkpoint and truncate WAL now
"PRAGMA busy_timeout=5000", // 5 second busy timeout
"PRAGMA analysis_limit=0", // Disable automatic ANALYZE
}
for _, pragma := range pragmas {
@@ -89,8 +116,17 @@ func (d *Database) Initialize() error {
}
err := d.exec(dbSchema)
if err != nil {
return err
}
// Run VACUUM on startup to optimize database
d.logger.Info("Running VACUUM to optimize database (this may take a moment)")
if err := d.exec("VACUUM"); err != nil {
d.logger.Warn("Failed to VACUUM database", "error", err)
}
return nil
}
// Close closes the database connection.
@@ -98,6 +134,33 @@ func (d *Database) Close() error {
return d.db.Close()
}
// lock acquires the database mutex and logs debug information
func (d *Database) lock(operation string) {
// Get caller information
_, file, line, _ := runtime.Caller(1)
caller := fmt.Sprintf("%s:%d", filepath.Base(file), line)
d.logger.Debug("Acquiring database lock", "operation", operation, "caller", caller)
d.mu.Lock()
d.lockedAt = time.Now()
d.lockedBy = fmt.Sprintf("%s (%s)", operation, caller)
d.logger.Debug("Database lock acquired", "operation", operation, "caller", caller)
}
// unlock releases the database mutex and logs debug information including hold duration
func (d *Database) unlock() {
holdDuration := time.Since(d.lockedAt)
lockedBy := d.lockedBy
d.lockedAt = time.Time{}
d.lockedBy = ""
d.mu.Unlock()
d.logger.Debug("Database lock released", "held_by", lockedBy, "duration_ms", holdDuration.Milliseconds())
}
// beginTx starts a new transaction with logging
func (d *Database) beginTx() (*loggingTx, error) {
tx, err := d.db.Begin()
@@ -108,8 +171,255 @@ func (d *Database) beginTx() (*loggingTx, error) {
return &loggingTx{Tx: tx, logger: d.logger}, nil
}
// UpsertLiveRouteBatch inserts or updates multiple live routes in a single transaction
func (d *Database) UpsertLiveRouteBatch(routes []*LiveRoute) error {
if len(routes) == 0 {
return nil
}
d.lock("UpsertLiveRouteBatch")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
d.logger.Error("Failed to rollback transaction", "error", err)
}
}()
// Use prepared statement for better performance
query := `
INSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop,
last_updated, v4_ip_start, v4_ip_end)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(prefix, origin_asn, peer_ip) DO UPDATE SET
mask_length = excluded.mask_length,
ip_version = excluded.ip_version,
as_path = excluded.as_path,
next_hop = excluded.next_hop,
last_updated = excluded.last_updated,
v4_ip_start = excluded.v4_ip_start,
v4_ip_end = excluded.v4_ip_end
`
stmt, err := tx.Prepare(query)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer func() { _ = stmt.Close() }()
for _, route := range routes {
// Encode AS path as JSON
pathJSON, err := json.Marshal(route.ASPath)
if err != nil {
return fmt.Errorf("failed to encode AS path: %w", err)
}
// Convert v4_ip_start and v4_ip_end to interface{} for SQL NULL handling
var v4Start, v4End interface{}
if route.V4IPStart != nil {
v4Start = *route.V4IPStart
}
if route.V4IPEnd != nil {
v4End = *route.V4IPEnd
}
_, err = stmt.Exec(
route.ID.String(),
route.Prefix,
route.MaskLength,
route.IPVersion,
route.OriginASN,
route.PeerIP,
string(pathJSON),
route.NextHop,
route.LastUpdated,
v4Start,
v4End,
)
if err != nil {
return fmt.Errorf("failed to upsert route %s: %w", route.Prefix, err)
}
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// DeleteLiveRouteBatch deletes multiple live routes in a single transaction
func (d *Database) DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error {
if len(deletions) == 0 {
return nil
}
d.lock("DeleteLiveRouteBatch")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
d.logger.Error("Failed to rollback transaction", "error", err)
}
}()
// Separate deletions by type and use prepared statements
var withOrigin []LiveRouteDeletion
var withoutOrigin []LiveRouteDeletion
for _, del := range deletions {
if del.OriginASN == 0 {
withoutOrigin = append(withoutOrigin, del)
} else {
withOrigin = append(withOrigin, del)
}
}
// Process deletions with origin ASN
if len(withOrigin) > 0 {
stmt, err := tx.Prepare(`DELETE FROM live_routes WHERE prefix = ? AND origin_asn = ? AND peer_ip = ?`)
if err != nil {
return fmt.Errorf("failed to prepare delete statement: %w", err)
}
defer func() { _ = stmt.Close() }()
for _, del := range withOrigin {
_, err = stmt.Exec(del.Prefix, del.OriginASN, del.PeerIP)
if err != nil {
return fmt.Errorf("failed to delete route %s: %w", del.Prefix, err)
}
}
}
// Process deletions without origin ASN
if len(withoutOrigin) > 0 {
stmt, err := tx.Prepare(`DELETE FROM live_routes WHERE prefix = ? AND peer_ip = ?`)
if err != nil {
return fmt.Errorf("failed to prepare delete statement: %w", err)
}
defer func() { _ = stmt.Close() }()
for _, del := range withoutOrigin {
_, err = stmt.Exec(del.Prefix, del.PeerIP)
if err != nil {
return fmt.Errorf("failed to delete route %s: %w", del.Prefix, err)
}
}
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// GetOrCreateASNBatch creates or updates multiple ASNs in a single transaction
func (d *Database) GetOrCreateASNBatch(asns map[int]time.Time) error {
if len(asns) == 0 {
return nil
}
d.lock("GetOrCreateASNBatch")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
d.logger.Error("Failed to rollback transaction", "error", err)
}
}()
// Prepare statements
selectStmt, err := tx.Prepare(
"SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?")
if err != nil {
return fmt.Errorf("failed to prepare select statement: %w", err)
}
defer func() { _ = selectStmt.Close() }()
updateStmt, err := tx.Prepare("UPDATE asns SET last_seen = ? WHERE id = ?")
if err != nil {
return fmt.Errorf("failed to prepare update statement: %w", err)
}
defer func() { _ = updateStmt.Close() }()
insertStmt, err := tx.Prepare(
"INSERT INTO asns (id, number, handle, description, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil {
return fmt.Errorf("failed to prepare insert statement: %w", err)
}
defer func() { _ = insertStmt.Close() }()
for number, timestamp := range asns {
var asn ASN
var idStr string
var handle, description sql.NullString
err = selectStmt.QueryRow(number).Scan(&idStr, &asn.Number, &handle, &description, &asn.FirstSeen, &asn.LastSeen)
if err == nil {
// ASN exists, update last_seen
asn.ID, _ = uuid.Parse(idStr)
_, err = updateStmt.Exec(timestamp, asn.ID.String())
if err != nil {
return fmt.Errorf("failed to update ASN %d: %w", number, err)
}
continue
}
if err == sql.ErrNoRows {
// ASN doesn't exist, create it
asn = ASN{
ID: generateUUID(),
Number: number,
FirstSeen: timestamp,
LastSeen: timestamp,
}
// Look up ASN info
if info, ok := asinfo.Get(number); ok {
asn.Handle = info.Handle
asn.Description = info.Description
}
_, err = insertStmt.Exec(asn.ID.String(), asn.Number, asn.Handle, asn.Description, asn.FirstSeen, asn.LastSeen)
if err != nil {
return fmt.Errorf("failed to insert ASN %d: %w", number, err)
}
continue
}
if err != nil {
return fmt.Errorf("failed to query ASN %d: %w", number, err)
}
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// GetOrCreateASN retrieves an existing ASN or creates a new one if it doesn't exist.
func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error) {
d.lock("GetOrCreateASN")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return nil, err
@@ -181,6 +491,9 @@ func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error)
// GetOrCreatePrefix retrieves an existing prefix or creates a new one if it doesn't exist.
func (d *Database) GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error) {
d.lock("GetOrCreatePrefix")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return nil, err
@@ -243,6 +556,9 @@ func (d *Database) GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefi
// RecordAnnouncement inserts a new BGP announcement or withdrawal into the database.
func (d *Database) RecordAnnouncement(announcement *Announcement) error {
d.lock("RecordAnnouncement")
defer d.unlock()
err := d.exec(`
INSERT INTO announcements (id, prefix_id, asn_id, origin_asn_id, path, next_hop, timestamp, is_withdrawal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -254,7 +570,23 @@ func (d *Database) RecordAnnouncement(announcement *Announcement) error {
}
// RecordPeering records a peering relationship between two ASNs.
func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time) error {
func (d *Database) RecordPeering(asA, asB int, timestamp time.Time) error {
// Validate ASNs
if asA <= 0 || asB <= 0 {
return fmt.Errorf("invalid ASN: asA=%d, asB=%d", asA, asB)
}
if asA == asB {
return fmt.Errorf("cannot create peering with same ASN: %d", asA)
}
// Normalize: ensure asA < asB
if asA > asB {
asA, asB = asB, asA
}
d.lock("RecordPeering")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return err
@@ -266,20 +598,20 @@ func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time)
}()
var exists bool
err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM asn_peerings WHERE from_asn_id = ? AND to_asn_id = ?)",
fromASNID, toASNID).Scan(&exists)
err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM peerings WHERE as_a = ? AND as_b = ?)",
asA, asB).Scan(&exists)
if err != nil {
return err
}
if exists {
_, err = tx.Exec("UPDATE asn_peerings SET last_seen = ? WHERE from_asn_id = ? AND to_asn_id = ?",
timestamp, fromASNID, toASNID)
_, err = tx.Exec("UPDATE peerings SET last_seen = ? WHERE as_a = ? AND as_b = ?",
timestamp, asA, asB)
} else {
_, err = tx.Exec(`
INSERT INTO asn_peerings (id, from_asn_id, to_asn_id, first_seen, last_seen)
INSERT INTO peerings (id, as_a, as_b, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?)`,
generateUUID().String(), fromASNID, toASNID, timestamp, timestamp)
generateUUID().String(), asA, asB, timestamp, timestamp)
}
if err != nil {
return err
@@ -287,8 +619,8 @@ func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time)
if err = tx.Commit(); err != nil {
d.logger.Error("Failed to commit transaction for peering",
"from_asn_id", fromASNID,
"to_asn_id", toASNID,
"as_a", asA,
"as_b", asB,
"error", err,
)
@@ -298,8 +630,77 @@ func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time)
return nil
}
// UpdatePeerBatch updates or creates multiple BGP peer records in a single transaction
func (d *Database) UpdatePeerBatch(peers map[string]PeerUpdate) error {
if len(peers) == 0 {
return nil
}
d.lock("UpdatePeerBatch")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
d.logger.Error("Failed to rollback transaction", "error", err)
}
}()
// Prepare statements
checkStmt, err := tx.Prepare("SELECT EXISTS(SELECT 1 FROM bgp_peers WHERE peer_ip = ?)")
if err != nil {
return fmt.Errorf("failed to prepare check statement: %w", err)
}
defer func() { _ = checkStmt.Close() }()
updateStmt, err := tx.Prepare(
"UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, last_message_type = ? WHERE peer_ip = ?")
if err != nil {
return fmt.Errorf("failed to prepare update statement: %w", err)
}
defer func() { _ = updateStmt.Close() }()
insertStmt, err := tx.Prepare(
"INSERT INTO bgp_peers (id, peer_ip, peer_asn, first_seen, last_seen, last_message_type) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil {
return fmt.Errorf("failed to prepare insert statement: %w", err)
}
defer func() { _ = insertStmt.Close() }()
for _, update := range peers {
var exists bool
err = checkStmt.QueryRow(update.PeerIP).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check peer %s: %w", update.PeerIP, err)
}
if exists {
_, err = updateStmt.Exec(update.PeerASN, update.Timestamp, update.MessageType, update.PeerIP)
} else {
_, err = insertStmt.Exec(
generateUUID().String(), update.PeerIP, update.PeerASN,
update.Timestamp, update.Timestamp, update.MessageType)
}
if err != nil {
return fmt.Errorf("failed to update peer %s: %w", update.PeerIP, err)
}
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// UpdatePeer updates or creates a BGP peer record
func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
d.lock("UpdatePeer")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return err
@@ -374,7 +775,13 @@ func (d *Database) GetStats() (Stats, error) {
}
// Count peerings
err = d.queryRow("SELECT COUNT(*) FROM asn_peerings").Scan(&stats.Peerings)
err = d.queryRow("SELECT COUNT(*) FROM peerings").Scan(&stats.Peerings)
if err != nil {
return stats, err
}
// Count peers
err = d.queryRow("SELECT COUNT(*) FROM bgp_peers").Scan(&stats.Peers)
if err != nil {
return stats, err
}
@@ -406,15 +813,21 @@ func (d *Database) GetStats() (Stats, error) {
// UpsertLiveRoute inserts or updates a live route
func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
d.lock("UpsertLiveRoute")
defer d.unlock()
query := `
INSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop,
last_updated, v4_ip_start, v4_ip_end)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(prefix, origin_asn, peer_ip) DO UPDATE SET
mask_length = excluded.mask_length,
ip_version = excluded.ip_version,
as_path = excluded.as_path,
next_hop = excluded.next_hop,
last_updated = excluded.last_updated
last_updated = excluded.last_updated,
v4_ip_start = excluded.v4_ip_start,
v4_ip_end = excluded.v4_ip_end
`
// Encode AS path as JSON
@@ -423,6 +836,15 @@ func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
return fmt.Errorf("failed to encode AS path: %w", err)
}
// Convert v4_ip_start and v4_ip_end to interface{} for SQL NULL handling
var v4Start, v4End interface{}
if route.V4IPStart != nil {
v4Start = *route.V4IPStart
}
if route.V4IPEnd != nil {
v4End = *route.V4IPEnd
}
_, err = d.db.Exec(query,
route.ID.String(),
route.Prefix,
@@ -433,6 +855,8 @@ func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
string(pathJSON),
route.NextHop,
route.LastUpdated,
v4Start,
v4End,
)
return err
@@ -441,6 +865,9 @@ func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
// DeleteLiveRoute deletes a live route
// If originASN is 0, deletes all routes for the prefix/peer combination
func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string) error {
d.lock("DeleteLiveRoute")
defer d.unlock()
var query string
var err error
@@ -505,3 +932,298 @@ func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []Pr
return ipv4, ipv6, nil
}
// GetLiveRouteCounts returns the count of IPv4 and IPv6 routes
func (d *Database) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
// Get IPv4 count
err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes WHERE ip_version = 4").Scan(&ipv4Count)
if err != nil {
return 0, 0, fmt.Errorf("failed to count IPv4 routes: %w", err)
}
// Get IPv6 count
err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes WHERE ip_version = 6").Scan(&ipv6Count)
if err != nil {
return 0, 0, fmt.Errorf("failed to count IPv6 routes: %w", err)
}
return ipv4Count, ipv6Count, nil
}
// GetASInfoForIP returns AS information for the given IP address
func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
// Parse the IP to validate it
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
}
// Determine IP version
ipVersion := ipVersionV4
ipv4 := parsedIP.To4()
if ipv4 == nil {
ipVersion = ipVersionV6
}
// For IPv4, use optimized range query
if ipVersion == ipVersionV4 {
// Convert IP to 32-bit unsigned integer
ipUint := ipToUint32(ipv4)
query := `
SELECT DISTINCT lr.prefix, lr.mask_length, lr.origin_asn, lr.last_updated, a.handle, a.description
FROM live_routes lr
LEFT JOIN asns a ON a.number = lr.origin_asn
WHERE lr.ip_version = ? AND lr.v4_ip_start <= ? AND lr.v4_ip_end >= ?
ORDER BY lr.mask_length DESC
LIMIT 1
`
var prefix string
var maskLength, originASN int
var lastUpdated time.Time
var handle, description sql.NullString
err := d.db.QueryRow(query, ipVersionV4, ipUint, ipUint).Scan(
&prefix, &maskLength, &originASN, &lastUpdated, &handle, &description)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
}
return nil, fmt.Errorf("failed to query routes: %w", err)
}
age := time.Since(lastUpdated).Round(time.Second).String()
return &ASInfo{
ASN: originASN,
Handle: handle.String,
Description: description.String,
Prefix: prefix,
LastUpdated: lastUpdated,
Age: age,
}, nil
}
// For IPv6, use the original method since we don't have range optimization
query := `
SELECT DISTINCT lr.prefix, lr.mask_length, lr.origin_asn, lr.last_updated, a.handle, a.description
FROM live_routes lr
LEFT JOIN asns a ON a.number = lr.origin_asn
WHERE lr.ip_version = ?
ORDER BY lr.mask_length DESC
`
rows, err := d.db.Query(query, ipVersionV6)
if err != nil {
return nil, fmt.Errorf("failed to query routes: %w", err)
}
defer func() { _ = rows.Close() }()
// Find the most specific matching prefix
var bestMatch struct {
prefix string
maskLength int
originASN int
lastUpdated time.Time
handle sql.NullString
description sql.NullString
}
bestMaskLength := -1
for rows.Next() {
var prefix string
var maskLength, originASN int
var lastUpdated time.Time
var handle, description sql.NullString
if err := rows.Scan(&prefix, &maskLength, &originASN, &lastUpdated, &handle, &description); err != nil {
continue
}
// Parse the prefix CIDR
_, ipNet, err := net.ParseCIDR(prefix)
if err != nil {
continue
}
// Check if the IP is in this prefix
if ipNet.Contains(parsedIP) && maskLength > bestMaskLength {
bestMatch.prefix = prefix
bestMatch.maskLength = maskLength
bestMatch.originASN = originASN
bestMatch.lastUpdated = lastUpdated
bestMatch.handle = handle
bestMatch.description = description
bestMaskLength = maskLength
}
}
if bestMaskLength == -1 {
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
}
age := time.Since(bestMatch.lastUpdated).Round(time.Second).String()
return &ASInfo{
ASN: bestMatch.originASN,
Handle: bestMatch.handle.String,
Description: bestMatch.description.String,
Prefix: bestMatch.prefix,
LastUpdated: bestMatch.lastUpdated,
Age: age,
}, nil
}
// ipToUint32 converts an IPv4 address to a 32-bit unsigned integer
func ipToUint32(ip net.IP) uint32 {
if len(ip) == ipv6Length {
// Convert to 4-byte representation
ip = ip[ipv4Offset:ipv6Length]
}
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
}
// CalculateIPv4Range calculates the start and end IP addresses for an IPv4 CIDR block
func CalculateIPv4Range(cidr string) (start, end uint32, err error) {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return 0, 0, err
}
// Get the network address (start of range)
ip := ipNet.IP.To4()
if ip == nil {
return 0, 0, fmt.Errorf("not an IPv4 address")
}
start = ipToUint32(ip)
// Calculate the end of the range
ones, bits := ipNet.Mask.Size()
hostBits := bits - ones
if hostBits >= ipv4Bits {
// Special case for /0 - entire IPv4 space
end = maxIPv4
} else {
// Safe to convert since we checked hostBits < 32
//nolint:gosec // hostBits is guaranteed to be < 32 from the check above
end = start | ((1 << uint(hostBits)) - 1)
}
return start, end, nil
}
// GetASDetails returns detailed information about an AS including prefixes
func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
// Get AS information
var asnInfo ASN
var idStr string
var handle, description sql.NullString
err := d.db.QueryRow(
"SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?",
asn,
).Scan(&idStr, &asnInfo.Number, &handle, &description, &asnInfo.FirstSeen, &asnInfo.LastSeen)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
return nil, nil, fmt.Errorf("failed to query AS: %w", err)
}
asnInfo.ID, _ = uuid.Parse(idStr)
asnInfo.Handle = handle.String
asnInfo.Description = description.String
// Get prefixes announced by this AS (unique prefixes with most recent update)
query := `
SELECT prefix, mask_length, ip_version, MAX(last_updated) as last_updated
FROM live_routes
WHERE origin_asn = ?
GROUP BY prefix, mask_length, ip_version
`
rows, err := d.db.Query(query, asn)
if err != nil {
return &asnInfo, nil, fmt.Errorf("failed to query prefixes: %w", err)
}
defer func() { _ = rows.Close() }()
var prefixes []LiveRoute
for rows.Next() {
var route LiveRoute
var lastUpdatedStr string
err := rows.Scan(&route.Prefix, &route.MaskLength, &route.IPVersion, &lastUpdatedStr)
if err != nil {
d.logger.Error("Failed to scan prefix row", "error", err, "asn", asn)
continue
}
// Parse the timestamp string
route.LastUpdated, err = time.Parse("2006-01-02 15:04:05-07:00", lastUpdatedStr)
if err != nil {
// Try without timezone
route.LastUpdated, err = time.Parse("2006-01-02 15:04:05", lastUpdatedStr)
if err != nil {
d.logger.Error("Failed to parse timestamp", "error", err, "timestamp", lastUpdatedStr)
continue
}
}
route.OriginASN = asn
prefixes = append(prefixes, route)
}
return &asnInfo, prefixes, nil
}
// GetPrefixDetails returns detailed information about a prefix
func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
query := `
SELECT lr.origin_asn, lr.peer_ip, lr.as_path, lr.next_hop, lr.last_updated,
a.handle, a.description
FROM live_routes lr
LEFT JOIN asns a ON a.number = lr.origin_asn
WHERE lr.prefix = ?
ORDER BY lr.origin_asn, lr.peer_ip
`
rows, err := d.db.Query(query, prefix)
if err != nil {
return nil, fmt.Errorf("failed to query prefix details: %w", err)
}
defer func() { _ = rows.Close() }()
var routes []LiveRoute
for rows.Next() {
var route LiveRoute
var pathJSON string
var handle, description sql.NullString
err := rows.Scan(
&route.OriginASN, &route.PeerIP, &pathJSON, &route.NextHop,
&route.LastUpdated, &handle, &description,
)
if err != nil {
continue
}
// Decode AS path
if err := json.Unmarshal([]byte(pathJSON), &route.ASPath); err != nil {
route.ASPath = []int{}
}
route.Prefix = prefix
routes = append(routes, route)
}
if len(routes) == 0 {
return nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix)
}
return routes, nil
}

View File

@@ -0,0 +1,301 @@
package database
import (
"net"
"testing"
)
func TestIPToUint32(t *testing.T) {
tests := []struct {
name string
ip string
expected uint32
}{
{
name: "Simple IP",
ip: "192.168.1.1",
expected: 3232235777, // 192<<24 + 168<<16 + 1<<8 + 1
},
{
name: "Minimum IP",
ip: "0.0.0.0",
expected: 0,
},
{
name: "Maximum IP",
ip: "255.255.255.255",
expected: 4294967295,
},
{
name: "10.0.0.0",
ip: "10.0.0.0",
expected: 167772160,
},
{
name: "172.16.0.0",
ip: "172.16.0.0",
expected: 2886729728,
},
{
name: "8.8.8.8",
ip: "8.8.8.8",
expected: 134744072,
},
{
name: "1.2.3.4",
ip: "1.2.3.4",
expected: 16909060,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("Failed to parse IP: %s", tt.ip)
}
result := ipToUint32(ip)
if result != tt.expected {
t.Errorf("ipToUint32(%s) = %d, want %d", tt.ip, result, tt.expected)
}
// Test with IPv4-mapped IPv6 address
ip6 := net.ParseIP(tt.ip).To16()
if ip6 != nil {
result6 := ipToUint32(ip6)
if result6 != tt.expected {
t.Errorf("ipToUint32(%s as IPv6) = %d, want %d", tt.ip, result6, tt.expected)
}
}
})
}
}
func TestCalculateIPv4Range(t *testing.T) {
tests := []struct {
name string
cidr string
wantStart uint32
wantEnd uint32
wantErr bool
}{
{
name: "Single IP /32",
cidr: "192.168.1.1/32",
wantStart: 3232235777,
wantEnd: 3232235777,
},
{
name: "Class C /24",
cidr: "192.168.1.0/24",
wantStart: 3232235776, // 192.168.1.0
wantEnd: 3232236031, // 192.168.1.255
},
{
name: "Class B /16",
cidr: "192.168.0.0/16",
wantStart: 3232235520, // 192.168.0.0
wantEnd: 3232301055, // 192.168.255.255
},
{
name: "Class A /8",
cidr: "10.0.0.0/8",
wantStart: 167772160, // 10.0.0.0
wantEnd: 184549375, // 10.255.255.255
},
{
name: "Entire IPv4 space /0",
cidr: "0.0.0.0/0",
wantStart: 0,
wantEnd: 4294967295,
},
{
name: "Small subnet /30",
cidr: "192.168.1.0/30",
wantStart: 3232235776, // 192.168.1.0
wantEnd: 3232235779, // 192.168.1.3
},
{
name: "Medium subnet /20",
cidr: "172.16.0.0/20",
wantStart: 2886729728, // 172.16.0.0
wantEnd: 2886733823, // 172.16.15.255
},
{
name: "Private range 172.16/12",
cidr: "172.16.0.0/12",
wantStart: 2886729728, // 172.16.0.0
wantEnd: 2887778303, // 172.31.255.255
},
{
name: "Google DNS /29",
cidr: "8.8.8.8/29",
wantStart: 134744072, // 8.8.8.8 (network is actually 8.8.8.8 with /29)
wantEnd: 134744079, // 8.8.8.15
},
{
name: "Non-zero host bits",
cidr: "192.168.1.5/24",
wantStart: 3232235776, // 192.168.1.0 (network address)
wantEnd: 3232236031, // 192.168.1.255
},
{
name: "Invalid CIDR",
cidr: "192.168.1.1/33",
wantErr: true,
},
{
name: "Invalid IP",
cidr: "256.256.256.256/24",
wantErr: true,
},
{
name: "IPv6 CIDR",
cidr: "2001:db8::/32",
wantErr: true,
},
{
name: "Empty CIDR",
cidr: "",
wantErr: true,
},
{
name: "Missing mask",
cidr: "192.168.1.1",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := CalculateIPv4Range(tt.cidr)
if tt.wantErr {
if err == nil {
t.Errorf("CalculateIPv4Range(%s) expected error, got nil", tt.cidr)
}
return
}
if err != nil {
t.Errorf("CalculateIPv4Range(%s) unexpected error: %v", tt.cidr, err)
return
}
if start != tt.wantStart {
t.Errorf("CalculateIPv4Range(%s) start = %d, want %d", tt.cidr, start, tt.wantStart)
}
if end != tt.wantEnd {
t.Errorf("CalculateIPv4Range(%s) end = %d, want %d", tt.cidr, end, tt.wantEnd)
}
// Verify that start <= end
if start > end {
t.Errorf("CalculateIPv4Range(%s) start (%d) > end (%d)", tt.cidr, start, end)
}
// Verify the range size matches the CIDR mask
if !tt.wantErr && tt.cidr != "" {
_, ipNet, _ := net.ParseCIDR(tt.cidr)
if ipNet != nil {
ones, bits := ipNet.Mask.Size()
expectedSize := uint32(1) << uint(bits-ones)
actualSize := end - start + 1
if actualSize != expectedSize {
t.Errorf("CalculateIPv4Range(%s) range size = %d, want %d", tt.cidr, actualSize, expectedSize)
}
}
}
})
}
}
func TestIPv4RangeIntegration(t *testing.T) {
// Test that our functions work correctly together
tests := []struct {
name string
cidr string
testIPs []string
shouldContain []bool
}{
{
name: "192.168.1.0/24",
cidr: "192.168.1.0/24",
testIPs: []string{
"192.168.1.0",
"192.168.1.1",
"192.168.1.255",
"192.168.0.255",
"192.168.2.0",
},
shouldContain: []bool{true, true, true, false, false},
},
{
name: "10.0.0.0/8",
cidr: "10.0.0.0/8",
testIPs: []string{
"10.0.0.0",
"10.255.255.255",
"10.1.2.3",
"9.255.255.255",
"11.0.0.0",
},
shouldContain: []bool{true, true, true, false, false},
},
{
name: "172.16.0.0/12",
cidr: "172.16.0.0/12",
testIPs: []string{
"172.16.0.0",
"172.31.255.255",
"172.20.1.1",
"172.15.255.255",
"172.32.0.0",
},
shouldContain: []bool{true, true, true, false, false},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := CalculateIPv4Range(tt.cidr)
if err != nil {
t.Fatalf("Failed to calculate range for %s: %v", tt.cidr, err)
}
for i, testIP := range tt.testIPs {
ip := net.ParseIP(testIP)
if ip == nil {
t.Fatalf("Failed to parse test IP: %s", testIP)
}
ipUint := ipToUint32(ip)
contained := ipUint >= start && ipUint <= end
if contained != tt.shouldContain[i] {
t.Errorf("IP %s in range %s: got %v, want %v", testIP, tt.cidr, contained, tt.shouldContain[i])
}
}
})
}
}
func BenchmarkIPToUint32(b *testing.B) {
ip := net.ParseIP("192.168.1.1")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ipToUint32(ip)
}
}
func BenchmarkCalculateIPv4Range(b *testing.B) {
cidr := "192.168.0.0/16"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _ = CalculateIPv4Range(cidr)
}
}

View File

@@ -11,6 +11,7 @@ type Stats struct {
IPv4Prefixes int
IPv6Prefixes int
Peerings int
Peers int
FileSizeBytes int64
LiveRoutes int
IPv4PrefixDistribution []PrefixDistribution
@@ -21,6 +22,7 @@ type Stats struct {
type Store interface {
// ASN operations
GetOrCreateASN(number int, timestamp time.Time) (*ASN, error)
GetOrCreateASNBatch(asns map[int]time.Time) error
// Prefix operations
GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error)
@@ -29,18 +31,29 @@ type Store interface {
RecordAnnouncement(announcement *Announcement) error
// Peering operations
RecordPeering(fromASNID, toASNID string, timestamp time.Time) error
RecordPeering(asA, asB int, timestamp time.Time) error
// Statistics
GetStats() (Stats, error)
// Peer operations
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
UpdatePeerBatch(peers map[string]PeerUpdate) error
// Live route operations
UpsertLiveRoute(route *LiveRoute) error
UpsertLiveRouteBatch(routes []*LiveRoute) error
DeleteLiveRoute(prefix string, originASN int, peerIP string) error
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
// IP lookup operations
GetASInfoForIP(ip string) (*ASInfo, error)
// AS and prefix detail operations
GetASDetails(asn int) (*ASN, []LiveRoute, error)
GetPrefixDetails(prefix string) ([]LiveRoute, error)
// Lifecycle
Close() error

View File

@@ -57,6 +57,9 @@ type LiveRoute struct {
ASPath []int `json:"as_path"`
NextHop string `json:"next_hop"`
LastUpdated time.Time `json:"last_updated"`
// IPv4 range fields for fast lookups (nil for IPv6)
V4IPStart *uint32 `json:"v4_ip_start,omitempty"`
V4IPEnd *uint32 `json:"v4_ip_end,omitempty"`
}
// PrefixDistribution represents the distribution of prefixes by mask length
@@ -64,3 +67,28 @@ type PrefixDistribution struct {
MaskLength int `json:"mask_length"`
Count int `json:"count"`
}
// ASInfo represents AS information for an IP lookup
type ASInfo struct {
ASN int `json:"asn"`
Handle string `json:"handle"`
Description string `json:"description"`
Prefix string `json:"prefix"`
LastUpdated time.Time `json:"last_updated"`
Age string `json:"age"`
}
// LiveRouteDeletion represents parameters for deleting a live route
type LiveRouteDeletion struct {
Prefix string
OriginASN int
PeerIP string
}
// PeerUpdate represents parameters for updating a peer
type PeerUpdate struct {
PeerIP string
PeerASN int
MessageType string
Timestamp time.Time
}

View File

@@ -29,15 +29,13 @@ CREATE TABLE IF NOT EXISTS announcements (
FOREIGN KEY (origin_asn_id) REFERENCES asns(id)
);
CREATE TABLE IF NOT EXISTS asn_peerings (
CREATE TABLE IF NOT EXISTS peerings (
id TEXT PRIMARY KEY,
from_asn_id TEXT NOT NULL,
to_asn_id TEXT NOT NULL,
as_a INTEGER NOT NULL,
as_b INTEGER NOT NULL,
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
FOREIGN KEY (from_asn_id) REFERENCES asns(id),
FOREIGN KEY (to_asn_id) REFERENCES asns(id),
UNIQUE(from_asn_id, to_asn_id)
UNIQUE(as_a, as_b)
);
-- BGP peers that send us messages
@@ -55,9 +53,9 @@ CREATE INDEX IF NOT EXISTS idx_prefixes_version_prefix ON prefixes(ip_version, p
CREATE INDEX IF NOT EXISTS idx_announcements_timestamp ON announcements(timestamp);
CREATE INDEX IF NOT EXISTS idx_announcements_prefix_id ON announcements(prefix_id);
CREATE INDEX IF NOT EXISTS idx_announcements_asn_id ON announcements(asn_id);
CREATE INDEX IF NOT EXISTS idx_asn_peerings_from_asn ON asn_peerings(from_asn_id);
CREATE INDEX IF NOT EXISTS idx_asn_peerings_to_asn ON asn_peerings(to_asn_id);
CREATE INDEX IF NOT EXISTS idx_asn_peerings_lookup ON asn_peerings(from_asn_id, to_asn_id);
CREATE INDEX IF NOT EXISTS idx_peerings_as_a ON peerings(as_a);
CREATE INDEX IF NOT EXISTS idx_peerings_as_b ON peerings(as_b);
CREATE INDEX IF NOT EXISTS idx_peerings_lookup ON peerings(as_a, as_b);
-- Additional indexes for prefixes table
CREATE INDEX IF NOT EXISTS idx_prefixes_prefix ON prefixes(prefix);
@@ -81,6 +79,9 @@ CREATE TABLE IF NOT EXISTS live_routes (
as_path TEXT NOT NULL, -- JSON array
next_hop TEXT NOT NULL,
last_updated DATETIME NOT NULL,
-- IPv4 range columns for fast lookups (NULL for IPv6)
v4_ip_start INTEGER, -- Start of IPv4 range as 32-bit unsigned int
v4_ip_end INTEGER, -- End of IPv4 range as 32-bit unsigned int
UNIQUE(prefix, origin_asn, peer_ip)
);
@@ -89,3 +90,5 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_prefix ON live_routes(prefix);
CREATE INDEX IF NOT EXISTS idx_live_routes_mask_length ON live_routes(mask_length);
CREATE INDEX IF NOT EXISTS idx_live_routes_ip_version_mask ON live_routes(ip_version, mask_length);
CREATE INDEX IF NOT EXISTS idx_live_routes_last_updated ON live_routes(last_updated);
-- Indexes for IPv4 range queries
CREATE INDEX IF NOT EXISTS idx_live_routes_ipv4_range ON live_routes(v4_ip_start, v4_ip_end) WHERE ip_version = 4;

View File

@@ -10,11 +10,6 @@ func generateUUID() uuid.UUID {
return uuid.New()
}
const (
ipVersionV4 = 4
ipVersionV6 = 6
)
// detectIPVersion determines if a prefix is IPv4 (returns 4) or IPv6 (returns 6)
func detectIPVersion(prefix string) int {
if strings.Contains(prefix, ":") {

View File

@@ -21,6 +21,10 @@ type Tracker struct {
byteCounter metrics.Counter
messageRate metrics.Meter
byteRate metrics.Meter
// Route update metrics
ipv4UpdateRate metrics.Meter
ipv6UpdateRate metrics.Meter
}
// New creates a new metrics tracker
@@ -33,6 +37,8 @@ func New() *Tracker {
byteCounter: metrics.NewCounter(),
messageRate: metrics.NewMeter(),
byteRate: metrics.NewMeter(),
ipv4UpdateRate: metrics.NewMeter(),
ipv6UpdateRate: metrics.NewMeter(),
}
}
@@ -89,6 +95,24 @@ func (t *Tracker) GetStreamMetrics() StreamMetrics {
}
}
// RecordIPv4Update records an IPv4 route update
func (t *Tracker) RecordIPv4Update() {
t.ipv4UpdateRate.Mark(1)
}
// RecordIPv6Update records an IPv6 route update
func (t *Tracker) RecordIPv6Update() {
t.ipv6UpdateRate.Mark(1)
}
// GetRouteMetrics returns current route update metrics
func (t *Tracker) GetRouteMetrics() RouteMetrics {
return RouteMetrics{
IPv4UpdatesPerSec: t.ipv4UpdateRate.Rate1(),
IPv6UpdatesPerSec: t.ipv6UpdateRate.Rate1(),
}
}
// StreamMetrics contains streaming statistics
type StreamMetrics struct {
TotalMessages uint64
@@ -98,3 +122,9 @@ type StreamMetrics struct {
MessagesPerSec float64
BitsPerSec float64
}
// RouteMetrics contains route update statistics
type RouteMetrics struct {
IPv4UpdatesPerSec float64
IPv6UpdatesPerSec float64
}

View File

@@ -5,7 +5,6 @@ package routewatch
import (
"context"
"fmt"
"os"
"sync"
"time"
@@ -13,25 +12,17 @@ import (
"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/routingtable"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/snapshotter"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"go.uber.org/fx"
)
const (
// routingTableStatsInterval is how often we log routing table statistics
routingTableStatsInterval = 15 * time.Second
)
// Dependencies contains all dependencies for RouteWatch
type Dependencies struct {
fx.In
DB database.Store
RoutingTable *routingtable.RoutingTable
Streamer *streamer.Streamer
Server *server.Server
Logger *logger.Logger
@@ -41,36 +32,23 @@ type Dependencies struct {
// RouteWatch represents the main application instance
type RouteWatch struct {
db database.Store
routingTable *routingtable.RoutingTable
streamer *streamer.Streamer
server *server.Server
snapshotter *snapshotter.Snapshotter
logger *logger.Logger
maxRuntime time.Duration
shutdown bool
mu sync.Mutex
config *config.Config
dbHandler *DBHandler
dbHandler *ASHandler
peerHandler *PeerHandler
prefixHandler *PrefixHandler
}
// isTruthy returns true if the value is considered truthy
// Empty string, "0", and "false" are considered falsy, everything else is truthy
func isTruthy(value string) bool {
return value != "" && value != "0" && value != "false"
}
// isSnapshotterEnabled checks if the snapshotter should be enabled based on environment variable
func isSnapshotterEnabled() bool {
return !isTruthy(os.Getenv("ROUTEWATCH_DISABLE_SNAPSHOTTER"))
peeringHandler *PeeringHandler
}
// New creates a new RouteWatch instance
func New(deps Dependencies) *RouteWatch {
rw := &RouteWatch{
db: deps.DB,
routingTable: deps.RoutingTable,
streamer: deps.Streamer,
server: deps.Server,
logger: deps.Logger,
@@ -78,17 +56,6 @@ func New(deps Dependencies) *RouteWatch {
config: deps.Config,
}
// Create snapshotter if enabled
if isSnapshotterEnabled() {
snap, err := snapshotter.New(deps.RoutingTable, deps.Config, deps.Logger)
if err != nil {
deps.Logger.Error("Failed to create snapshotter", "error", err)
// Continue without snapshotter
} else {
rw.snapshotter = snap
}
}
return rw
}
@@ -107,14 +74,22 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
// Register database handler to process BGP UPDATE messages
if rw.config.EnableBatchedDatabaseWrites {
rw.logger.Info("Using batched database handlers for improved performance")
rw.dbHandler = NewDBHandler(rw.db, rw.logger)
// ASHandler maintains the asns table
rw.dbHandler = NewASHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(rw.dbHandler)
// PeerHandler maintains the bgp_peers table
rw.peerHandler = NewPeerHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(rw.peerHandler)
// PrefixHandler maintains the prefixes and live_routes tables
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger)
rw.prefixHandler.SetMetricsTracker(rw.streamer.GetMetricsTracker())
rw.streamer.RegisterHandler(rw.prefixHandler)
// PeeringHandler maintains the asn_peerings table
rw.peeringHandler = NewPeeringHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(rw.peeringHandler)
} else {
// Non-batched handlers not implemented yet
rw.logger.Error("Non-batched handlers not implemented")
@@ -122,17 +97,7 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
return fmt.Errorf("non-batched handlers not implemented")
}
// Register routing table handler to maintain in-memory routing table
rtHandler := NewRoutingTableHandler(rw.routingTable, rw.logger)
rw.streamer.RegisterHandler(rtHandler)
// Start periodic routing table stats logging
go rw.logRoutingTableStats(ctx)
// Start snapshotter if available
if rw.snapshotter != nil {
rw.snapshotter.Start(ctx)
}
// No longer need routing table handler - PrefixHandler maintains live_routes table
// Start streaming
if err := rw.streamer.Start(); err != nil {
@@ -174,13 +139,14 @@ func (rw *RouteWatch) Shutdown() {
rw.logger.Info("Flushing prefix handler")
rw.prefixHandler.Stop()
}
if rw.peeringHandler != nil {
rw.logger.Info("Flushing peering handler")
rw.peeringHandler.Stop()
}
// Stop services
rw.streamer.Stop()
// Stop routing table expiration
rw.routingTable.Stop()
// Stop HTTP server with a timeout
const serverStopTimeout = 5 * time.Second
stopCtx, cancel := context.WithTimeout(context.Background(), serverStopTimeout)
@@ -199,43 +165,6 @@ func (rw *RouteWatch) Shutdown() {
"duration", time.Since(metrics.ConnectedSince),
)
// Take final snapshot before shutdown if snapshotter is available
if rw.snapshotter != nil {
rw.logger.Info("Taking final snapshot before shutdown")
if err := rw.snapshotter.Shutdown(); err != nil {
rw.logger.Error("Failed to shutdown snapshotter", "error", err)
} else {
rw.logger.Info("Final snapshot completed")
}
} else {
rw.logger.Info("No snapshotter available")
}
}
// 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,
)
}
}
}
// getModule provides all fx dependencies
@@ -249,7 +178,6 @@ func getModule() fx.Option {
database.New,
fx.As(new(database.Store)),
),
routingtable.New,
streamer.New,
server.New,
New,

View File

@@ -2,6 +2,7 @@ package routewatch
import (
"context"
"fmt"
"strings"
"sync"
"testing"
@@ -11,7 +12,6 @@ import (
"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/routingtable"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"github.com/google/uuid"
@@ -119,11 +119,16 @@ func (m *mockStore) RecordAnnouncement(_ *database.Announcement) error {
}
// RecordPeering mock implementation
func (m *mockStore) RecordPeering(fromASNID, toASNID string, _ time.Time) error {
func (m *mockStore) RecordPeering(asA, asB int, _ time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
key := fromASNID + "_" + toASNID
// Normalize
if asA > asB {
asA, asB = asB, asA
}
key := fmt.Sprintf("%d_%d", asA, asB)
if !m.Peerings[key] {
m.Peerings[key] = true
m.PeeringCount++
@@ -154,6 +159,7 @@ func (m *mockStore) GetStats() (database.Stats, error) {
IPv4Prefixes: m.IPv4Prefixes,
IPv6Prefixes: m.IPv6Prefixes,
Peerings: m.PeeringCount,
Peers: 10, // Mock peer count
}, nil
}
@@ -175,9 +181,105 @@ func (m *mockStore) GetPrefixDistribution() (ipv4 []database.PrefixDistribution,
return nil, nil, nil
}
// GetLiveRouteCounts mock implementation
func (m *mockStore) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
// Return mock counts
return m.RouteCount / 2, m.RouteCount / 2, nil
}
// GetASInfoForIP mock implementation
func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
// Simple mock - return a test AS
now := time.Now()
return &database.ASInfo{
ASN: 15169,
Handle: "GOOGLE",
Description: "Google LLC",
Prefix: "8.8.8.0/24",
LastUpdated: now.Add(-5 * time.Minute),
Age: "5m0s",
}, nil
}
// GetASDetails mock implementation
func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if ASN exists
if asnInfo, exists := m.ASNs[asn]; exists {
// Return empty prefixes for now
return asnInfo, []database.LiveRoute{}, nil
}
return nil, nil, database.ErrNoRoute
}
// GetPrefixDetails mock implementation
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
// Return empty routes for now
return []database.LiveRoute{}, nil
}
// UpsertLiveRouteBatch mock implementation
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, route := range routes {
// Track prefix
if _, exists := m.Prefixes[route.Prefix]; !exists {
m.Prefixes[route.Prefix] = &database.Prefix{
ID: uuid.New(),
Prefix: route.Prefix,
IPVersion: route.IPVersion,
FirstSeen: route.LastUpdated,
LastSeen: route.LastUpdated,
}
m.PrefixCount++
if route.IPVersion == 4 {
m.IPv4Prefixes++
} else {
m.IPv6Prefixes++
}
}
m.RouteCount++
}
return nil
}
// DeleteLiveRouteBatch mock implementation
func (m *mockStore) DeleteLiveRouteBatch(deletions []database.LiveRouteDeletion) error {
// Simple mock - just return nil
return nil
}
// GetOrCreateASNBatch mock implementation
func (m *mockStore) GetOrCreateASNBatch(asns map[int]time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
for number, timestamp := range asns {
if _, exists := m.ASNs[number]; !exists {
m.ASNs[number] = &database.ASN{
ID: uuid.New(),
Number: number,
FirstSeen: timestamp,
LastSeen: timestamp,
}
m.ASNCount++
}
}
return nil
}
// UpdatePeerBatch mock implementation
func (m *mockStore) UpdatePeerBatch(peers map[string]database.PeerUpdate) error {
// Simple mock - just return nil
return nil
}
func TestRouteWatchLiveFeed(t *testing.T) {
// Disable snapshotter for tests
t.Setenv("ROUTEWATCH_DISABLE_SNAPSHOTTER", "1")
// Create mock database
mockDB := newMockStore()
@@ -198,16 +300,12 @@ func TestRouteWatchLiveFeed(t *testing.T) {
EnableBatchedDatabaseWrites: true,
}
// Create routing table
rt := routingtable.New(cfg, logger)
// Create server
srv := server.New(mockDB, rt, s, logger)
srv := server.New(mockDB, s, logger)
// Create RouteWatch with 5 second limit
deps := Dependencies{
DB: mockDB,
RoutingTable: rt,
Streamer: s,
Server: srv,
Logger: logger,
@@ -224,6 +322,11 @@ func TestRouteWatchLiveFeed(t *testing.T) {
// Wait for the configured duration
time.Sleep(5 * time.Second)
// Force peering processing for test
if rw.peeringHandler != nil {
rw.peeringHandler.ProcessPeeringsNow()
}
// Get statistics
stats, err := mockDB.GetStats()
if err != nil {

View File

@@ -0,0 +1,161 @@
package routewatch
import (
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
const (
// asHandlerQueueSize is the queue capacity for ASN operations
asHandlerQueueSize = 100000
// asnBatchSize is the number of ASN operations to batch together
asnBatchSize = 10000
// asnBatchTimeout is the maximum time to wait before flushing a batch
asnBatchTimeout = 2 * time.Second
)
// ASHandler handles ASN information from BGP messages using batched operations
type ASHandler struct {
db database.Store
logger *logger.Logger
// Batching
mu sync.Mutex
batch []asnOp
lastFlush time.Time
stopCh chan struct{}
wg sync.WaitGroup
}
type asnOp struct {
number int
timestamp time.Time
}
// NewASHandler creates a new batched ASN handler
func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler {
h := &ASHandler{
db: db,
logger: logger,
batch: make([]asnOp, 0, asnBatchSize),
lastFlush: time.Now(),
stopCh: make(chan struct{}),
}
// Start the flush timer goroutine
h.wg.Add(1)
go h.flushLoop()
return h
}
// WantsMessage returns true if this handler wants to process messages of the given type
func (h *ASHandler) WantsMessage(messageType string) bool {
// We only care about UPDATE messages for the database
return messageType == "UPDATE"
}
// QueueCapacity returns the desired queue capacity for this handler
func (h *ASHandler) QueueCapacity() int {
// Batching allows us to use a larger queue
return asHandlerQueueSize
}
// HandleMessage processes a RIS message and queues database operations
func (h *ASHandler) 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()
// Queue origin ASN operation
if originASN > 0 {
h.batch = append(h.batch, asnOp{
number: originASN,
timestamp: timestamp,
})
}
// Also track all ASNs in the path
for _, asn := range msg.Path {
if asn > 0 {
h.batch = append(h.batch, asnOp{
number: asn,
timestamp: timestamp,
})
}
}
// Check if we need to flush
if len(h.batch) >= asnBatchSize {
h.flushBatchLocked()
}
}
// flushLoop runs in a goroutine and periodically flushes batches
func (h *ASHandler) flushLoop() {
defer h.wg.Done()
ticker := time.NewTicker(asnBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.mu.Lock()
if time.Since(h.lastFlush) >= asnBatchTimeout {
h.flushBatchLocked()
}
h.mu.Unlock()
case <-h.stopCh:
// Final flush
h.mu.Lock()
h.flushBatchLocked()
h.mu.Unlock()
return
}
}
}
// flushBatchLocked flushes the ASN batch to the database (must be called with mutex held)
func (h *ASHandler) flushBatchLocked() {
if len(h.batch) == 0 {
return
}
// Process ASNs first (deduped)
asnMap := make(map[int]time.Time)
for _, op := range h.batch {
if existing, ok := asnMap[op.number]; !ok || op.timestamp.After(existing) {
asnMap[op.number] = op.timestamp
}
}
// Process all ASNs in a single batch transaction
if err := h.db.GetOrCreateASNBatch(asnMap); err != nil {
h.logger.Error("Failed to process ASN batch", "error", err, "count", len(asnMap))
}
// Clear batch
h.batch = h.batch[:0]
h.lastFlush = time.Now()
}
// Stop gracefully stops the handler and flushes remaining batches
func (h *ASHandler) Stop() {
close(h.stopCh)
h.wg.Wait()
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"os"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
@@ -14,19 +16,53 @@ import (
const (
// shutdownTimeout is the maximum time allowed for graceful shutdown
shutdownTimeout = 60 * time.Second
// debugInterval is how often to log debug stats
debugInterval = 60 * time.Second
// bytesPerMB is bytes per megabyte
bytesPerMB = 1024 * 1024
)
// logDebugStats logs goroutine count and memory usage
func logDebugStats(logger *logger.Logger) {
// Only run if DEBUG env var contains "routewatch"
debugEnv := os.Getenv("DEBUG")
if !strings.Contains(debugEnv, "routewatch") {
return
}
ticker := time.NewTicker(debugInterval)
defer ticker.Stop()
for range ticker.C {
var m runtime.MemStats
runtime.ReadMemStats(&m)
logger.Debug("System stats",
"goroutines", runtime.NumGoroutine(),
"alloc_mb", m.Alloc/bytesPerMB,
"total_alloc_mb", m.TotalAlloc/bytesPerMB,
"sys_mb", m.Sys/bytesPerMB,
"num_gc", m.NumGC,
"heap_alloc_mb", m.HeapAlloc/bytesPerMB,
"heap_sys_mb", m.HeapSys/bytesPerMB,
"heap_idle_mb", m.HeapIdle/bytesPerMB,
"heap_inuse_mb", m.HeapInuse/bytesPerMB,
"heap_released_mb", m.HeapReleased/bytesPerMB,
"stack_inuse_mb", m.StackInuse/bytesPerMB,
)
}
}
// CLIEntry is the main entry point for the CLI
func CLIEntry() {
app := fx.New(
getModule(),
fx.StopTimeout(shutdownTimeout), // Allow 60 seconds for graceful shutdown
fx.Invoke(func(lc fx.Lifecycle, rw *RouteWatch, logger *logger.Logger) {
fx.Invoke(func(lc fx.Lifecycle, rw *RouteWatch, logger *logger.Logger, shutdowner fx.Shutdowner) {
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
go func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
OnStart: func(ctx context.Context) error {
// Start debug stats logging
go logDebugStats(logger)
// Handle shutdown signals
sigCh := make(chan os.Signal, 1)
@@ -35,9 +71,12 @@ func CLIEntry() {
go func() {
<-sigCh
logger.Info("Received shutdown signal")
cancel()
if err := shutdowner.Shutdown(); err != nil {
logger.Error("Failed to shutdown gracefully", "error", err)
}
}()
go func() {
if err := rw.Run(ctx); err != nil {
logger.Error("RouteWatch error", "error", err)
}

View File

@@ -1,263 +0,0 @@
package routewatch
import (
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
const (
// dbHandlerQueueSize is the queue capacity for database operations
dbHandlerQueueSize = 50000
// batchSize is the number of operations to batch together
batchSize = 32000
// batchTimeout is the maximum time to wait before flushing a batch
batchTimeout = 5 * time.Second
)
// DBHandler handles BGP messages and stores them in the database using batched operations
type DBHandler struct {
db database.Store
logger *logger.Logger
// Batching
mu sync.Mutex
prefixBatch []prefixOp
asnBatch []asnOp
peeringBatch []peeringOp
lastFlush time.Time
stopCh chan struct{}
wg sync.WaitGroup
}
type prefixOp struct {
prefix string
timestamp time.Time
}
type asnOp struct {
number int
timestamp time.Time
}
type peeringOp struct {
fromASN int
toASN int
timestamp time.Time
}
// NewDBHandler creates a new batched database handler
func NewDBHandler(
db database.Store,
logger *logger.Logger,
) *DBHandler {
h := &DBHandler{
db: db,
logger: logger,
prefixBatch: make([]prefixOp, 0, batchSize),
asnBatch: make([]asnOp, 0, batchSize),
peeringBatch: make([]peeringOp, 0, batchSize),
lastFlush: time.Now(),
stopCh: make(chan struct{}),
}
// Start the flush timer goroutine
h.wg.Add(1)
go h.flushLoop()
return h
}
// WantsMessage returns true if this handler wants to process messages of the given type
func (h *DBHandler) WantsMessage(messageType string) bool {
// We only care about UPDATE messages for the database
return messageType == "UPDATE"
}
// QueueCapacity returns the desired queue capacity for this handler
func (h *DBHandler) QueueCapacity() int {
// Batching allows us to use a larger queue
return dbHandlerQueueSize
}
// HandleMessage processes a RIS message and queues database operations
func (h *DBHandler) 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()
// Queue operations for announcements
for _, announcement := range msg.Announcements {
for _, prefix := range announcement.Prefixes {
// Queue prefix operation
h.prefixBatch = append(h.prefixBatch, prefixOp{
prefix: prefix,
timestamp: timestamp,
})
// Queue origin ASN operation
if originASN > 0 {
h.asnBatch = append(h.asnBatch, asnOp{
number: originASN,
timestamp: timestamp,
})
}
// Process AS path to queue peering operations
if len(msg.Path) > 1 {
for i := range len(msg.Path) - 1 {
fromASN := msg.Path[i]
toASN := msg.Path[i+1]
// Queue ASN operations
h.asnBatch = append(h.asnBatch, asnOp{
number: fromASN,
timestamp: timestamp,
})
h.asnBatch = append(h.asnBatch, asnOp{
number: toASN,
timestamp: timestamp,
})
// Queue peering operation
h.peeringBatch = append(h.peeringBatch, peeringOp{
fromASN: fromASN,
toASN: toASN,
timestamp: timestamp,
})
}
}
}
}
// Queue operations for withdrawals
for _, prefix := range msg.Withdrawals {
h.prefixBatch = append(h.prefixBatch, prefixOp{
prefix: prefix,
timestamp: timestamp,
})
}
// Check if we need to flush
if len(h.prefixBatch) >= batchSize || len(h.asnBatch) >= batchSize || len(h.peeringBatch) >= batchSize {
h.flushBatchesLocked()
}
}
// flushLoop runs in a goroutine and periodically flushes batches
func (h *DBHandler) flushLoop() {
defer h.wg.Done()
ticker := time.NewTicker(batchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.mu.Lock()
if time.Since(h.lastFlush) >= batchTimeout {
h.flushBatchesLocked()
}
h.mu.Unlock()
case <-h.stopCh:
// Final flush
h.mu.Lock()
h.flushBatchesLocked()
h.mu.Unlock()
return
}
}
}
// flushBatchesLocked flushes all batches to the database (must be called with mutex held)
func (h *DBHandler) flushBatchesLocked() {
if len(h.prefixBatch) == 0 && len(h.asnBatch) == 0 && len(h.peeringBatch) == 0 {
return
}
// Process ASNs first (deduped)
asnMap := make(map[int]time.Time)
for _, op := range h.asnBatch {
if existing, ok := asnMap[op.number]; !ok || op.timestamp.After(existing) {
asnMap[op.number] = op.timestamp
}
}
asnCache := make(map[int]*database.ASN)
for asn, ts := range asnMap {
asnObj, err := h.db.GetOrCreateASN(asn, ts)
if err != nil {
h.logger.Error("Failed to get/create ASN", "asn", asn, "error", err)
continue
}
asnCache[asn] = asnObj
}
// Process prefixes (deduped)
prefixMap := make(map[string]time.Time)
for _, op := range h.prefixBatch {
if existing, ok := prefixMap[op.prefix]; !ok || op.timestamp.After(existing) {
prefixMap[op.prefix] = op.timestamp
}
}
for prefix, ts := range prefixMap {
_, err := h.db.GetOrCreatePrefix(prefix, ts)
if err != nil {
h.logger.Error("Failed to get/create prefix", "prefix", prefix, "error", err)
}
}
// Process peerings (deduped)
type peeringKey struct {
from, to int
}
peeringMap := make(map[peeringKey]time.Time)
for _, op := range h.peeringBatch {
key := peeringKey{from: op.fromASN, to: op.toASN}
if existing, ok := peeringMap[key]; !ok || op.timestamp.After(existing) {
peeringMap[key] = op.timestamp
}
}
for key, ts := range peeringMap {
fromAS := asnCache[key.from]
toAS := asnCache[key.to]
if fromAS != nil && toAS != nil {
err := h.db.RecordPeering(fromAS.ID.String(), toAS.ID.String(), ts)
if err != nil {
h.logger.Error("Failed to record peering",
"from_asn", key.from,
"to_asn", key.to,
"error", err,
)
}
}
}
// Clear batches
h.prefixBatch = h.prefixBatch[:0]
h.asnBatch = h.asnBatch[:0]
h.peeringBatch = h.peeringBatch[:0]
h.lastFlush = time.Now()
}
// Stop gracefully stops the handler and flushes remaining batches
func (h *DBHandler) Stop() {
close(h.stopCh)
h.wg.Wait()
}

View File

@@ -12,13 +12,13 @@ import (
const (
// peerHandlerQueueSize is the queue capacity for peer tracking operations
peerHandlerQueueSize = 50000
peerHandlerQueueSize = 100000
// peerBatchSize is the number of peer updates to batch together
peerBatchSize = 500
peerBatchSize = 10000
// peerBatchTimeout is the maximum time to wait before flushing a batch
peerBatchTimeout = 5 * time.Second
peerBatchTimeout = 2 * time.Second
)
// PeerHandler tracks BGP peers from all message types using batched operations
@@ -135,18 +135,22 @@ func (h *PeerHandler) flushBatchLocked() {
}
}
// Apply updates
for _, update := range peerMap {
if err := h.db.UpdatePeer(update.peerIP, update.peerASN, update.messageType, update.timestamp); err != nil {
h.logger.Error("Failed to update peer",
"peer", update.peerIP,
"peer_asn", update.peerASN,
"message_type", update.messageType,
"error", err,
)
// Convert to database format
dbPeerMap := make(map[string]database.PeerUpdate)
for peerIP, update := range peerMap {
dbPeerMap[peerIP] = database.PeerUpdate{
PeerIP: update.peerIP,
PeerASN: update.peerASN,
MessageType: update.messageType,
Timestamp: update.timestamp,
}
}
// Process all peers in a single batch transaction
if err := h.db.UpdatePeerBatch(dbPeerMap); err != nil {
h.logger.Error("Failed to process peer batch", "error", err, "count", len(dbPeerMap))
}
// Clear batch
h.peerBatch = h.peerBatch[:0]
h.lastFlush = time.Now()

View File

@@ -0,0 +1,230 @@
package routewatch
import (
"encoding/json"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
const (
// peeringHandlerQueueSize is the queue capacity for peering operations
peeringHandlerQueueSize = 100000
// minPathLengthForPeering is the minimum AS path length to extract peerings
minPathLengthForPeering = 2
// pathExpirationTime is how long to keep AS paths in memory
pathExpirationTime = 30 * time.Minute
// peeringProcessInterval is how often to process AS paths into peerings
peeringProcessInterval = 2 * time.Minute
// pathPruneInterval is how often to prune old AS paths
pathPruneInterval = 5 * time.Minute
)
// PeeringHandler handles AS peering relationships from BGP path data
type PeeringHandler struct {
db database.Store
logger *logger.Logger
// In-memory AS path tracking
mu sync.RWMutex
asPaths map[string]time.Time // key is JSON-encoded AS path
stopCh chan struct{}
}
// NewPeeringHandler creates a new batched peering handler
func NewPeeringHandler(db database.Store, logger *logger.Logger) *PeeringHandler {
h := &PeeringHandler{
db: db,
logger: logger,
asPaths: make(map[string]time.Time),
stopCh: make(chan struct{}),
}
// Start the periodic processing goroutines
go h.processLoop()
go h.pruneLoop()
return h
}
// WantsMessage returns true if this handler wants to process messages of the given type
func (h *PeeringHandler) WantsMessage(messageType string) bool {
// We only care about UPDATE messages that have AS paths
return messageType == "UPDATE"
}
// QueueCapacity returns the desired queue capacity for this handler
func (h *PeeringHandler) QueueCapacity() int {
return peeringHandlerQueueSize
}
// HandleMessage processes a message to extract AS paths
func (h *PeeringHandler) HandleMessage(msg *ristypes.RISMessage) {
// Skip if no AS path or only one AS
if len(msg.Path) < minPathLengthForPeering {
return
}
timestamp := msg.ParsedTimestamp
// Encode AS path as JSON for use as map key
pathJSON, err := json.Marshal(msg.Path)
if err != nil {
h.logger.Error("Failed to encode AS path", "error", err)
return
}
h.mu.Lock()
h.asPaths[string(pathJSON)] = timestamp
h.mu.Unlock()
}
// processLoop runs periodically to process AS paths into peerings
func (h *PeeringHandler) processLoop() {
ticker := time.NewTicker(peeringProcessInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.processPeerings()
case <-h.stopCh:
// Final processing
h.processPeerings()
return
}
}
}
// pruneLoop runs periodically to remove old AS paths
func (h *PeeringHandler) pruneLoop() {
ticker := time.NewTicker(pathPruneInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.prunePaths()
case <-h.stopCh:
return
}
}
}
// prunePaths removes AS paths older than pathExpirationTime
func (h *PeeringHandler) prunePaths() {
cutoff := time.Now().Add(-pathExpirationTime)
var removed int
h.mu.Lock()
for pathKey, timestamp := range h.asPaths {
if timestamp.Before(cutoff) {
delete(h.asPaths, pathKey)
removed++
}
}
pathCount := len(h.asPaths)
h.mu.Unlock()
if removed > 0 {
h.logger.Debug("Pruned old AS paths", "removed", removed, "remaining", pathCount)
}
}
// ProcessPeeringsNow forces immediate processing of peerings (for testing)
func (h *PeeringHandler) ProcessPeeringsNow() {
h.processPeerings()
}
// processPeerings extracts peerings from AS paths and writes to database
func (h *PeeringHandler) processPeerings() {
// Take a snapshot of current AS paths
h.mu.RLock()
pathsCopy := make(map[string]time.Time, len(h.asPaths))
for k, v := range h.asPaths {
pathsCopy[k] = v
}
h.mu.RUnlock()
if len(pathsCopy) == 0 {
return
}
// Extract unique peerings from AS paths
type peeringKey struct {
low, high int
}
peerings := make(map[peeringKey]time.Time)
for pathJSON, timestamp := range pathsCopy {
var path []int
if err := json.Unmarshal([]byte(pathJSON), &path); err != nil {
h.logger.Error("Failed to decode AS path", "error", err)
continue
}
// Extract peerings from path
for i := range len(path) - 1 {
asn1 := path[i]
asn2 := path[i+1]
// Skip invalid ASNs
if asn1 <= 0 || asn2 <= 0 || asn1 == asn2 {
continue
}
// Normalize: lower AS number first
low, high := asn1, asn2
if low > high {
low, high = high, low
}
key := peeringKey{low: low, high: high}
// Update timestamp if this is newer
if existing, ok := peerings[key]; !ok || timestamp.After(existing) {
peerings[key] = timestamp
}
}
}
// Record peerings in database
start := time.Now()
successCount := 0
for key, ts := range peerings {
err := h.db.RecordPeering(key.low, key.high, ts)
if err != nil {
h.logger.Error("Failed to record peering",
"as_a", key.low,
"as_b", key.high,
"error", err,
)
} else {
successCount++
}
}
h.logger.Info("Processed AS peerings",
"paths", len(pathsCopy),
"unique_peerings", len(peerings),
"success", successCount,
"duration", time.Since(start),
)
}
// Stop gracefully stops the handler and processes remaining peerings
func (h *PeeringHandler) Stop() {
close(h.stopCh)
// Process any remaining peerings synchronously
h.processPeerings()
}

View File

@@ -8,19 +8,20 @@ import (
"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
prefixHandlerQueueSize = 50000
prefixHandlerQueueSize = 100000
// prefixBatchSize is the number of prefix updates to batch together
prefixBatchSize = 2000
prefixBatchSize = 5000
// prefixBatchTimeout is the maximum time to wait before flushing a batch
prefixBatchTimeout = 5 * time.Second
prefixBatchTimeout = 1 * time.Second
// IP version constants
ipv4Version = 4
@@ -32,6 +33,7 @@ const (
type PrefixHandler struct {
db database.Store
logger *logger.Logger
metrics *metrics.Tracker
// Batching
mu sync.Mutex
@@ -67,6 +69,11 @@ func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler {
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
@@ -156,6 +163,9 @@ func (h *PrefixHandler) flushBatchLocked() {
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)
@@ -166,27 +176,55 @@ func (h *PrefixHandler) flushBatchLocked() {
}
}
// Apply updates to database
// Collect routes to upsert and delete
var routesToUpsert []*database.LiveRoute
var routesToDelete []database.LiveRouteDeletion
// Skip the prefix table updates entirely - just update live_routes
// The prefix table is not critical for routing lookups
for _, update := range prefixMap {
// Get or create prefix
prefix, err := h.db.GetOrCreatePrefix(update.prefix, update.timestamp)
if err != nil {
h.logger.Error("Failed to get/create prefix",
"prefix", update.prefix,
"error", err,
)
continue
}
// For announcements, get ASN info and create announcement record
if update.messageType == "announcement" && update.originASN > 0 {
h.processAnnouncement(prefix, update)
// Create live route for batch upsert
route := h.createLiveRoute(update)
if route != nil {
routesToUpsert = append(routesToUpsert, route)
}
} else if update.messageType == "withdrawal" {
h.processWithdrawal(prefix, update)
// Create deletion record for batch delete
routesToDelete = append(routesToDelete, database.LiveRouteDeletion{
Prefix: update.prefix,
OriginASN: update.originASN,
PeerIP: update.peer,
})
}
}
// Process batch operations
successCount := 0
if len(routesToUpsert) > 0 {
if err := h.db.UpsertLiveRouteBatch(routesToUpsert); err != nil {
h.logger.Error("Failed to upsert route batch", "error", err, "count", len(routesToUpsert))
} else {
successCount += len(routesToUpsert)
}
}
if len(routesToDelete) > 0 {
if err := h.db.DeleteLiveRouteBatch(routesToDelete); err != nil {
h.logger.Error("Failed to delete route batch", "error", err, "count", len(routesToDelete))
} else {
successCount += len(routesToDelete)
}
}
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()
@@ -208,6 +246,7 @@ func parseCIDR(prefix string) (maskLength int, ipVersion int, err error) {
}
// processAnnouncement handles storing an announcement in the database
// nolint:unused // kept for potential future use
func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpdate) {
// Parse CIDR to get mask length
maskLength, ipVersion, err := parseCIDR(update.prefix)
@@ -220,6 +259,15 @@ func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpd
return
}
// 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(),
@@ -233,6 +281,20 @@ func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpd
LastUpdated: update.timestamp,
}
// For IPv4, calculate the IP range
if ipVersion == ipv4Version {
start, end, err := database.CalculateIPv4Range(update.prefix)
if err == nil {
liveRoute.V4IPStart = &start
liveRoute.V4IPEnd = &end
} else {
h.logger.Error("Failed to calculate IPv4 range",
"prefix", update.prefix,
"error", err,
)
}
}
if err := h.db.UpsertLiveRoute(liveRoute); err != nil {
h.logger.Error("Failed to upsert live route",
"prefix", update.prefix,
@@ -241,7 +303,143 @@ func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpd
}
}
// 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,
}
// For IPv4, calculate the IP range
if ipVersion == ipv4Version {
start, end, err := database.CalculateIPv4Range(update.prefix)
if err == nil {
liveRoute.V4IPStart = &start
liveRoute.V4IPEnd = &end
} else {
h.logger.Error("Failed to calculate IPv4 range",
"prefix", update.prefix,
"error", err,
)
}
}
return liveRoute
}
// processAnnouncementDirect handles storing an announcement directly without prefix table
// nolint:unused // kept for potential future use
func (h *PrefixHandler) processAnnouncementDirect(update prefixUpdate) {
// 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
}
// 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,
}
// For IPv4, calculate the IP range
if ipVersion == ipv4Version {
start, end, err := database.CalculateIPv4Range(update.prefix)
if err == nil {
liveRoute.V4IPStart = &start
liveRoute.V4IPEnd = &end
} else {
h.logger.Error("Failed to calculate IPv4 range",
"prefix", update.prefix,
"error", err,
)
}
}
if err := h.db.UpsertLiveRoute(liveRoute); err != nil {
h.logger.Error("Failed to upsert live route",
"prefix", update.prefix,
"error", err,
)
}
}
// processWithdrawalDirect handles removing a route directly without prefix table
// nolint:unused // kept for potential future use
func (h *PrefixHandler) processWithdrawalDirect(update prefixUpdate) {
// For withdrawals, we need to delete the route from live_routes
if update.originASN > 0 {
if err := h.db.DeleteLiveRoute(update.prefix, update.originASN, update.peer); err != nil {
h.logger.Error("Failed to delete live route",
"prefix", update.prefix,
"origin_asn", update.originASN,
"peer", update.peer,
"error", err,
)
}
} else {
// If no origin ASN, just delete all routes for this prefix from this peer
if err := h.db.DeleteLiveRoute(update.prefix, 0, update.peer); err != nil {
h.logger.Error("Failed to delete live route (no origin ASN)",
"prefix", update.prefix,
"peer", update.peer,
"error", err,
)
}
}
}
// processWithdrawal handles removing a route from the live routing table
// nolint:unused // kept for potential future use
func (h *PrefixHandler) processWithdrawal(_ *database.Prefix, update prefixUpdate) {
// For withdrawals, we need to delete the route from live_routes
// Since we have the origin ASN from the update, we can delete the specific route

View File

@@ -1,131 +0,0 @@
package routewatch
import (
"strconv"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routingtable"
"github.com/google/uuid"
)
const (
// routingTableHandlerQueueSize is the queue capacity for in-memory routing table operations
routingTableHandlerQueueSize = 10000
)
// RoutingTableHandler handles BGP messages and updates the in-memory routing table
type RoutingTableHandler struct {
rt *routingtable.RoutingTable
logger *logger.Logger
}
// NewRoutingTableHandler creates a new routing table handler
func NewRoutingTableHandler(rt *routingtable.RoutingTable, logger *logger.Logger) *RoutingTableHandler {
return &RoutingTableHandler{
rt: rt,
logger: logger,
}
}
// WantsMessage returns true if this handler wants to process messages of the given type
func (h *RoutingTableHandler) WantsMessage(messageType string) bool {
// We only care about UPDATE messages for the routing table
return messageType == "UPDATE"
}
// QueueCapacity returns the desired queue capacity for this handler
func (h *RoutingTableHandler) QueueCapacity() int {
// In-memory operations are very fast, so use a large queue
return routingTableHandlerQueueSize
}
// HandleMessage processes a RIS message and updates the routing table
func (h *RoutingTableHandler) HandleMessage(msg *ristypes.RISMessage) {
// Use the pre-parsed timestamp
timestamp := msg.ParsedTimestamp
// Parse peer ASN
peerASN, err := strconv.Atoi(msg.PeerASN)
if err != nil {
h.logger.Error("Failed to parse peer ASN", "peer_asn", msg.PeerASN, "error", err)
return
}
// Get origin ASN from path (last element)
var originASN int
if len(msg.Path) > 0 {
originASN = msg.Path[len(msg.Path)-1]
}
// Process announcements
for _, announcement := range msg.Announcements {
for _, prefix := range announcement.Prefixes {
// Generate deterministic UUIDs based on the prefix and origin ASN
// This ensures consistency across restarts
prefixID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(prefix))
originASNID := uuid.NewSHA1(uuid.NameSpaceOID, []byte(strconv.Itoa(originASN)))
// Create route for the routing table
route := &routingtable.Route{
PrefixID: prefixID,
Prefix: prefix,
OriginASNID: originASNID,
OriginASN: originASN,
PeerASN: peerASN,
ASPath: msg.Path,
NextHop: announcement.NextHop,
AnnouncedAt: timestamp,
}
// Add route to routing table
h.rt.AddRoute(route)
}
}
// Process withdrawals
for _, prefix := range msg.Withdrawals {
// Generate deterministic UUID for the prefix
prefixID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(prefix))
// Withdraw all routes for this prefix from this peer
h.rt.WithdrawRoutesByPrefixAndPeer(prefixID, peerASN)
}
}
// GetRoutingTableStats returns statistics about the routing table
func (h *RoutingTableHandler) GetRoutingTableStats() map[string]int {
return h.rt.Stats()
}
// GetActiveRouteCount returns the number of active routes
func (h *RoutingTableHandler) GetActiveRouteCount() int {
return h.rt.Size()
}
// GetRoutesByPrefix returns all routes for a specific prefix
func (h *RoutingTableHandler) GetRoutesByPrefix(prefixID uuid.UUID) []*routingtable.Route {
return h.rt.GetRoutesByPrefix(prefixID)
}
// GetRoutesByOriginASN returns all routes originated by a specific ASN
func (h *RoutingTableHandler) GetRoutesByOriginASN(originASNID uuid.UUID) []*routingtable.Route {
return h.rt.GetRoutesByOriginASN(originASNID)
}
// GetRoutesByPeerASN returns all routes received from a specific peer ASN
func (h *RoutingTableHandler) GetRoutesByPeerASN(peerASN int) []*routingtable.Route {
return h.rt.GetRoutesByPeerASN(peerASN)
}
// GetAllRoutes returns all active routes
func (h *RoutingTableHandler) GetAllRoutes() []*routingtable.Route {
return h.rt.GetAllRoutes()
}
// ClearRoutingTable clears all routes from the routing table
func (h *RoutingTableHandler) ClearRoutingTable() {
h.rt.Clear()
h.logger.Info("Cleared routing table")
}

View File

@@ -1,604 +0,0 @@
// Package routingtable provides a thread-safe in-memory representation of the DFZ routing table.
package routingtable
import (
"compress/gzip"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"github.com/google/uuid"
)
const (
// routeStalenessThreshold is how old a route can be before we consider it stale
// Using 30 minutes as a conservative value for snapshot loading
routeStalenessThreshold = 30 * time.Minute
// snapshotFilename is the name of the snapshot file
snapshotFilename = "routingtable.json.gz"
)
// Route represents a single route entry in the routing table
type Route struct {
PrefixID uuid.UUID `json:"prefix_id"`
Prefix string `json:"prefix"` // The actual prefix string (e.g., "10.0.0.0/8")
OriginASNID uuid.UUID `json:"origin_asn_id"`
OriginASN int `json:"origin_asn"` // The actual ASN number
PeerASN int `json:"peer_asn"`
ASPath []int `json:"as_path"` // Full AS path
NextHop string `json:"next_hop"`
AnnouncedAt time.Time `json:"announced_at"`
AddedAt time.Time `json:"added_at"` // When we added this route to our table
}
// RouteKey uniquely identifies a route in the table
type RouteKey struct {
PrefixID uuid.UUID
OriginASNID uuid.UUID
PeerASN int
}
// RoutingTable is a thread-safe in-memory routing table
type RoutingTable struct {
mu sync.RWMutex
routes map[RouteKey]*Route
// Secondary indexes for efficient lookups
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
// Configuration
snapshotDir string
routeExpirationTimeout time.Duration
logger *logger.Logger
// Expiration management
stopExpiration chan struct{}
}
// New creates a new routing table, loading from snapshot if available
func New(cfg *config.Config, logger *logger.Logger) *RoutingTable {
rt := &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),
lastMetricsReset: time.Now(),
snapshotDir: cfg.GetStateDir(),
routeExpirationTimeout: cfg.RouteExpirationTimeout,
logger: logger,
stopExpiration: make(chan struct{}),
}
// Try to load from snapshot
if err := rt.loadFromSnapshot(logger); err != nil {
logger.Warn("Failed to load routing table from snapshot", "error", err)
}
// Start expiration goroutine
go rt.expireRoutesLoop()
return rt
}
// AddRoute adds or updates a route in the routing table
func (rt *RoutingTable) AddRoute(route *Route) {
rt.mu.Lock()
defer rt.mu.Unlock()
key := RouteKey{
PrefixID: route.PrefixID,
OriginASNID: route.OriginASNID,
PeerASN: route.PeerASN,
}
// 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--
}
}
// Set AddedAt if not already set
if route.AddedAt.IsZero() {
route.AddedAt = time.Now().UTC()
}
// Add to main map
rt.routes[key] = 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
func (rt *RoutingTable) RemoveRoute(prefixID, originASNID uuid.UUID, peerASN int) bool {
rt.mu.Lock()
defer rt.mu.Unlock()
key := RouteKey{
PrefixID: prefixID,
OriginASNID: originASNID,
PeerASN: peerASN,
}
route, exists := rt.routes[key]
if !exists {
return false
}
// Remove from indexes
rt.removeFromIndexes(key, route)
// 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
}
// WithdrawRoutesByPrefixAndPeer removes all routes for a specific prefix from a specific peer
func (rt *RoutingTable) WithdrawRoutesByPrefixAndPeer(prefixID uuid.UUID, peerASN int) int {
rt.mu.Lock()
defer rt.mu.Unlock()
prefixRoutes, exists := rt.byPrefix[prefixID]
if !exists {
return 0
}
// 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
}
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)
}
}
return count
}
// GetRoute retrieves a specific route
func (rt *RoutingTable) GetRoute(prefixID, originASNID uuid.UUID, peerASN int) (*Route, bool) {
rt.mu.RLock()
defer rt.mu.RUnlock()
key := RouteKey{
PrefixID: prefixID,
OriginASNID: originASNID,
PeerASN: peerASN,
}
route, exists := rt.routes[key]
if !exists {
return nil, false
}
// Return a copy to prevent external modification
routeCopy := *route
return &routeCopy, true
}
// GetRoutesByPrefix returns all routes for a specific prefix
func (rt *RoutingTable) GetRoutesByPrefix(prefixID uuid.UUID) []*Route {
rt.mu.RLock()
defer rt.mu.RUnlock()
routes := make([]*Route, 0)
if prefixRoutes, exists := rt.byPrefix[prefixID]; exists {
for _, route := range prefixRoutes {
routeCopy := *route
routes = append(routes, &routeCopy)
}
}
return routes
}
// GetRoutesByOriginASN returns all routes originated by a specific ASN
func (rt *RoutingTable) GetRoutesByOriginASN(originASNID uuid.UUID) []*Route {
rt.mu.RLock()
defer rt.mu.RUnlock()
routes := make([]*Route, 0)
if asnRoutes, exists := rt.byOriginASN[originASNID]; exists {
for _, route := range asnRoutes {
routeCopy := *route
routes = append(routes, &routeCopy)
}
}
return routes
}
// GetRoutesByPeerASN returns all routes received from a specific peer ASN
func (rt *RoutingTable) GetRoutesByPeerASN(peerASN int) []*Route {
rt.mu.RLock()
defer rt.mu.RUnlock()
routes := make([]*Route, 0)
if peerRoutes, exists := rt.byPeerASN[peerASN]; exists {
for _, route := range peerRoutes {
routeCopy := *route
routes = append(routes, &routeCopy)
}
}
return routes
}
// GetAllRoutes returns all active routes in the routing table
func (rt *RoutingTable) GetAllRoutes() []*Route {
rt.mu.RLock()
defer rt.mu.RUnlock()
routes := make([]*Route, 0, len(rt.routes))
for _, route := range rt.routes {
routeCopy := *route
routes = append(routes, &routeCopy)
}
return routes
}
// Size returns the total number of routes in the table
func (rt *RoutingTable) Size() int {
rt.mu.RLock()
defer rt.mu.RUnlock()
return len(rt.routes)
}
// Stats returns statistics about the routing table
func (rt *RoutingTable) Stats() map[string]int {
rt.mu.RLock()
defer rt.mu.RUnlock()
stats := map[string]int{
"total_routes": len(rt.routes),
"unique_prefixes": len(rt.byPrefix),
"unique_origins": len(rt.byOriginASN),
"unique_peers": len(rt.byPeerASN),
}
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()
defer rt.mu.Unlock()
rt.routes = make(map[RouteKey]*Route)
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()
}
// RLock acquires a read lock on the routing table
// This is exposed for the snapshotter to use
func (rt *RoutingTable) RLock() {
rt.mu.RLock()
}
// RUnlock releases a read lock on the routing table
// This is exposed for the snapshotter to use
func (rt *RoutingTable) RUnlock() {
rt.mu.RUnlock()
}
// GetAllRoutesUnsafe returns all routes without copying
// IMPORTANT: Caller must hold RLock before calling this method
func (rt *RoutingTable) GetAllRoutesUnsafe() []*Route {
routes := make([]*Route, 0, len(rt.routes))
for _, route := range rt.routes {
routes = append(routes, route)
}
return routes
}
// Helper methods for index management
func (rt *RoutingTable) addToIndexes(key RouteKey, route *Route) {
// Add to prefix index
if rt.byPrefix[route.PrefixID] == nil {
rt.byPrefix[route.PrefixID] = make(map[RouteKey]*Route)
}
rt.byPrefix[route.PrefixID][key] = route
// Add to origin ASN index
if rt.byOriginASN[route.OriginASNID] == nil {
rt.byOriginASN[route.OriginASNID] = make(map[RouteKey]*Route)
}
rt.byOriginASN[route.OriginASNID][key] = route
// Add to peer ASN index
if rt.byPeerASN[route.PeerASN] == nil {
rt.byPeerASN[route.PeerASN] = make(map[RouteKey]*Route)
}
rt.byPeerASN[route.PeerASN][key] = route
}
func (rt *RoutingTable) removeFromIndexes(key RouteKey, route *Route) {
// Remove from prefix index
if prefixRoutes, exists := rt.byPrefix[route.PrefixID]; exists {
delete(prefixRoutes, key)
if len(prefixRoutes) == 0 {
delete(rt.byPrefix, route.PrefixID)
}
}
// Remove from origin ASN index
if asnRoutes, exists := rt.byOriginASN[route.OriginASNID]; exists {
delete(asnRoutes, key)
if len(asnRoutes) == 0 {
delete(rt.byOriginASN, route.OriginASNID)
}
}
// Remove from peer ASN index
if peerRoutes, exists := rt.byPeerASN[route.PeerASN]; exists {
delete(peerRoutes, key)
if len(peerRoutes) == 0 {
delete(rt.byPeerASN, route.PeerASN)
}
}
}
// String returns a string representation of the route key
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, ":")
}
// loadFromSnapshot attempts to load the routing table from a snapshot file
func (rt *RoutingTable) loadFromSnapshot(logger *logger.Logger) error {
// If no snapshot directory specified, nothing to load
if rt.snapshotDir == "" {
return nil
}
snapshotPath := filepath.Join(rt.snapshotDir, snapshotFilename)
// Check if snapshot file exists
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
// No snapshot file exists, this is normal - start with empty routing table
return nil
}
// Open the snapshot file
file, err := os.Open(filepath.Clean(snapshotPath))
if err != nil {
return fmt.Errorf("failed to open snapshot file: %w", err)
}
defer func() { _ = file.Close() }()
// Create gzip reader
gzReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() { _ = gzReader.Close() }()
// Decode the snapshot
var snapshot struct {
Timestamp time.Time `json:"timestamp"`
Stats DetailedStats `json:"stats"`
Routes []*Route `json:"routes"`
}
decoder := json.NewDecoder(gzReader)
if err := decoder.Decode(&snapshot); err != nil {
return fmt.Errorf("failed to decode snapshot: %w", err)
}
// Calculate staleness cutoff time
now := time.Now().UTC()
cutoffTime := now.Add(-routeStalenessThreshold)
// Load non-stale routes
loadedCount := 0
staleCount := 0
for _, route := range snapshot.Routes {
// Check if route is stale based on AddedAt time
if route.AddedAt.Before(cutoffTime) {
staleCount++
continue
}
// Add the route (this will update counters and indexes)
rt.AddRoute(route)
loadedCount++
}
logger.Info("Loaded routing table from snapshot",
"snapshot_time", snapshot.Timestamp,
"loaded_routes", loadedCount,
"stale_routes", staleCount,
"total_routes_in_snapshot", len(snapshot.Routes),
)
return nil
}
// expireRoutesLoop periodically removes expired routes
func (rt *RoutingTable) expireRoutesLoop() {
// Run every minute to check for expired routes
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rt.expireStaleRoutes()
case <-rt.stopExpiration:
return
}
}
}
// expireStaleRoutes removes routes that haven't been updated recently
func (rt *RoutingTable) expireStaleRoutes() {
rt.mu.Lock()
defer rt.mu.Unlock()
now := time.Now().UTC()
cutoffTime := now.Add(-rt.routeExpirationTimeout)
expiredCount := 0
// Collect keys to delete (can't delete while iterating)
var keysToDelete []RouteKey
for key, route := range rt.routes {
// Use AnnouncedAt as the last update time
if route.AnnouncedAt.Before(cutoffTime) {
keysToDelete = append(keysToDelete, key)
}
}
// Delete expired routes
for _, key := range keysToDelete {
route, exists := rt.routes[key]
if !exists {
continue
}
rt.removeFromIndexes(key, route)
delete(rt.routes, key)
expiredCount++
// Update metrics
if isIPv6(route.Prefix) {
rt.ipv6Routes--
} else {
rt.ipv4Routes--
}
}
if expiredCount > 0 {
rt.logger.Info("Expired stale routes",
"count", expiredCount,
"timeout", rt.routeExpirationTimeout,
"remaining_routes", len(rt.routes),
)
}
}
// Stop gracefully stops the routing table background tasks
func (rt *RoutingTable) Stop() {
if rt.stopExpiration != nil {
close(rt.stopExpiration)
}
}

View File

@@ -1,245 +0,0 @@
package routingtable
import (
"sync"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"github.com/google/uuid"
)
func TestRoutingTable(t *testing.T) {
// Create a test logger
logger := logger.New()
// Create test config with empty state dir (no snapshot loading)
cfg := &config.Config{
StateDir: "",
}
rt := New(cfg, logger)
// Test data
prefixID1 := uuid.New()
prefixID2 := uuid.New()
originASNID1 := uuid.New()
originASNID2 := uuid.New()
route1 := &Route{
PrefixID: prefixID1,
Prefix: "10.0.0.0/8",
OriginASNID: originASNID1,
OriginASN: 64512,
PeerASN: 64513,
ASPath: []int{64513, 64512},
NextHop: "192.168.1.1",
AnnouncedAt: time.Now(),
}
route2 := &Route{
PrefixID: prefixID2,
Prefix: "192.168.0.0/16",
OriginASNID: originASNID2,
OriginASN: 64514,
PeerASN: 64513,
ASPath: []int{64513, 64514},
NextHop: "192.168.1.1",
AnnouncedAt: time.Now(),
}
// Test AddRoute
rt.AddRoute(route1)
rt.AddRoute(route2)
if rt.Size() != 2 {
t.Errorf("Expected 2 routes, got %d", rt.Size())
}
// Test GetRoute
retrievedRoute, exists := rt.GetRoute(prefixID1, originASNID1, 64513)
if !exists {
t.Error("Route 1 should exist")
}
if retrievedRoute.Prefix != "10.0.0.0/8" {
t.Errorf("Expected prefix 10.0.0.0/8, got %s", retrievedRoute.Prefix)
}
// Test GetRoutesByPrefix
prefixRoutes := rt.GetRoutesByPrefix(prefixID1)
if len(prefixRoutes) != 1 {
t.Errorf("Expected 1 route for prefix, got %d", len(prefixRoutes))
}
// Test GetRoutesByPeerASN
peerRoutes := rt.GetRoutesByPeerASN(64513)
if len(peerRoutes) != 2 {
t.Errorf("Expected 2 routes from peer 64513, got %d", len(peerRoutes))
}
// Test RemoveRoute
removed := rt.RemoveRoute(prefixID1, originASNID1, 64513)
if !removed {
t.Error("Route should have been removed")
}
if rt.Size() != 1 {
t.Errorf("Expected 1 route after removal, got %d", rt.Size())
}
// Test WithdrawRoutesByPrefixAndPeer
// Add the route back first
rt.AddRoute(route1)
// Add another route for the same prefix from the same peer
route3 := &Route{
PrefixID: prefixID1,
Prefix: "10.0.0.0/8",
OriginASNID: originASNID2, // Different origin
OriginASN: 64515,
PeerASN: 64513,
ASPath: []int{64513, 64515},
NextHop: "192.168.1.1",
AnnouncedAt: time.Now(),
}
rt.AddRoute(route3)
count := rt.WithdrawRoutesByPrefixAndPeer(prefixID1, 64513)
if count != 2 {
t.Errorf("Expected to withdraw 2 routes, withdrew %d", count)
}
// Should only have route2 left
if rt.Size() != 1 {
t.Errorf("Expected 1 route after withdrawal, got %d", rt.Size())
}
// Test Stats
stats := rt.Stats()
if stats["total_routes"] != 1 {
t.Errorf("Expected 1 total route in stats, got %d", stats["total_routes"])
}
// Test Clear
rt.Clear()
if rt.Size() != 0 {
t.Errorf("Expected 0 routes after clear, got %d", rt.Size())
}
}
func TestRoutingTableConcurrency(t *testing.T) {
// Create a test logger
logger := logger.New()
// Create test config with empty state dir (no snapshot loading)
cfg := &config.Config{
StateDir: "",
}
rt := New(cfg, logger)
// Test concurrent access
var wg sync.WaitGroup
numGoroutines := 10
numOperations := 100
// Start multiple goroutines that add/remove routes
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
prefixID := uuid.New()
originASNID := uuid.New()
route := &Route{
PrefixID: prefixID,
Prefix: "10.0.0.0/8",
OriginASNID: originASNID,
OriginASN: 64512 + id,
PeerASN: 64500,
ASPath: []int{64500, 64512 + id},
NextHop: "192.168.1.1",
AnnouncedAt: time.Now(),
}
// Add route
rt.AddRoute(route)
// Try to get it
_, _ = rt.GetRoute(prefixID, originASNID, 64500)
// Get stats
_ = rt.Stats()
// Remove it
rt.RemoveRoute(prefixID, originASNID, 64500)
}
}(i)
}
wg.Wait()
// Table should be empty after all operations
if rt.Size() != 0 {
t.Errorf("Expected empty table after concurrent operations, got %d routes", rt.Size())
}
}
func TestRouteUpdate(t *testing.T) {
// Create a test logger
logger := logger.New()
// Create test config with empty state dir (no snapshot loading)
cfg := &config.Config{
StateDir: "",
}
rt := New(cfg, logger)
prefixID := uuid.New()
originASNID := uuid.New()
route1 := &Route{
PrefixID: prefixID,
Prefix: "10.0.0.0/8",
OriginASNID: originASNID,
OriginASN: 64512,
PeerASN: 64513,
ASPath: []int{64513, 64512},
NextHop: "192.168.1.1",
AnnouncedAt: time.Now(),
}
// Add initial route
rt.AddRoute(route1)
// Update the same route with new next hop
route2 := &Route{
PrefixID: prefixID,
Prefix: "10.0.0.0/8",
OriginASNID: originASNID,
OriginASN: 64512,
PeerASN: 64513,
ASPath: []int{64513, 64512},
NextHop: "192.168.1.2", // Changed
AnnouncedAt: time.Now().Add(1 * time.Minute),
}
rt.AddRoute(route2)
// Should still have only 1 route
if rt.Size() != 1 {
t.Errorf("Expected 1 route after update, got %d", rt.Size())
}
// Check that the route was updated
retrievedRoute, exists := rt.GetRoute(prefixID, originASNID, 64513)
if !exists {
t.Error("Route should exist after update")
}
if retrievedRoute.NextHop != "192.168.1.2" {
t.Errorf("Expected updated next hop 192.168.1.2, got %s", retrievedRoute.NextHop)
}
}

704
internal/server/handlers.go Normal file
View File

@@ -0,0 +1,704 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/url"
"runtime"
"sort"
"strconv"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/templates"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
)
// handleRoot returns a handler that redirects to /status
func (s *Server) handleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/status", http.StatusSeeOther)
}
}
// writeJSONError writes a standardized JSON error response
func writeJSONError(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"msg": message,
"code": statusCode,
},
})
}
// writeJSONSuccess writes a standardized JSON success response
func writeJSONSuccess(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"data": data,
})
}
// handleStatusJSON returns a handler that serves JSON statistics
func (s *Server) handleStatusJSON() http.HandlerFunc {
// Stats represents the statistics response
type Stats struct {
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
}
return func(w http.ResponseWriter, r *http.Request) {
// Create a 1 second timeout context for this request
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
metrics := s.streamer.GetMetrics()
// Get database stats with timeout
statsChan := make(chan database.Stats)
errChan := make(chan error)
go func() {
dbStats, err := s.db.GetStats()
if err != nil {
s.logger.Debug("Database stats query failed", "error", err)
errChan <- err
return
}
statsChan <- dbStats
}()
var dbStats database.Stats
select {
case <-ctx.Done():
s.logger.Error("Database stats timeout in status.json")
writeJSONError(w, http.StatusRequestTimeout, "Database timeout")
return
case err := <-errChan:
s.logger.Error("Failed to get database stats", "error", err)
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
case dbStats = <-statsChan:
// Success
}
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
if metrics.ConnectedSince.IsZero() {
uptime = "0s"
}
const bitsPerMegabit = 1000000.0
// Get route counts from database
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
if err != nil {
s.logger.Warn("Failed to get live route counts", "error", err)
// Continue with zero counts
}
// Get route update metrics
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
// Get memory stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
stats := Stats{
Uptime: uptime,
TotalMessages: metrics.TotalMessages,
TotalBytes: metrics.TotalBytes,
MessagesPerSec: metrics.MessagesPerSec,
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
Connected: metrics.Connected,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
ASNs: dbStats.ASNs,
Prefixes: dbStats.Prefixes,
IPv4Prefixes: dbStats.IPv4Prefixes,
IPv6Prefixes: dbStats.IPv6Prefixes,
Peerings: dbStats.Peerings,
Peers: dbStats.Peers,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: ipv4Routes,
IPv6Routes: ipv6Routes,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
}
if err := writeJSONSuccess(w, stats); err != nil {
s.logger.Error("Failed to encode stats", "error", err)
}
}
}
// handleStats returns a handler that serves API v1 statistics
func (s *Server) handleStats() http.HandlerFunc {
// HandlerStatsInfo represents handler statistics in the API response
type HandlerStatsInfo struct {
Name string `json:"name"`
QueueLength int `json:"queue_length"`
QueueCapacity int `json:"queue_capacity"`
ProcessedCount uint64 `json:"processed_count"`
DroppedCount uint64 `json:"dropped_count"`
AvgProcessTimeMs float64 `json:"avg_process_time_ms"`
MinProcessTimeMs float64 `json:"min_process_time_ms"`
MaxProcessTimeMs float64 `json:"max_process_time_ms"`
}
// StatsResponse represents the API statistics response
type StatsResponse struct {
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
}
return func(w http.ResponseWriter, r *http.Request) {
// Create a 1 second timeout context for this request
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
// Check if context is already cancelled
select {
case <-ctx.Done():
http.Error(w, "Request timeout", http.StatusRequestTimeout)
return
default:
}
metrics := s.streamer.GetMetrics()
// Get database stats with timeout
statsChan := make(chan database.Stats)
errChan := make(chan error)
go func() {
dbStats, err := s.db.GetStats()
if err != nil {
s.logger.Debug("Database stats query failed", "error", err)
errChan <- err
return
}
statsChan <- dbStats
}()
var dbStats database.Stats
select {
case <-ctx.Done():
s.logger.Error("Database stats timeout")
http.Error(w, "Database timeout", http.StatusRequestTimeout)
return
case err := <-errChan:
s.logger.Error("Failed to get database stats", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
case dbStats = <-statsChan:
// Success
}
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
if metrics.ConnectedSince.IsZero() {
uptime = "0s"
}
const bitsPerMegabit = 1000000.0
// Get route counts from database
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts()
if err != nil {
s.logger.Warn("Failed to get live route counts", "error", err)
// Continue with zero counts
}
// Get route update metrics
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
// Get handler stats
handlerStats := s.streamer.GetHandlerStats()
handlerStatsInfo := make([]HandlerStatsInfo, 0, len(handlerStats))
const microsecondsPerMillisecond = 1000.0
for _, hs := range handlerStats {
handlerStatsInfo = append(handlerStatsInfo, HandlerStatsInfo{
Name: hs.Name,
QueueLength: hs.QueueLength,
QueueCapacity: hs.QueueCapacity,
ProcessedCount: hs.ProcessedCount,
DroppedCount: hs.DroppedCount,
AvgProcessTimeMs: float64(hs.AvgProcessTime.Microseconds()) / microsecondsPerMillisecond,
MinProcessTimeMs: float64(hs.MinProcessTime.Microseconds()) / microsecondsPerMillisecond,
MaxProcessTimeMs: float64(hs.MaxProcessTime.Microseconds()) / microsecondsPerMillisecond,
})
}
// Get memory stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
stats := StatsResponse{
Uptime: uptime,
TotalMessages: metrics.TotalMessages,
TotalBytes: metrics.TotalBytes,
MessagesPerSec: metrics.MessagesPerSec,
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
Connected: metrics.Connected,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
ASNs: dbStats.ASNs,
Prefixes: dbStats.Prefixes,
IPv4Prefixes: dbStats.IPv4Prefixes,
IPv6Prefixes: dbStats.IPv6Prefixes,
Peerings: dbStats.Peerings,
Peers: dbStats.Peers,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: ipv4Routes,
IPv6Routes: ipv6Routes,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
HandlerStats: handlerStatsInfo,
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
}
if err := writeJSONSuccess(w, stats); err != nil {
s.logger.Error("Failed to encode stats", "error", err)
}
}
}
// handleStatusHTML returns a handler that serves the HTML status page
func (s *Server) handleStatusHTML() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.StatusTemplate()
if err := tmpl.Execute(w, nil); err != nil {
s.logger.Error("Failed to render template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// handleIPLookup returns a handler that looks up AS information for an IP address
func (s *Server) handleIPLookup() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := chi.URLParam(r, "ip")
if ip == "" {
writeJSONError(w, http.StatusBadRequest, "IP parameter is required")
return
}
// Look up AS information for the IP
asInfo, err := s.db.GetASInfoForIP(ip)
if err != nil {
// Check if it's an invalid IP error
if errors.Is(err, database.ErrInvalidIP) {
writeJSONError(w, http.StatusBadRequest, err.Error())
} else {
// All other errors (including ErrNoRoute) are 404
writeJSONError(w, http.StatusNotFound, err.Error())
}
return
}
// Return successful response
if err := writeJSONSuccess(w, asInfo); err != nil {
s.logger.Error("Failed to encode AS info", "error", err)
}
}
}
// handleASDetailJSON returns AS details as JSON
func (s *Server) handleASDetailJSON() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
asnStr := chi.URLParam(r, "asn")
asn, err := strconv.Atoi(asnStr)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid ASN")
return
}
asInfo, prefixes, err := s.db.GetASDetails(asn)
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
writeJSONError(w, http.StatusNotFound, err.Error())
} else {
writeJSONError(w, http.StatusInternalServerError, err.Error())
}
return
}
// Group prefixes by IP version
const ipVersionV4 = 4
var ipv4Prefixes, ipv6Prefixes []database.LiveRoute
for _, p := range prefixes {
if p.IPVersion == ipVersionV4 {
ipv4Prefixes = append(ipv4Prefixes, p)
} else {
ipv6Prefixes = append(ipv6Prefixes, p)
}
}
response := map[string]interface{}{
"asn": asInfo,
"ipv4_prefixes": ipv4Prefixes,
"ipv6_prefixes": ipv6Prefixes,
"total_count": len(prefixes),
}
if err := writeJSONSuccess(w, response); err != nil {
s.logger.Error("Failed to encode AS details", "error", err)
}
}
}
// handlePrefixDetailJSON returns prefix details as JSON
func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" {
writeJSONError(w, http.StatusBadRequest, "Prefix parameter is required")
return
}
// URL decode the prefix parameter
prefix, err := url.QueryUnescape(prefixParam)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid prefix parameter")
return
}
routes, err := s.db.GetPrefixDetails(prefix)
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
writeJSONError(w, http.StatusNotFound, err.Error())
} else {
writeJSONError(w, http.StatusInternalServerError, err.Error())
}
return
}
// Group by origin AS
originMap := make(map[int][]database.LiveRoute)
for _, route := range routes {
originMap[route.OriginASN] = append(originMap[route.OriginASN], route)
}
response := map[string]interface{}{
"prefix": prefix,
"routes": routes,
"origins": originMap,
"peer_count": len(routes),
"origin_count": len(originMap),
}
if err := writeJSONSuccess(w, response); err != nil {
s.logger.Error("Failed to encode prefix details", "error", err)
}
}
}
// handleASDetail returns a handler that serves the AS detail HTML page
func (s *Server) handleASDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
asnStr := chi.URLParam(r, "asn")
asn, err := strconv.Atoi(asnStr)
if err != nil {
http.Error(w, "Invalid ASN", http.StatusBadRequest)
return
}
asInfo, prefixes, err := s.db.GetASDetails(asn)
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "AS not found", http.StatusNotFound)
} else {
s.logger.Error("Failed to get AS details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Group prefixes by IP version
const ipVersionV4 = 4
var ipv4Prefixes, ipv6Prefixes []database.LiveRoute
for _, p := range prefixes {
if p.IPVersion == ipVersionV4 {
ipv4Prefixes = append(ipv4Prefixes, p)
} else {
ipv6Prefixes = append(ipv6Prefixes, p)
}
}
// Sort prefixes by network address
sort.Slice(ipv4Prefixes, func(i, j int) bool {
// Parse the prefixes to compare network addresses
ipI, netI, _ := net.ParseCIDR(ipv4Prefixes[i].Prefix)
ipJ, netJ, _ := net.ParseCIDR(ipv4Prefixes[j].Prefix)
// Compare by network address first
cmp := bytes.Compare(ipI.To4(), ipJ.To4())
if cmp != 0 {
return cmp < 0
}
// If network addresses are equal, compare by mask length
onesI, _ := netI.Mask.Size()
onesJ, _ := netJ.Mask.Size()
return onesI < onesJ
})
sort.Slice(ipv6Prefixes, func(i, j int) bool {
// Parse the prefixes to compare network addresses
ipI, netI, _ := net.ParseCIDR(ipv6Prefixes[i].Prefix)
ipJ, netJ, _ := net.ParseCIDR(ipv6Prefixes[j].Prefix)
// Compare by network address first
cmp := bytes.Compare(ipI.To16(), ipJ.To16())
if cmp != 0 {
return cmp < 0
}
// If network addresses are equal, compare by mask length
onesI, _ := netI.Mask.Size()
onesJ, _ := netJ.Mask.Size()
return onesI < onesJ
})
// Prepare template data
data := struct {
ASN *database.ASN
IPv4Prefixes []database.LiveRoute
IPv6Prefixes []database.LiveRoute
TotalCount int
IPv4Count int
IPv6Count int
}{
ASN: asInfo,
IPv4Prefixes: ipv4Prefixes,
IPv6Prefixes: ipv6Prefixes,
TotalCount: len(prefixes),
IPv4Count: len(ipv4Prefixes),
IPv6Count: len(ipv6Prefixes),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.ASDetailTemplate()
if err := tmpl.Execute(w, data); err != nil {
s.logger.Error("Failed to render AS detail template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// handlePrefixDetail returns a handler that serves the prefix detail HTML page
func (s *Server) handlePrefixDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" {
http.Error(w, "Prefix parameter is required", http.StatusBadRequest)
return
}
// URL decode the prefix parameter
prefix, err := url.QueryUnescape(prefixParam)
if err != nil {
http.Error(w, "Invalid prefix parameter", http.StatusBadRequest)
return
}
routes, err := s.db.GetPrefixDetails(prefix)
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "Prefix not found", http.StatusNotFound)
} else {
s.logger.Error("Failed to get prefix details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Group by origin AS and collect unique AS info
type ASNInfo struct {
Number int
Handle string
Description string
PeerCount int
}
originMap := make(map[int]*ASNInfo)
for _, route := range routes {
if _, exists := originMap[route.OriginASN]; !exists {
// Get AS info from database
asInfo, _, _ := s.db.GetASDetails(route.OriginASN)
handle := ""
description := ""
if asInfo != nil {
handle = asInfo.Handle
description = asInfo.Description
}
originMap[route.OriginASN] = &ASNInfo{
Number: route.OriginASN,
Handle: handle,
Description: description,
PeerCount: 0,
}
}
originMap[route.OriginASN].PeerCount++
}
// Get the first route to extract some common info
var maskLength, ipVersion int
if len(routes) > 0 {
// Parse CIDR to get mask length and IP version
_, ipNet, err := net.ParseCIDR(prefix)
if err == nil {
ones, _ := ipNet.Mask.Size()
maskLength = ones
if ipNet.IP.To4() != nil {
ipVersion = 4
} else {
ipVersion = 6
}
}
}
// Convert origin map to sorted slice
var origins []*ASNInfo
for _, origin := range originMap {
origins = append(origins, origin)
}
// Prepare template data
data := struct {
Prefix string
MaskLength int
IPVersion int
Routes []database.LiveRoute
Origins []*ASNInfo
PeerCount int
OriginCount int
}{
Prefix: prefix,
MaskLength: maskLength,
IPVersion: ipVersion,
Routes: routes,
Origins: origins,
PeerCount: len(routes),
OriginCount: len(originMap),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.PrefixDetailTemplate()
if err := tmpl.Execute(w, data); err != nil {
s.logger.Error("Failed to render prefix detail template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// handleIPRedirect looks up the prefix containing the IP and redirects to its detail page
func (s *Server) handleIPRedirect() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := chi.URLParam(r, "ip")
if ip == "" {
http.Error(w, "IP parameter is required", http.StatusBadRequest)
return
}
// Look up AS information for the IP (which includes the prefix)
asInfo, err := s.db.GetASInfoForIP(ip)
if err != nil {
if errors.Is(err, database.ErrInvalidIP) {
http.Error(w, "Invalid IP address", http.StatusBadRequest)
} else if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "No route found for this IP", http.StatusNotFound)
} else {
s.logger.Error("Failed to look up IP", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Redirect to the prefix detail page (URL encode the prefix)
http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther)
}
}

42
internal/server/routes.go Normal file
View File

@@ -0,0 +1,42 @@
package server
import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// setupRoutes configures the HTTP routes
func (s *Server) setupRoutes() {
r := chi.NewRouter()
// Middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
const requestTimeout = 2 * time.Second
r.Use(TimeoutMiddleware(requestTimeout))
r.Use(JSONResponseMiddleware)
// Routes
r.Get("/", s.handleRoot())
r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", s.handleStatusJSON())
// AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail())
r.Get("/prefix/{prefix}", s.handlePrefixDetail())
r.Get("/ip/{ip}", s.handleIPRedirect())
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Get("/stats", s.handleStats())
r.Get("/ip/{ip}", s.handleIPLookup())
r.Get("/as/{asn}", s.handleASDetailJSON())
r.Get("/prefix/{prefix}", s.handlePrefixDetailJSON())
})
s.router = r
}

View File

@@ -3,35 +3,29 @@ package server
import (
"context"
"encoding/json"
"net/http"
"os"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/routingtable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Server provides HTTP endpoints for status monitoring
type Server struct {
router *chi.Mux
db database.Store
routingTable *routingtable.RoutingTable
streamer *streamer.Streamer
logger *logger.Logger
srv *http.Server
}
// New creates a new HTTP server
func New(db database.Store, rt *routingtable.RoutingTable, streamer *streamer.Streamer, logger *logger.Logger) *Server {
func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger) *Server {
s := &Server{
db: db,
routingTable: rt,
streamer: streamer,
logger: logger,
}
@@ -41,32 +35,6 @@ func New(db database.Store, rt *routingtable.RoutingTable, streamer *streamer.St
return s
}
// setupRoutes configures the HTTP routes
func (s *Server) setupRoutes() {
r := chi.NewRouter()
// Middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
const requestTimeout = 2 * time.Second
r.Use(TimeoutMiddleware(requestTimeout))
r.Use(JSONResponseMiddleware)
// Routes
r.Get("/", s.handleRoot())
r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", s.handleStatusJSON())
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Get("/stats", s.handleStats())
})
s.router = r
}
// Start starts the HTTP server
func (s *Server) Start() error {
port := os.Getenv("PORT")
@@ -102,291 +70,3 @@ func (s *Server) Stop(ctx context.Context) error {
return s.srv.Shutdown(ctx)
}
// handleRoot returns a handler that redirects to /status
func (s *Server) handleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/status", http.StatusSeeOther)
}
}
// handleStatusJSON returns a handler that serves JSON statistics
func (s *Server) handleStatusJSON() http.HandlerFunc {
// Stats represents the statistics response
type Stats struct {
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
Connected bool `json:"connected"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
}
return func(w http.ResponseWriter, r *http.Request) {
// Create a 1 second timeout context for this request
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
metrics := s.streamer.GetMetrics()
// Get database stats with timeout
statsChan := make(chan database.Stats)
errChan := make(chan error)
go func() {
dbStats, err := s.db.GetStats()
if err != nil {
s.logger.Debug("Database stats query failed", "error", err)
errChan <- err
return
}
statsChan <- dbStats
}()
var dbStats database.Stats
select {
case <-ctx.Done():
s.logger.Error("Database stats timeout in status.json")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestTimeout)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"msg": "Database timeout",
"code": http.StatusRequestTimeout,
},
})
return
case err := <-errChan:
s.logger.Error("Failed to get database stats", "error", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": map[string]interface{}{
"msg": err.Error(),
"code": http.StatusInternalServerError,
},
})
return
case dbStats = <-statsChan:
// Success
}
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
if metrics.ConnectedSince.IsZero() {
uptime = "0s"
}
const bitsPerMegabit = 1000000.0
// Get detailed routing table stats
rtStats := s.routingTable.GetDetailedStats()
stats := Stats{
Uptime: uptime,
TotalMessages: metrics.TotalMessages,
TotalBytes: metrics.TotalBytes,
MessagesPerSec: metrics.MessagesPerSec,
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
Connected: metrics.Connected,
ASNs: dbStats.ASNs,
Prefixes: dbStats.Prefixes,
IPv4Prefixes: dbStats.IPv4Prefixes,
IPv6Prefixes: dbStats.IPv6Prefixes,
Peerings: dbStats.Peerings,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: rtStats.IPv4Routes,
IPv6Routes: rtStats.IPv6Routes,
IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate,
IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate,
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
}
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"status": "ok",
"data": stats,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
s.logger.Error("Failed to encode stats", "error", err)
}
}
}
// handleStats returns a handler that serves API v1 statistics
func (s *Server) handleStats() http.HandlerFunc {
// HandlerStatsInfo represents handler statistics in the API response
type HandlerStatsInfo struct {
Name string `json:"name"`
QueueLength int `json:"queue_length"`
QueueCapacity int `json:"queue_capacity"`
ProcessedCount uint64 `json:"processed_count"`
DroppedCount uint64 `json:"dropped_count"`
AvgProcessTimeMs float64 `json:"avg_process_time_ms"`
MinProcessTimeMs float64 `json:"min_process_time_ms"`
MaxProcessTimeMs float64 `json:"max_process_time_ms"`
}
// StatsResponse represents the API statistics response
type StatsResponse struct {
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
Connected bool `json:"connected"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
}
return func(w http.ResponseWriter, r *http.Request) {
// Create a 1 second timeout context for this request
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
// Check if context is already cancelled
select {
case <-ctx.Done():
http.Error(w, "Request timeout", http.StatusRequestTimeout)
return
default:
}
metrics := s.streamer.GetMetrics()
// Get database stats with timeout
statsChan := make(chan database.Stats)
errChan := make(chan error)
go func() {
dbStats, err := s.db.GetStats()
if err != nil {
s.logger.Debug("Database stats query failed", "error", err)
errChan <- err
return
}
statsChan <- dbStats
}()
var dbStats database.Stats
select {
case <-ctx.Done():
s.logger.Error("Database stats timeout")
http.Error(w, "Database timeout", http.StatusRequestTimeout)
return
case err := <-errChan:
s.logger.Error("Failed to get database stats", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
case dbStats = <-statsChan:
// Success
}
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
if metrics.ConnectedSince.IsZero() {
uptime = "0s"
}
const bitsPerMegabit = 1000000.0
// Get detailed routing table stats
rtStats := s.routingTable.GetDetailedStats()
// Get handler stats
handlerStats := s.streamer.GetHandlerStats()
handlerStatsInfo := make([]HandlerStatsInfo, 0, len(handlerStats))
const microsecondsPerMillisecond = 1000.0
for _, hs := range handlerStats {
handlerStatsInfo = append(handlerStatsInfo, HandlerStatsInfo{
Name: hs.Name,
QueueLength: hs.QueueLength,
QueueCapacity: hs.QueueCapacity,
ProcessedCount: hs.ProcessedCount,
DroppedCount: hs.DroppedCount,
AvgProcessTimeMs: float64(hs.AvgProcessTime.Microseconds()) / microsecondsPerMillisecond,
MinProcessTimeMs: float64(hs.MinProcessTime.Microseconds()) / microsecondsPerMillisecond,
MaxProcessTimeMs: float64(hs.MaxProcessTime.Microseconds()) / microsecondsPerMillisecond,
})
}
stats := StatsResponse{
Uptime: uptime,
TotalMessages: metrics.TotalMessages,
TotalBytes: metrics.TotalBytes,
MessagesPerSec: metrics.MessagesPerSec,
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
Connected: metrics.Connected,
ASNs: dbStats.ASNs,
Prefixes: dbStats.Prefixes,
IPv4Prefixes: dbStats.IPv4Prefixes,
IPv6Prefixes: dbStats.IPv6Prefixes,
Peerings: dbStats.Peerings,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: rtStats.IPv4Routes,
IPv6Routes: rtStats.IPv6Routes,
IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate,
IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate,
HandlerStats: handlerStatsInfo,
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
}
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"status": "ok",
"data": stats,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
s.logger.Error("Failed to encode stats", "error", err)
}
}
}
// handleStatusHTML returns a handler that serves the HTML status page
func (s *Server) handleStatusHTML() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.StatusTemplate()
if err := tmpl.Execute(w, nil); err != nil {
s.logger.Error("Failed to render template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}

View File

@@ -1,242 +0,0 @@
// Package snapshotter provides functionality for creating periodic and on-demand
// snapshots of the routing table state.
package snapshotter
import (
"compress/gzip"
"context"
"encoding/json"
"fmt"
"git.eeqj.de/sneak/routewatch/internal/logger"
"os"
"path/filepath"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/routingtable"
)
const (
snapshotInterval = 10 * time.Minute
snapshotFilename = "routingtable.json.gz"
tempFileSuffix = ".tmp"
)
// Snapshotter handles periodic and on-demand snapshots of the routing table
type Snapshotter struct {
rt *routingtable.RoutingTable
stateDir string
logger *logger.Logger
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex // Ensures only one snapshot runs at a time
wg sync.WaitGroup
lastSnapshot time.Time
}
// New creates a new Snapshotter instance
func New(rt *routingtable.RoutingTable, cfg *config.Config, logger *logger.Logger) (*Snapshotter, error) {
stateDir := cfg.GetStateDir()
// If state directory is specified, ensure it exists
if stateDir != "" {
const stateDirPerms = 0750
if err := os.MkdirAll(stateDir, stateDirPerms); err != nil {
return nil, fmt.Errorf("failed to create snapshot directory: %w", err)
}
}
s := &Snapshotter{
rt: rt,
stateDir: stateDir,
logger: logger,
}
return s, nil
}
// Start begins the periodic snapshot process
func (s *Snapshotter) Start(ctx context.Context) {
s.mu.Lock()
defer s.mu.Unlock()
if s.ctx != nil {
// Already started
return
}
ctx, cancel := context.WithCancel(ctx)
s.ctx = ctx
s.cancel = cancel
// Start periodic snapshot goroutine
s.wg.Add(1)
go s.periodicSnapshot()
}
// periodicSnapshot runs periodic snapshots
func (s *Snapshotter) periodicSnapshot() {
defer s.wg.Done()
ticker := time.NewTicker(snapshotInterval)
defer ticker.Stop()
// Wait for the first interval before taking any snapshots
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
if err := s.TakeSnapshot(); err != nil {
s.logger.Error("Failed to take periodic snapshot", "error", err)
}
}
}
}
// TakeSnapshot creates a snapshot of the current routing table state
func (s *Snapshotter) TakeSnapshot() error {
// Can't take snapshot without a state directory
if s.stateDir == "" {
return nil
}
// Ensure only one snapshot runs at a time
s.mu.Lock()
defer s.mu.Unlock()
start := time.Now()
s.logger.Info("Starting routing table snapshot")
// Get a copy of all routes while holding read lock
copyStart := time.Now()
s.rt.RLock()
routes := s.rt.GetAllRoutesUnsafe() // We'll need to add this method
s.rt.RUnlock()
// Get stats separately to avoid deadlock
stats := s.rt.GetDetailedStats()
s.logger.Info("Copied routes from routing table",
"duration", time.Since(copyStart),
"route_count", len(routes))
// Create snapshot data structure
snapshot := struct {
Timestamp time.Time `json:"timestamp"`
Stats routingtable.DetailedStats `json:"stats"`
Routes []*routingtable.Route `json:"routes"`
}{
Timestamp: time.Now().UTC(),
Stats: stats,
Routes: routes,
}
// Serialize to JSON
marshalStart := time.Now()
jsonData, err := json.Marshal(snapshot)
if err != nil {
return fmt.Errorf("failed to marshal snapshot: %w", err)
}
s.logger.Info("Marshaled snapshot to JSON",
"duration", time.Since(marshalStart),
"size_bytes", len(jsonData))
// Write compressed data to temporary file
tempPath := filepath.Join(s.stateDir, snapshotFilename+tempFileSuffix)
finalPath := filepath.Join(s.stateDir, snapshotFilename)
// Clean the paths to avoid any path traversal issues
tempPath = filepath.Clean(tempPath)
finalPath = filepath.Clean(finalPath)
tempFile, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer func() {
_ = tempFile.Close()
// Clean up temp file if it still exists
_ = os.Remove(tempPath)
}()
// Create gzip writer
gzipWriter := gzip.NewWriter(tempFile)
gzipWriter.Comment = fmt.Sprintf("RouteWatch snapshot taken at %s", snapshot.Timestamp.Format(time.RFC3339))
// Write compressed data
writeStart := time.Now()
if _, err := gzipWriter.Write(jsonData); err != nil {
return fmt.Errorf("failed to write compressed data: %w", err)
}
// Close gzip writer to flush all data
if err := gzipWriter.Close(); err != nil {
return fmt.Errorf("failed to close gzip writer: %w", err)
}
// Sync to disk
if err := tempFile.Sync(); err != nil {
return fmt.Errorf("failed to sync temporary file: %w", err)
}
// Close temp file before rename
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close temporary file: %w", err)
}
// Atomically rename temp file to final location
if err := os.Rename(tempPath, finalPath); err != nil {
return fmt.Errorf("failed to rename temporary file: %w", err)
}
s.logger.Info("Wrote compressed snapshot to disk",
"duration", time.Since(writeStart))
duration := time.Since(start)
s.lastSnapshot = time.Now()
s.logger.Info("Routing table snapshot completed",
"duration", duration,
"routes", len(routes),
"ipv4_routes", stats.IPv4Routes,
"ipv6_routes", stats.IPv6Routes,
"size_bytes", len(jsonData),
"path", finalPath,
)
return nil
}
// Shutdown performs a final snapshot and cleans up resources
func (s *Snapshotter) Shutdown() error {
s.logger.Info("Shutting down snapshotter")
// Cancel context to stop periodic snapshots
if s.cancel != nil {
s.cancel()
}
// Wait for periodic snapshot goroutine to finish
s.wg.Wait()
// Take final snapshot
if err := s.TakeSnapshot(); err != nil {
return fmt.Errorf("failed to take final snapshot: %w", err)
}
return nil
}
// GetLastSnapshotTime returns the time of the last successful snapshot
func (s *Snapshotter) GetLastSnapshotTime() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastSnapshot
}
// GetSnapshotPath returns the path to the snapshot file
func (s *Snapshotter) GetSnapshotPath() string {
return filepath.Join(s.stateDir, snapshotFilename)
}

View File

@@ -195,6 +195,11 @@ func (s *Streamer) GetMetrics() metrics.StreamMetrics {
return s.metrics.GetStreamMetrics()
}
// GetMetricsTracker returns the metrics tracker instance
func (s *Streamer) GetMetricsTracker() *metrics.Tracker {
return s.metrics
}
// HandlerStats represents metrics for a single handler
type HandlerStats struct {
Name string

View File

@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AS{{.ASN.Number}} - {{.ASN.Handle}} - RouteWatch</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 30px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.info-card {
background: #f8f9fa;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #3498db;
}
.info-label {
font-size: 14px;
color: #7f8c8d;
margin-bottom: 5px;
}
.info-value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.prefix-section {
margin-top: 30px;
}
.prefix-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.prefix-header h2 {
margin: 0;
color: #2c3e50;
}
.prefix-count {
background: #e74c3c;
color: white;
padding: 5px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.prefix-table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.prefix-table th {
background: #34495e;
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
}
.prefix-table td {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
.prefix-table tr:hover {
background: #f8f9fa;
}
.prefix-table tr:last-child td {
border-bottom: none;
}
.prefix-link {
color: #3498db;
text-decoration: none;
font-family: monospace;
}
.prefix-link:hover {
text-decoration: underline;
}
.age {
color: #7f8c8d;
font-size: 14px;
}
.nav-link {
display: inline-block;
margin-bottom: 20px;
color: #3498db;
text-decoration: none;
}
.nav-link:hover {
text-decoration: underline;
}
.empty-state {
text-align: center;
padding: 40px;
color: #7f8c8d;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
.info-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<a href="/status" class="nav-link">← Back to Status</a>
<h1>AS{{.ASN.Number}}{{if .ASN.Handle}} - {{.ASN.Handle}}{{end}}</h1>
{{if .ASN.Description}}
<p class="subtitle">{{.ASN.Description}}</p>
{{end}}
<div class="info-grid">
<div class="info-card">
<div class="info-label">Total Prefixes</div>
<div class="info-value">{{.TotalCount}}</div>
</div>
<div class="info-card">
<div class="info-label">IPv4 Prefixes</div>
<div class="info-value">{{.IPv4Count}}</div>
</div>
<div class="info-card">
<div class="info-label">IPv6 Prefixes</div>
<div class="info-value">{{.IPv6Count}}</div>
</div>
<div class="info-card">
<div class="info-label">First Seen</div>
<div class="info-value">{{.ASN.FirstSeen.Format "2006-01-02"}}</div>
</div>
</div>
{{if .IPv4Prefixes}}
<div class="prefix-section">
<div class="prefix-header">
<h2>IPv4 Prefixes</h2>
<span class="prefix-count">{{.IPv4Count}}</span>
</div>
<table class="prefix-table">
<thead>
<tr>
<th>Prefix</th>
<th>Mask Length</th>
<th>Last Updated</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{{range .IPv4Prefixes}}
<tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{if .IPv6Prefixes}}
<div class="prefix-section">
<div class="prefix-header">
<h2>IPv6 Prefixes</h2>
<span class="prefix-count">{{.IPv6Count}}</span>
</div>
<table class="prefix-table">
<thead>
<tr>
<th>Prefix</th>
<th>Mask Length</th>
<th>Last Updated</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{{range .IPv6Prefixes}}
<tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{if eq .TotalCount 0}}
<div class="empty-state">
<p>No prefixes announced by this AS</p>
</div>
{{end}}
</div>
</body>
</html>

View File

@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Prefix}} - RouteWatch</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
margin: 0 0 10px 0;
color: #2c3e50;
font-family: monospace;
font-size: 28px;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 30px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.info-card {
background: #f8f9fa;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #3498db;
}
.info-label {
font-size: 14px;
color: #7f8c8d;
margin-bottom: 5px;
}
.info-value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.routes-section {
margin-top: 30px;
}
.routes-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.routes-header h2 {
margin: 0;
color: #2c3e50;
}
.route-count {
background: #e74c3c;
color: white;
padding: 5px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.route-table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.route-table th {
background: #34495e;
color: white;
padding: 12px;
text-align: left;
font-weight: 600;
}
.route-table td {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
.route-table tr:hover {
background: #f8f9fa;
}
.route-table tr:last-child td {
border-bottom: none;
}
.as-link {
color: #3498db;
text-decoration: none;
}
.as-link:hover {
text-decoration: underline;
}
.peer-ip {
font-family: monospace;
font-size: 14px;
color: #555;
}
.as-path {
font-family: monospace;
font-size: 13px;
color: #666;
max-width: 300px;
overflow-x: auto;
white-space: nowrap;
}
.age {
color: #7f8c8d;
font-size: 14px;
}
.nav-link {
display: inline-block;
margin-bottom: 20px;
color: #3498db;
text-decoration: none;
}
.nav-link:hover {
text-decoration: underline;
}
.origins-section {
margin-top: 30px;
background: #f8f9fa;
padding: 20px;
border-radius: 6px;
}
.origins-section h3 {
margin-top: 0;
color: #2c3e50;
}
.origin-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.origin-item {
background: white;
padding: 10px 15px;
border-radius: 4px;
border: 1px solid #e0e0e0;
}
.empty-state {
text-align: center;
padding: 40px;
color: #7f8c8d;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
.info-grid {
grid-template-columns: 1fr;
}
.route-table {
font-size: 14px;
}
.as-path {
max-width: 150px;
}
}
</style>
</head>
<body>
<div class="container">
<a href="/status" class="nav-link">← Back to Status</a>
<h1>{{.Prefix}}</h1>
<p class="subtitle">IPv{{.IPVersion}} Prefix{{if .MaskLength}} • /{{.MaskLength}}{{end}}</p>
<div class="info-grid">
<div class="info-card">
<div class="info-label">Seen from Peers</div>
<div class="info-value">{{.PeerCount}}</div>
</div>
<div class="info-card">
<div class="info-label">Origin ASNs</div>
<div class="info-value">{{.OriginCount}}</div>
</div>
<div class="info-card">
<div class="info-label">IP Version</div>
<div class="info-value">IPv{{.IPVersion}}</div>
</div>
</div>
{{if .Origins}}
<div class="origins-section">
<h3>Origin ASNs</h3>
<div class="origin-list">
{{range .Origins}}
<div class="origin-item">
<a href="/as/{{.Number}}" class="as-link">AS{{.Number}}</a>
{{if .Handle}} ({{.Handle}}){{end}}
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
{{if .Routes}}
<div class="routes-section">
<div class="routes-header">
<h2>Route Details</h2>
<span class="route-count">{{.PeerCount}} route{{if ne .PeerCount 1}}s{{end}}</span>
</div>
<table class="route-table">
<thead>
<tr>
<th>Origin AS</th>
<th>Peer IP</th>
<th>AS Path</th>
<th>Next Hop</th>
<th>Last Updated</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{{range .Routes}}
<tr>
<td>
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
</td>
<td class="peer-ip">{{.PeerIP}}</td>
<td class="as-path">{{range $i, $as := .ASPath}}{{if $i}} → {{end}}{{$as}}{{end}}</td>
<td class="peer-ip">{{.NextHop}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<p>No routes found for this prefix</p>
</div>
{{end}}
</div>
</body>
</html>

View File

@@ -69,7 +69,7 @@
<div id="error" class="error" style="display: none;"></div>
<div class="status-grid">
<div class="status-card">
<h2>Connection Status</h2>
<h2>RouteWatch</h2>
<div class="metric">
<span class="metric-label">Status</span>
<span class="metric-value" id="connected">-</span>
@@ -78,6 +78,18 @@
<span class="metric-label">Uptime</span>
<span class="metric-value" id="uptime">-</span>
</div>
<div class="metric">
<span class="metric-label">Go Version</span>
<span class="metric-value" id="go_version">-</span>
</div>
<div class="metric">
<span class="metric-label">Goroutines</span>
<span class="metric-value" id="goroutines">-</span>
</div>
<div class="metric">
<span class="metric-label">Memory Usage</span>
<span class="metric-value" id="memory_usage">-</span>
</div>
</div>
<div class="status-card">
@@ -110,18 +122,14 @@
<span class="metric-label">Total Prefixes</span>
<span class="metric-value" id="prefixes">-</span>
</div>
<div class="metric">
<span class="metric-label">IPv4 Prefixes</span>
<span class="metric-value" id="ipv4_prefixes">-</span>
</div>
<div class="metric">
<span class="metric-label">IPv6 Prefixes</span>
<span class="metric-value" id="ipv6_prefixes">-</span>
</div>
<div class="metric">
<span class="metric-label">Peerings</span>
<span class="metric-value" id="peerings">-</span>
</div>
<div class="metric">
<span class="metric-label">Peers</span>
<span class="metric-value" id="peers">-</span>
</div>
<div class="metric">
<span class="metric-label">Database Size</span>
<span class="metric-value" id="database_size">-</span>
@@ -134,6 +142,14 @@
<span class="metric-label">Live Routes</span>
<span class="metric-value" id="live_routes">-</span>
</div>
<div class="metric">
<span class="metric-label">IPv4 Prefixes</span>
<span class="metric-value" id="ipv4_prefixes">-</span>
</div>
<div class="metric">
<span class="metric-label">IPv6 Prefixes</span>
<span class="metric-value" id="ipv6_prefixes">-</span>
</div>
<div class="metric">
<span class="metric-label">IPv4 Routes</span>
<span class="metric-value" id="ipv4_routes">-</span>
@@ -186,6 +202,18 @@
return num.toLocaleString();
}
function formatProcessingTime(ms) {
if (ms < 0.001) {
return (ms * 1000).toFixed(0) + ' µs';
} else if (ms < 0.01) {
return (ms * 1000).toFixed(1) + ' µs';
} else if (ms < 1) {
return ms.toFixed(3) + ' ms';
} else {
return ms.toFixed(2) + ' ms';
}
}
function updatePrefixDistribution(elementId, distribution) {
const container = document.getElementById(elementId);
container.innerHTML = '';
@@ -236,11 +264,11 @@
</div>
<div class="metric">
<span class="metric-label">Avg Time</span>
<span class="metric-value">${handler.avg_process_time_ms.toFixed(2)} ms</span>
<span class="metric-value">${formatProcessingTime(handler.avg_process_time_ms)}</span>
</div>
<div class="metric">
<span class="metric-label">Min/Max Time</span>
<span class="metric-value">${handler.min_process_time_ms.toFixed(2)} / ${handler.max_process_time_ms.toFixed(2)} ms</span>
<span class="metric-value">${formatProcessingTime(handler.min_process_time_ms)} / ${formatProcessingTime(handler.max_process_time_ms)}</span>
</div>
`;
@@ -269,6 +297,9 @@
// Update all metrics
document.getElementById('uptime').textContent = data.uptime;
document.getElementById('go_version').textContent = data.go_version;
document.getElementById('goroutines').textContent = formatNumber(data.goroutines);
document.getElementById('memory_usage').textContent = data.memory_usage;
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
document.getElementById('total_bytes').textContent = formatBytes(data.total_bytes);
@@ -278,6 +309,7 @@
document.getElementById('ipv4_prefixes').textContent = formatNumber(data.ipv4_prefixes);
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
document.getElementById('peerings').textContent = formatNumber(data.peerings);
document.getElementById('peers').textContent = formatNumber(data.peers);
document.getElementById('database_size').textContent = formatBytes(data.database_size_bytes);
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);

View File

@@ -4,15 +4,25 @@ package templates
import (
_ "embed"
"html/template"
"net/url"
"sync"
"time"
)
//go:embed status.html
var statusHTML string
//go:embed as_detail.html
var asDetailHTML string
//go:embed prefix_detail.html
var prefixDetailHTML string
// Templates contains all parsed templates
type Templates struct {
Status *template.Template
ASDetail *template.Template
PrefixDetail *template.Template
}
var (
@@ -22,17 +32,73 @@ var (
once sync.Once
)
const (
hoursPerDay = 24
daysPerMonth = 30
)
// timeSince returns a human-readable duration since the given time
func timeSince(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "just now"
}
if duration < time.Hour {
minutes := int(duration.Minutes())
if minutes == 1 {
return "1 minute ago"
}
return duration.Truncate(time.Minute).String() + " ago"
}
if duration < hoursPerDay*time.Hour {
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return duration.Truncate(time.Hour).String() + " ago"
}
days := int(duration.Hours() / hoursPerDay)
if days == 1 {
return "1 day ago"
}
if days < daysPerMonth {
return duration.Truncate(hoursPerDay*time.Hour).String() + " ago"
}
return t.Format("2006-01-02")
}
// initTemplates parses all embedded templates
func initTemplates() {
var err error
defaultTemplates = &Templates{}
// Create common template functions
funcs := template.FuncMap{
"timeSince": timeSince,
"urlEncode": url.QueryEscape,
}
// Parse status template
defaultTemplates.Status, err = template.New("status").Parse(statusHTML)
if err != nil {
panic("failed to parse status template: " + err.Error())
}
// Parse AS detail template
defaultTemplates.ASDetail, err = template.New("asDetail").Funcs(funcs).Parse(asDetailHTML)
if err != nil {
panic("failed to parse AS detail template: " + err.Error())
}
// Parse prefix detail template
defaultTemplates.PrefixDetail, err = template.New("prefixDetail").Funcs(funcs).Parse(prefixDetailHTML)
if err != nil {
panic("failed to parse prefix detail template: " + err.Error())
}
}
// Get returns the singleton Templates instance
@@ -46,3 +112,13 @@ func Get() *Templates {
func StatusTemplate() *template.Template {
return Get().Status
}
// ASDetailTemplate returns the parsed AS detail template
func ASDetailTemplate() *template.Template {
return Get().ASDetail
}
// PrefixDetailTemplate returns the parsed prefix detail template
func PrefixDetailTemplate() *template.Template {
return Get().PrefixDetail
}

270955
log.txt Normal file

File diff suppressed because it is too large Load Diff