Compare commits
28 Commits
eda90d96a9
...
optimize-s
| Author | SHA1 | Date | |
|---|---|---|---|
| a78e5c6e92 | |||
| 9ef2a22db3 | |||
| 05805b8847 | |||
| ddb3cfa4f0 | |||
| 3ef60459b2 | |||
| 40d7f0185b | |||
| b9b0792df9 | |||
| 21921a170c | |||
| 78d6e17c76 | |||
| 9b649c98c9 | |||
| 48db8b9edf | |||
| df31cf880a | |||
| af9ff258b1 | |||
| aeeb5e7d7d | |||
| 27ae80ea2e | |||
| 2fc24bb937 | |||
| 691710bc7c | |||
| afb916036c | |||
| 13047b5cb9 | |||
| ae89468a1b | |||
| d929f24f80 | |||
| cb1f4d9052 | |||
| bc640b0b37 | |||
| 7d814c9d2d | |||
| 54bb0ba1cb | |||
| 1157003db7 | |||
| eaa11b5f8d | |||
| 8b43882526 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,4 +34,5 @@ go.work.sum
|
|||||||
pkg/asinfo/asdata.json
|
pkg/asinfo/asdata.json
|
||||||
|
|
||||||
# Debug output files
|
# Debug output files
|
||||||
out
|
out
|
||||||
|
log.txt
|
||||||
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
go.uber.org/dig v1.19.0 // indirect
|
go.uber.org/dig v1.19.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,5 +1,7 @@
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
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=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||||
@@ -20,13 +24,32 @@ import (
|
|||||||
//go:embed schema.sql
|
//go:embed schema.sql
|
||||||
var dbSchema string
|
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.
|
// Database manages the SQLite database connection and operations.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
path string
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
lockedAt time.Time
|
||||||
|
lockedBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new database connection and initializes the schema.
|
// 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
|
// Add connection parameters for go-sqlite3
|
||||||
// Enable WAL mode and other performance optimizations
|
// Configure SQLite connection parameters
|
||||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&cache=shared", dbPath)
|
dsn := fmt.Sprintf(
|
||||||
|
"file:%s",
|
||||||
|
dbPath,
|
||||||
|
)
|
||||||
db, err := sql.Open("sqlite3", dsn)
|
db, err := sql.Open("sqlite3", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
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
|
// Set connection pool parameters
|
||||||
db.SetMaxOpenConns(1) // Force serialization since SQLite doesn't handle true concurrency well
|
// Multiple connections allow concurrent reads while writes are serialized
|
||||||
db.SetMaxIdleConns(1)
|
const maxConns = 10
|
||||||
|
db.SetMaxOpenConns(maxConns)
|
||||||
|
db.SetMaxIdleConns(maxConns)
|
||||||
db.SetConnMaxLifetime(0)
|
db.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
database := &Database{db: db, logger: logger, path: dbPath}
|
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.
|
// Initialize creates the database schema if it doesn't exist.
|
||||||
func (d *Database) Initialize() error {
|
func (d *Database) Initialize() error {
|
||||||
// Set SQLite pragmas for better performance
|
// Set SQLite pragmas for performance
|
||||||
pragmas := []string{
|
pragmas := []string{
|
||||||
"PRAGMA journal_mode=WAL", // Already set in connection string
|
"PRAGMA journal_mode=WAL", // Write-Ahead Logging
|
||||||
"PRAGMA synchronous=NORMAL", // Faster than FULL, still safe
|
"PRAGMA synchronous=OFF", // Don't wait for disk writes
|
||||||
"PRAGMA cache_size=-524288", // 512MB cache (negative = KB)
|
"PRAGMA cache_size=-3145728", // 3GB cache (upper limit for 2.4GB DB)
|
||||||
"PRAGMA temp_store=MEMORY", // Use memory for temp tables
|
"PRAGMA temp_store=MEMORY", // Use memory for temp tables
|
||||||
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
"PRAGMA wal_checkpoint(TRUNCATE)", // Checkpoint and truncate WAL now
|
||||||
"PRAGMA wal_autocheckpoint=1000", // Checkpoint every 1000 pages
|
"PRAGMA busy_timeout=5000", // 5 second busy timeout
|
||||||
"PRAGMA wal_checkpoint(PASSIVE)", // Checkpoint now
|
"PRAGMA analysis_limit=0", // Disable automatic ANALYZE
|
||||||
"PRAGMA optimize", // Run optimizer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, pragma := range pragmas {
|
for _, pragma := range pragmas {
|
||||||
@@ -89,8 +116,17 @@ func (d *Database) Initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := d.exec(dbSchema)
|
err := d.exec(dbSchema)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
// Close closes the database connection.
|
||||||
@@ -98,6 +134,33 @@ func (d *Database) Close() error {
|
|||||||
return d.db.Close()
|
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
|
// beginTx starts a new transaction with logging
|
||||||
func (d *Database) beginTx() (*loggingTx, error) {
|
func (d *Database) beginTx() (*loggingTx, error) {
|
||||||
tx, err := d.db.Begin()
|
tx, err := d.db.Begin()
|
||||||
@@ -108,8 +171,255 @@ func (d *Database) beginTx() (*loggingTx, error) {
|
|||||||
return &loggingTx{Tx: tx, logger: d.logger}, nil
|
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.
|
// 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) {
|
func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error) {
|
||||||
|
d.lock("GetOrCreateASN")
|
||||||
|
defer d.unlock()
|
||||||
|
|
||||||
tx, err := d.beginTx()
|
tx, err := d.beginTx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// 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) {
|
func (d *Database) GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error) {
|
||||||
|
d.lock("GetOrCreatePrefix")
|
||||||
|
defer d.unlock()
|
||||||
|
|
||||||
tx, err := d.beginTx()
|
tx, err := d.beginTx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// RecordAnnouncement inserts a new BGP announcement or withdrawal into the database.
|
||||||
func (d *Database) RecordAnnouncement(announcement *Announcement) error {
|
func (d *Database) RecordAnnouncement(announcement *Announcement) error {
|
||||||
|
d.lock("RecordAnnouncement")
|
||||||
|
defer d.unlock()
|
||||||
|
|
||||||
err := d.exec(`
|
err := d.exec(`
|
||||||
INSERT INTO announcements (id, prefix_id, asn_id, origin_asn_id, path, next_hop, timestamp, is_withdrawal)
|
INSERT INTO announcements (id, prefix_id, asn_id, origin_asn_id, path, next_hop, timestamp, is_withdrawal)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -254,7 +570,23 @@ func (d *Database) RecordAnnouncement(announcement *Announcement) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecordPeering records a peering relationship between two ASNs.
|
// 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()
|
tx, err := d.beginTx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -266,20 +598,20 @@ func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time)
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
var exists bool
|
var exists bool
|
||||||
err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM asn_peerings WHERE from_asn_id = ? AND to_asn_id = ?)",
|
err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM peerings WHERE as_a = ? AND as_b = ?)",
|
||||||
fromASNID, toASNID).Scan(&exists)
|
asA, asB).Scan(&exists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
_, err = tx.Exec("UPDATE asn_peerings SET last_seen = ? WHERE from_asn_id = ? AND to_asn_id = ?",
|
_, err = tx.Exec("UPDATE peerings SET last_seen = ? WHERE as_a = ? AND as_b = ?",
|
||||||
timestamp, fromASNID, toASNID)
|
timestamp, asA, asB)
|
||||||
} else {
|
} else {
|
||||||
_, err = tx.Exec(`
|
_, 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 (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
generateUUID().String(), fromASNID, toASNID, timestamp, timestamp)
|
generateUUID().String(), asA, asB, timestamp, timestamp)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -287,8 +619,8 @@ func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time)
|
|||||||
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
d.logger.Error("Failed to commit transaction for peering",
|
d.logger.Error("Failed to commit transaction for peering",
|
||||||
"from_asn_id", fromASNID,
|
"as_a", asA,
|
||||||
"to_asn_id", toASNID,
|
"as_b", asB,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -298,8 +630,77 @@ func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time)
|
|||||||
return nil
|
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
|
// UpdatePeer updates or creates a BGP peer record
|
||||||
func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
|
func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
|
||||||
|
d.lock("UpdatePeer")
|
||||||
|
defer d.unlock()
|
||||||
|
|
||||||
tx, err := d.beginTx()
|
tx, err := d.beginTx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -374,7 +775,13 @@ func (d *Database) GetStats() (Stats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count peerings
|
// 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 {
|
if err != nil {
|
||||||
return stats, err
|
return stats, err
|
||||||
}
|
}
|
||||||
@@ -406,15 +813,21 @@ func (d *Database) GetStats() (Stats, error) {
|
|||||||
|
|
||||||
// UpsertLiveRoute inserts or updates a live route
|
// UpsertLiveRoute inserts or updates a live route
|
||||||
func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
|
func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
|
||||||
|
d.lock("UpsertLiveRoute")
|
||||||
|
defer d.unlock()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop, last_updated)
|
INSERT INTO live_routes (id, prefix, mask_length, ip_version, origin_asn, peer_ip, as_path, next_hop,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
last_updated, v4_ip_start, v4_ip_end)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(prefix, origin_asn, peer_ip) DO UPDATE SET
|
ON CONFLICT(prefix, origin_asn, peer_ip) DO UPDATE SET
|
||||||
mask_length = excluded.mask_length,
|
mask_length = excluded.mask_length,
|
||||||
ip_version = excluded.ip_version,
|
ip_version = excluded.ip_version,
|
||||||
as_path = excluded.as_path,
|
as_path = excluded.as_path,
|
||||||
next_hop = excluded.next_hop,
|
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
|
// 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)
|
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,
|
_, err = d.db.Exec(query,
|
||||||
route.ID.String(),
|
route.ID.String(),
|
||||||
route.Prefix,
|
route.Prefix,
|
||||||
@@ -433,6 +855,8 @@ func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
|
|||||||
string(pathJSON),
|
string(pathJSON),
|
||||||
route.NextHop,
|
route.NextHop,
|
||||||
route.LastUpdated,
|
route.LastUpdated,
|
||||||
|
v4Start,
|
||||||
|
v4End,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -441,6 +865,9 @@ func (d *Database) UpsertLiveRoute(route *LiveRoute) error {
|
|||||||
// DeleteLiveRoute deletes a live route
|
// DeleteLiveRoute deletes a live route
|
||||||
// If originASN is 0, deletes all routes for the prefix/peer combination
|
// If originASN is 0, deletes all routes for the prefix/peer combination
|
||||||
func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string) error {
|
func (d *Database) DeleteLiveRoute(prefix string, originASN int, peerIP string) error {
|
||||||
|
d.lock("DeleteLiveRoute")
|
||||||
|
defer d.unlock()
|
||||||
|
|
||||||
var query string
|
var query string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -505,3 +932,298 @@ func (d *Database) GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []Pr
|
|||||||
|
|
||||||
return ipv4, ipv6, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
301
internal/database/database_test.go
Normal file
301
internal/database/database_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type Stats struct {
|
|||||||
IPv4Prefixes int
|
IPv4Prefixes int
|
||||||
IPv6Prefixes int
|
IPv6Prefixes int
|
||||||
Peerings int
|
Peerings int
|
||||||
|
Peers int
|
||||||
FileSizeBytes int64
|
FileSizeBytes int64
|
||||||
LiveRoutes int
|
LiveRoutes int
|
||||||
IPv4PrefixDistribution []PrefixDistribution
|
IPv4PrefixDistribution []PrefixDistribution
|
||||||
@@ -21,6 +22,7 @@ type Stats struct {
|
|||||||
type Store interface {
|
type Store interface {
|
||||||
// ASN operations
|
// ASN operations
|
||||||
GetOrCreateASN(number int, timestamp time.Time) (*ASN, error)
|
GetOrCreateASN(number int, timestamp time.Time) (*ASN, error)
|
||||||
|
GetOrCreateASNBatch(asns map[int]time.Time) error
|
||||||
|
|
||||||
// Prefix operations
|
// Prefix operations
|
||||||
GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error)
|
GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error)
|
||||||
@@ -29,18 +31,29 @@ type Store interface {
|
|||||||
RecordAnnouncement(announcement *Announcement) error
|
RecordAnnouncement(announcement *Announcement) error
|
||||||
|
|
||||||
// Peering operations
|
// Peering operations
|
||||||
RecordPeering(fromASNID, toASNID string, timestamp time.Time) error
|
RecordPeering(asA, asB int, timestamp time.Time) error
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
GetStats() (Stats, error)
|
GetStats() (Stats, error)
|
||||||
|
|
||||||
// Peer operations
|
// Peer operations
|
||||||
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
|
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
|
||||||
|
UpdatePeerBatch(peers map[string]PeerUpdate) error
|
||||||
|
|
||||||
// Live route operations
|
// Live route operations
|
||||||
UpsertLiveRoute(route *LiveRoute) error
|
UpsertLiveRoute(route *LiveRoute) error
|
||||||
|
UpsertLiveRouteBatch(routes []*LiveRoute) error
|
||||||
DeleteLiveRoute(prefix string, originASN int, peerIP string) error
|
DeleteLiveRoute(prefix string, originASN int, peerIP string) error
|
||||||
|
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
|
||||||
GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err 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
|
// Lifecycle
|
||||||
Close() error
|
Close() error
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ type LiveRoute struct {
|
|||||||
ASPath []int `json:"as_path"`
|
ASPath []int `json:"as_path"`
|
||||||
NextHop string `json:"next_hop"`
|
NextHop string `json:"next_hop"`
|
||||||
LastUpdated time.Time `json:"last_updated"`
|
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
|
// PrefixDistribution represents the distribution of prefixes by mask length
|
||||||
@@ -64,3 +67,28 @@ type PrefixDistribution struct {
|
|||||||
MaskLength int `json:"mask_length"`
|
MaskLength int `json:"mask_length"`
|
||||||
Count int `json:"count"`
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,15 +29,13 @@ CREATE TABLE IF NOT EXISTS announcements (
|
|||||||
FOREIGN KEY (origin_asn_id) REFERENCES asns(id)
|
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,
|
id TEXT PRIMARY KEY,
|
||||||
from_asn_id TEXT NOT NULL,
|
as_a INTEGER NOT NULL,
|
||||||
to_asn_id TEXT NOT NULL,
|
as_b INTEGER NOT NULL,
|
||||||
first_seen DATETIME NOT NULL,
|
first_seen DATETIME NOT NULL,
|
||||||
last_seen DATETIME NOT NULL,
|
last_seen DATETIME NOT NULL,
|
||||||
FOREIGN KEY (from_asn_id) REFERENCES asns(id),
|
UNIQUE(as_a, as_b)
|
||||||
FOREIGN KEY (to_asn_id) REFERENCES asns(id),
|
|
||||||
UNIQUE(from_asn_id, to_asn_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- BGP peers that send us messages
|
-- 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_timestamp ON announcements(timestamp);
|
||||||
CREATE INDEX IF NOT EXISTS idx_announcements_prefix_id ON announcements(prefix_id);
|
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_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_peerings_as_a ON peerings(as_a);
|
||||||
CREATE INDEX IF NOT EXISTS idx_asn_peerings_to_asn ON asn_peerings(to_asn_id);
|
CREATE INDEX IF NOT EXISTS idx_peerings_as_b ON peerings(as_b);
|
||||||
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_lookup ON peerings(as_a, as_b);
|
||||||
|
|
||||||
-- Additional indexes for prefixes table
|
-- Additional indexes for prefixes table
|
||||||
CREATE INDEX IF NOT EXISTS idx_prefixes_prefix ON prefixes(prefix);
|
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
|
as_path TEXT NOT NULL, -- JSON array
|
||||||
next_hop TEXT NOT NULL,
|
next_hop TEXT NOT NULL,
|
||||||
last_updated DATETIME 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)
|
UNIQUE(prefix, origin_asn, peer_ip)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,4 +89,6 @@ CREATE TABLE IF NOT EXISTS live_routes (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_live_routes_prefix ON live_routes(prefix);
|
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_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_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);
|
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;
|
||||||
@@ -10,11 +10,6 @@ func generateUUID() uuid.UUID {
|
|||||||
return uuid.New()
|
return uuid.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
ipVersionV4 = 4
|
|
||||||
ipVersionV6 = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
// detectIPVersion determines if a prefix is IPv4 (returns 4) or IPv6 (returns 6)
|
// detectIPVersion determines if a prefix is IPv4 (returns 4) or IPv6 (returns 6)
|
||||||
func detectIPVersion(prefix string) int {
|
func detectIPVersion(prefix string) int {
|
||||||
if strings.Contains(prefix, ":") {
|
if strings.Contains(prefix, ":") {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type Tracker struct {
|
|||||||
byteCounter metrics.Counter
|
byteCounter metrics.Counter
|
||||||
messageRate metrics.Meter
|
messageRate metrics.Meter
|
||||||
byteRate metrics.Meter
|
byteRate metrics.Meter
|
||||||
|
|
||||||
|
// Route update metrics
|
||||||
|
ipv4UpdateRate metrics.Meter
|
||||||
|
ipv6UpdateRate metrics.Meter
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new metrics tracker
|
// New creates a new metrics tracker
|
||||||
@@ -33,6 +37,8 @@ func New() *Tracker {
|
|||||||
byteCounter: metrics.NewCounter(),
|
byteCounter: metrics.NewCounter(),
|
||||||
messageRate: metrics.NewMeter(),
|
messageRate: metrics.NewMeter(),
|
||||||
byteRate: 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
|
// StreamMetrics contains streaming statistics
|
||||||
type StreamMetrics struct {
|
type StreamMetrics struct {
|
||||||
TotalMessages uint64
|
TotalMessages uint64
|
||||||
@@ -98,3 +122,9 @@ type StreamMetrics struct {
|
|||||||
MessagesPerSec float64
|
MessagesPerSec float64
|
||||||
BitsPerSec float64
|
BitsPerSec float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RouteMetrics contains route update statistics
|
||||||
|
type RouteMetrics struct {
|
||||||
|
IPv4UpdatesPerSec float64
|
||||||
|
IPv6UpdatesPerSec float64
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package routewatch
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,80 +12,48 @@ import (
|
|||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
"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/server"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/snapshotter"
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
// routingTableStatsInterval is how often we log routing table statistics
|
|
||||||
routingTableStatsInterval = 15 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// Dependencies contains all dependencies for RouteWatch
|
// Dependencies contains all dependencies for RouteWatch
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
DB database.Store
|
DB database.Store
|
||||||
RoutingTable *routingtable.RoutingTable
|
Streamer *streamer.Streamer
|
||||||
Streamer *streamer.Streamer
|
Server *server.Server
|
||||||
Server *server.Server
|
Logger *logger.Logger
|
||||||
Logger *logger.Logger
|
Config *config.Config
|
||||||
Config *config.Config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteWatch represents the main application instance
|
// RouteWatch represents the main application instance
|
||||||
type RouteWatch struct {
|
type RouteWatch struct {
|
||||||
db database.Store
|
db database.Store
|
||||||
routingTable *routingtable.RoutingTable
|
streamer *streamer.Streamer
|
||||||
streamer *streamer.Streamer
|
server *server.Server
|
||||||
server *server.Server
|
logger *logger.Logger
|
||||||
snapshotter *snapshotter.Snapshotter
|
maxRuntime time.Duration
|
||||||
logger *logger.Logger
|
shutdown bool
|
||||||
maxRuntime time.Duration
|
mu sync.Mutex
|
||||||
shutdown bool
|
config *config.Config
|
||||||
mu sync.Mutex
|
dbHandler *ASHandler
|
||||||
config *config.Config
|
peerHandler *PeerHandler
|
||||||
dbHandler *DBHandler
|
prefixHandler *PrefixHandler
|
||||||
peerHandler *PeerHandler
|
peeringHandler *PeeringHandler
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new RouteWatch instance
|
// New creates a new RouteWatch instance
|
||||||
func New(deps Dependencies) *RouteWatch {
|
func New(deps Dependencies) *RouteWatch {
|
||||||
rw := &RouteWatch{
|
rw := &RouteWatch{
|
||||||
db: deps.DB,
|
db: deps.DB,
|
||||||
routingTable: deps.RoutingTable,
|
streamer: deps.Streamer,
|
||||||
streamer: deps.Streamer,
|
server: deps.Server,
|
||||||
server: deps.Server,
|
logger: deps.Logger,
|
||||||
logger: deps.Logger,
|
maxRuntime: deps.Config.MaxRuntime,
|
||||||
maxRuntime: deps.Config.MaxRuntime,
|
config: deps.Config,
|
||||||
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
|
return rw
|
||||||
@@ -107,14 +74,22 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
|||||||
// Register database handler to process BGP UPDATE messages
|
// Register database handler to process BGP UPDATE messages
|
||||||
if rw.config.EnableBatchedDatabaseWrites {
|
if rw.config.EnableBatchedDatabaseWrites {
|
||||||
rw.logger.Info("Using batched database handlers for improved performance")
|
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)
|
rw.streamer.RegisterHandler(rw.dbHandler)
|
||||||
|
|
||||||
|
// PeerHandler maintains the bgp_peers table
|
||||||
rw.peerHandler = NewPeerHandler(rw.db, rw.logger)
|
rw.peerHandler = NewPeerHandler(rw.db, rw.logger)
|
||||||
rw.streamer.RegisterHandler(rw.peerHandler)
|
rw.streamer.RegisterHandler(rw.peerHandler)
|
||||||
|
|
||||||
|
// PrefixHandler maintains the prefixes and live_routes tables
|
||||||
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger)
|
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger)
|
||||||
|
rw.prefixHandler.SetMetricsTracker(rw.streamer.GetMetricsTracker())
|
||||||
rw.streamer.RegisterHandler(rw.prefixHandler)
|
rw.streamer.RegisterHandler(rw.prefixHandler)
|
||||||
|
|
||||||
|
// PeeringHandler maintains the asn_peerings table
|
||||||
|
rw.peeringHandler = NewPeeringHandler(rw.db, rw.logger)
|
||||||
|
rw.streamer.RegisterHandler(rw.peeringHandler)
|
||||||
} else {
|
} else {
|
||||||
// Non-batched handlers not implemented yet
|
// Non-batched handlers not implemented yet
|
||||||
rw.logger.Error("Non-batched handlers not implemented")
|
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")
|
return fmt.Errorf("non-batched handlers not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register routing table handler to maintain in-memory routing table
|
// No longer need routing table handler - PrefixHandler maintains live_routes 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start streaming
|
// Start streaming
|
||||||
if err := rw.streamer.Start(); err != nil {
|
if err := rw.streamer.Start(); err != nil {
|
||||||
@@ -174,13 +139,14 @@ func (rw *RouteWatch) Shutdown() {
|
|||||||
rw.logger.Info("Flushing prefix handler")
|
rw.logger.Info("Flushing prefix handler")
|
||||||
rw.prefixHandler.Stop()
|
rw.prefixHandler.Stop()
|
||||||
}
|
}
|
||||||
|
if rw.peeringHandler != nil {
|
||||||
|
rw.logger.Info("Flushing peering handler")
|
||||||
|
rw.peeringHandler.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// Stop services
|
// Stop services
|
||||||
rw.streamer.Stop()
|
rw.streamer.Stop()
|
||||||
|
|
||||||
// Stop routing table expiration
|
|
||||||
rw.routingTable.Stop()
|
|
||||||
|
|
||||||
// Stop HTTP server with a timeout
|
// Stop HTTP server with a timeout
|
||||||
const serverStopTimeout = 5 * time.Second
|
const serverStopTimeout = 5 * time.Second
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), serverStopTimeout)
|
stopCtx, cancel := context.WithTimeout(context.Background(), serverStopTimeout)
|
||||||
@@ -199,43 +165,6 @@ func (rw *RouteWatch) Shutdown() {
|
|||||||
"duration", time.Since(metrics.ConnectedSince),
|
"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
|
// getModule provides all fx dependencies
|
||||||
@@ -249,7 +178,6 @@ func getModule() fx.Option {
|
|||||||
database.New,
|
database.New,
|
||||||
fx.As(new(database.Store)),
|
fx.As(new(database.Store)),
|
||||||
),
|
),
|
||||||
routingtable.New,
|
|
||||||
streamer.New,
|
streamer.New,
|
||||||
server.New,
|
server.New,
|
||||||
New,
|
New,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package routewatch
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -11,7 +12,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
"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/server"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -119,11 +119,16 @@ func (m *mockStore) RecordAnnouncement(_ *database.Announcement) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecordPeering mock implementation
|
// 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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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] {
|
if !m.Peerings[key] {
|
||||||
m.Peerings[key] = true
|
m.Peerings[key] = true
|
||||||
m.PeeringCount++
|
m.PeeringCount++
|
||||||
@@ -154,6 +159,7 @@ func (m *mockStore) GetStats() (database.Stats, error) {
|
|||||||
IPv4Prefixes: m.IPv4Prefixes,
|
IPv4Prefixes: m.IPv4Prefixes,
|
||||||
IPv6Prefixes: m.IPv6Prefixes,
|
IPv6Prefixes: m.IPv6Prefixes,
|
||||||
Peerings: m.PeeringCount,
|
Peerings: m.PeeringCount,
|
||||||
|
Peers: 10, // Mock peer count
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,9 +181,105 @@ func (m *mockStore) GetPrefixDistribution() (ipv4 []database.PrefixDistribution,
|
|||||||
return nil, nil, nil
|
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) {
|
func TestRouteWatchLiveFeed(t *testing.T) {
|
||||||
// Disable snapshotter for tests
|
|
||||||
t.Setenv("ROUTEWATCH_DISABLE_SNAPSHOTTER", "1")
|
|
||||||
|
|
||||||
// Create mock database
|
// Create mock database
|
||||||
mockDB := newMockStore()
|
mockDB := newMockStore()
|
||||||
@@ -198,20 +300,16 @@ func TestRouteWatchLiveFeed(t *testing.T) {
|
|||||||
EnableBatchedDatabaseWrites: true,
|
EnableBatchedDatabaseWrites: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create routing table
|
|
||||||
rt := routingtable.New(cfg, logger)
|
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
srv := server.New(mockDB, rt, s, logger)
|
srv := server.New(mockDB, s, logger)
|
||||||
|
|
||||||
// Create RouteWatch with 5 second limit
|
// Create RouteWatch with 5 second limit
|
||||||
deps := Dependencies{
|
deps := Dependencies{
|
||||||
DB: mockDB,
|
DB: mockDB,
|
||||||
RoutingTable: rt,
|
Streamer: s,
|
||||||
Streamer: s,
|
Server: srv,
|
||||||
Server: srv,
|
Logger: logger,
|
||||||
Logger: logger,
|
Config: cfg,
|
||||||
Config: cfg,
|
|
||||||
}
|
}
|
||||||
rw := New(deps)
|
rw := New(deps)
|
||||||
|
|
||||||
@@ -224,6 +322,11 @@ func TestRouteWatchLiveFeed(t *testing.T) {
|
|||||||
// Wait for the configured duration
|
// Wait for the configured duration
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
// Force peering processing for test
|
||||||
|
if rw.peeringHandler != nil {
|
||||||
|
rw.peeringHandler.ProcessPeeringsNow()
|
||||||
|
}
|
||||||
|
|
||||||
// Get statistics
|
// Get statistics
|
||||||
stats, err := mockDB.GetStats()
|
stats, err := mockDB.GetStats()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
161
internal/routewatch/ashandler.go
Normal file
161
internal/routewatch/ashandler.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,30 +16,67 @@ import (
|
|||||||
const (
|
const (
|
||||||
// shutdownTimeout is the maximum time allowed for graceful shutdown
|
// shutdownTimeout is the maximum time allowed for graceful shutdown
|
||||||
shutdownTimeout = 60 * time.Second
|
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
|
// CLIEntry is the main entry point for the CLI
|
||||||
func CLIEntry() {
|
func CLIEntry() {
|
||||||
app := fx.New(
|
app := fx.New(
|
||||||
getModule(),
|
getModule(),
|
||||||
fx.StopTimeout(shutdownTimeout), // Allow 60 seconds for graceful shutdown
|
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{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
|
// Start debug stats logging
|
||||||
|
go logDebugStats(logger)
|
||||||
|
|
||||||
|
// Handle shutdown signals
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
<-sigCh
|
||||||
defer cancel()
|
logger.Info("Received shutdown signal")
|
||||||
|
if err := shutdowner.Shutdown(); err != nil {
|
||||||
// Handle shutdown signals
|
logger.Error("Failed to shutdown gracefully", "error", err)
|
||||||
sigCh := make(chan os.Signal, 1)
|
}
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
}()
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-sigCh
|
|
||||||
logger.Info("Received shutdown signal")
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
|
go func() {
|
||||||
if err := rw.Run(ctx); err != nil {
|
if err := rw.Run(ctx); err != nil {
|
||||||
logger.Error("RouteWatch error", "error", err)
|
logger.Error("RouteWatch error", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -12,13 +12,13 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// peerHandlerQueueSize is the queue capacity for peer tracking operations
|
// peerHandlerQueueSize is the queue capacity for peer tracking operations
|
||||||
peerHandlerQueueSize = 50000
|
peerHandlerQueueSize = 100000
|
||||||
|
|
||||||
// peerBatchSize is the number of peer updates to batch together
|
// 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 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
|
// PeerHandler tracks BGP peers from all message types using batched operations
|
||||||
@@ -135,18 +135,22 @@ func (h *PeerHandler) flushBatchLocked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply updates
|
// Convert to database format
|
||||||
for _, update := range peerMap {
|
dbPeerMap := make(map[string]database.PeerUpdate)
|
||||||
if err := h.db.UpdatePeer(update.peerIP, update.peerASN, update.messageType, update.timestamp); err != nil {
|
for peerIP, update := range peerMap {
|
||||||
h.logger.Error("Failed to update peer",
|
dbPeerMap[peerIP] = database.PeerUpdate{
|
||||||
"peer", update.peerIP,
|
PeerIP: update.peerIP,
|
||||||
"peer_asn", update.peerASN,
|
PeerASN: update.peerASN,
|
||||||
"message_type", update.messageType,
|
MessageType: update.messageType,
|
||||||
"error", err,
|
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
|
// Clear batch
|
||||||
h.peerBatch = h.peerBatch[:0]
|
h.peerBatch = h.peerBatch[:0]
|
||||||
h.lastFlush = time.Now()
|
h.lastFlush = time.Now()
|
||||||
|
|||||||
230
internal/routewatch/peeringhandler.go
Normal file
230
internal/routewatch/peeringhandler.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -8,19 +8,20 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// prefixHandlerQueueSize is the queue capacity for prefix tracking operations
|
// prefixHandlerQueueSize is the queue capacity for prefix tracking operations
|
||||||
prefixHandlerQueueSize = 50000
|
prefixHandlerQueueSize = 100000
|
||||||
|
|
||||||
// prefixBatchSize is the number of prefix updates to batch together
|
// 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 is the maximum time to wait before flushing a batch
|
||||||
prefixBatchTimeout = 5 * time.Second
|
prefixBatchTimeout = 1 * time.Second
|
||||||
|
|
||||||
// IP version constants
|
// IP version constants
|
||||||
ipv4Version = 4
|
ipv4Version = 4
|
||||||
@@ -30,8 +31,9 @@ const (
|
|||||||
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the database.
|
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the database.
|
||||||
// Routes are added on announcement and deleted on withdrawal.
|
// Routes are added on announcement and deleted on withdrawal.
|
||||||
type PrefixHandler struct {
|
type PrefixHandler struct {
|
||||||
db database.Store
|
db database.Store
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
|
metrics *metrics.Tracker
|
||||||
|
|
||||||
// Batching
|
// Batching
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -67,6 +69,11 @@ func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler {
|
|||||||
return h
|
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
|
// WantsMessage returns true if this handler wants to process messages of the given type
|
||||||
func (h *PrefixHandler) WantsMessage(messageType string) bool {
|
func (h *PrefixHandler) WantsMessage(messageType string) bool {
|
||||||
// We only care about UPDATE messages for the routing table
|
// We only care about UPDATE messages for the routing table
|
||||||
@@ -156,6 +163,9 @@ func (h *PrefixHandler) flushBatchLocked() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
batchSize := len(h.batch)
|
||||||
|
|
||||||
// Group updates by prefix to deduplicate
|
// Group updates by prefix to deduplicate
|
||||||
// For each prefix, keep the latest update
|
// For each prefix, keep the latest update
|
||||||
prefixMap := make(map[string]prefixUpdate)
|
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 {
|
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 {
|
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" {
|
} 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
|
// Clear batch
|
||||||
h.batch = h.batch[:0]
|
h.batch = h.batch[:0]
|
||||||
h.lastFlush = time.Now()
|
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
|
// processAnnouncement handles storing an announcement in the database
|
||||||
|
// nolint:unused // kept for potential future use
|
||||||
func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpdate) {
|
func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpdate) {
|
||||||
// Parse CIDR to get mask length
|
// Parse CIDR to get mask length
|
||||||
maskLength, ipVersion, err := parseCIDR(update.prefix)
|
maskLength, ipVersion, err := parseCIDR(update.prefix)
|
||||||
@@ -220,6 +259,15 @@ func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpd
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track route update metrics
|
||||||
|
if h.metrics != nil {
|
||||||
|
if ipVersion == ipv4Version {
|
||||||
|
h.metrics.RecordIPv4Update()
|
||||||
|
} else {
|
||||||
|
h.metrics.RecordIPv6Update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create live route record
|
// Create live route record
|
||||||
liveRoute := &database.LiveRoute{
|
liveRoute := &database.LiveRoute{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
@@ -233,6 +281,20 @@ func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpd
|
|||||||
LastUpdated: update.timestamp,
|
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 {
|
if err := h.db.UpsertLiveRoute(liveRoute); err != nil {
|
||||||
h.logger.Error("Failed to upsert live route",
|
h.logger.Error("Failed to upsert live route",
|
||||||
"prefix", update.prefix,
|
"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
|
// 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) {
|
func (h *PrefixHandler) processWithdrawal(_ *database.Prefix, update prefixUpdate) {
|
||||||
// For withdrawals, we need to delete the route from live_routes
|
// 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
|
// Since we have the origin ASN from the update, we can delete the specific route
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
704
internal/server/handlers.go
Normal 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
42
internal/server/routes.go
Normal 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
|
||||||
|
}
|
||||||
@@ -3,37 +3,31 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
"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/streamer"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server provides HTTP endpoints for status monitoring
|
// Server provides HTTP endpoints for status monitoring
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
db database.Store
|
db database.Store
|
||||||
routingTable *routingtable.RoutingTable
|
streamer *streamer.Streamer
|
||||||
streamer *streamer.Streamer
|
logger *logger.Logger
|
||||||
logger *logger.Logger
|
srv *http.Server
|
||||||
srv *http.Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new 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{
|
s := &Server{
|
||||||
db: db,
|
db: db,
|
||||||
routingTable: rt,
|
streamer: streamer,
|
||||||
streamer: streamer,
|
logger: logger,
|
||||||
logger: logger,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
@@ -41,32 +35,6 @@ func New(db database.Store, rt *routingtable.RoutingTable, streamer *streamer.St
|
|||||||
return s
|
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
|
// Start starts the HTTP server
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
@@ -102,291 +70,3 @@ func (s *Server) Stop(ctx context.Context) error {
|
|||||||
|
|
||||||
return s.srv.Shutdown(ctx)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -195,6 +195,11 @@ func (s *Streamer) GetMetrics() metrics.StreamMetrics {
|
|||||||
return s.metrics.GetStreamMetrics()
|
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
|
// HandlerStats represents metrics for a single handler
|
||||||
type HandlerStats struct {
|
type HandlerStats struct {
|
||||||
Name string
|
Name string
|
||||||
|
|||||||
228
internal/templates/as_detail.html
Normal file
228
internal/templates/as_detail.html
Normal 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>
|
||||||
253
internal/templates/prefix_detail.html
Normal file
253
internal/templates/prefix_detail.html
Normal 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>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<div id="error" class="error" style="display: none;"></div>
|
<div id="error" class="error" style="display: none;"></div>
|
||||||
<div class="status-grid">
|
<div class="status-grid">
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h2>Connection Status</h2>
|
<h2>RouteWatch</h2>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Status</span>
|
<span class="metric-label">Status</span>
|
||||||
<span class="metric-value" id="connected">-</span>
|
<span class="metric-value" id="connected">-</span>
|
||||||
@@ -78,6 +78,18 @@
|
|||||||
<span class="metric-label">Uptime</span>
|
<span class="metric-label">Uptime</span>
|
||||||
<span class="metric-value" id="uptime">-</span>
|
<span class="metric-value" id="uptime">-</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
@@ -110,18 +122,14 @@
|
|||||||
<span class="metric-label">Total Prefixes</span>
|
<span class="metric-label">Total Prefixes</span>
|
||||||
<span class="metric-value" id="prefixes">-</span>
|
<span class="metric-value" id="prefixes">-</span>
|
||||||
</div>
|
</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">
|
<div class="metric">
|
||||||
<span class="metric-label">Peerings</span>
|
<span class="metric-label">Peerings</span>
|
||||||
<span class="metric-value" id="peerings">-</span>
|
<span class="metric-value" id="peerings">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Peers</span>
|
||||||
|
<span class="metric-value" id="peers">-</span>
|
||||||
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Database Size</span>
|
<span class="metric-label">Database Size</span>
|
||||||
<span class="metric-value" id="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-label">Live Routes</span>
|
||||||
<span class="metric-value" id="live_routes">-</span>
|
<span class="metric-value" id="live_routes">-</span>
|
||||||
</div>
|
</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">
|
<div class="metric">
|
||||||
<span class="metric-label">IPv4 Routes</span>
|
<span class="metric-label">IPv4 Routes</span>
|
||||||
<span class="metric-value" id="ipv4_routes">-</span>
|
<span class="metric-value" id="ipv4_routes">-</span>
|
||||||
@@ -186,6 +202,18 @@
|
|||||||
return num.toLocaleString();
|
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) {
|
function updatePrefixDistribution(elementId, distribution) {
|
||||||
const container = document.getElementById(elementId);
|
const container = document.getElementById(elementId);
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -236,11 +264,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Avg Time</span>
|
<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>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Min/Max Time</span>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -269,6 +297,9 @@
|
|||||||
|
|
||||||
// Update all metrics
|
// Update all metrics
|
||||||
document.getElementById('uptime').textContent = data.uptime;
|
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('total_messages').textContent = formatNumber(data.total_messages);
|
||||||
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
|
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
|
||||||
document.getElementById('total_bytes').textContent = formatBytes(data.total_bytes);
|
document.getElementById('total_bytes').textContent = formatBytes(data.total_bytes);
|
||||||
@@ -278,6 +309,7 @@
|
|||||||
document.getElementById('ipv4_prefixes').textContent = formatNumber(data.ipv4_prefixes);
|
document.getElementById('ipv4_prefixes').textContent = formatNumber(data.ipv4_prefixes);
|
||||||
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
||||||
document.getElementById('peerings').textContent = formatNumber(data.peerings);
|
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('database_size').textContent = formatBytes(data.database_size_bytes);
|
||||||
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
||||||
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);
|
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);
|
||||||
|
|||||||
@@ -4,15 +4,25 @@ package templates
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed status.html
|
//go:embed status.html
|
||||||
var statusHTML string
|
var statusHTML string
|
||||||
|
|
||||||
|
//go:embed as_detail.html
|
||||||
|
var asDetailHTML string
|
||||||
|
|
||||||
|
//go:embed prefix_detail.html
|
||||||
|
var prefixDetailHTML string
|
||||||
|
|
||||||
// Templates contains all parsed templates
|
// Templates contains all parsed templates
|
||||||
type Templates struct {
|
type Templates struct {
|
||||||
Status *template.Template
|
Status *template.Template
|
||||||
|
ASDetail *template.Template
|
||||||
|
PrefixDetail *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -22,17 +32,73 @@ var (
|
|||||||
once sync.Once
|
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
|
// initTemplates parses all embedded templates
|
||||||
func initTemplates() {
|
func initTemplates() {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
defaultTemplates = &Templates{}
|
defaultTemplates = &Templates{}
|
||||||
|
|
||||||
|
// Create common template functions
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"timeSince": timeSince,
|
||||||
|
"urlEncode": url.QueryEscape,
|
||||||
|
}
|
||||||
|
|
||||||
// Parse status template
|
// Parse status template
|
||||||
defaultTemplates.Status, err = template.New("status").Parse(statusHTML)
|
defaultTemplates.Status, err = template.New("status").Parse(statusHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("failed to parse status template: " + err.Error())
|
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
|
// Get returns the singleton Templates instance
|
||||||
@@ -46,3 +112,13 @@ func Get() *Templates {
|
|||||||
func StatusTemplate() *template.Template {
|
func StatusTemplate() *template.Template {
|
||||||
return Get().Status
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user