WIP: lint fixes in the smaller packages
Model: opus-5-5
This commit is contained in:
+27
-12
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -27,6 +28,12 @@ const (
|
||||
maxPort = 65535
|
||||
)
|
||||
|
||||
var (
|
||||
errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path")
|
||||
errUnsupportedOS = errors.New("unsupported operating system")
|
||||
errInvalidPort = errors.New("PORT must be a whole number")
|
||||
)
|
||||
|
||||
// Config holds configuration for the entire application
|
||||
type Config struct {
|
||||
// StateDir is the directory for all application state (database, snapshots)
|
||||
@@ -38,11 +45,14 @@ type Config struct {
|
||||
// MaxRuntime is the maximum runtime (0 = run forever)
|
||||
MaxRuntime time.Duration
|
||||
|
||||
// EnableBatchedDatabaseWrites enables batched database operations for better performance
|
||||
// EnableBatchedDatabaseWrites enables batched database operations for
|
||||
// better performance
|
||||
EnableBatchedDatabaseWrites bool
|
||||
|
||||
// RouteExpirationTimeout is how long a route can go without being refreshed before expiring
|
||||
// Default is 2 hours which is conservative for BGP (typical BGP hold time is 90-180 seconds)
|
||||
// RouteExpirationTimeout is how long a route can go without being
|
||||
// refreshed before expiring
|
||||
// Default is 2 hours which is conservative for BGP (typical BGP hold
|
||||
// time is 90-180 seconds)
|
||||
RouteExpirationTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -59,11 +69,14 @@ func New() (*Config, error) {
|
||||
}
|
||||
|
||||
return &Config{
|
||||
StateDir: stateDir,
|
||||
Port: port,
|
||||
MaxRuntime: 0, // Run forever by default
|
||||
EnableBatchedDatabaseWrites: true, // Enable batching by default
|
||||
RouteExpirationTimeout: defaultRouteExpirationMinutes * time.Minute, // For active route monitoring
|
||||
StateDir: stateDir,
|
||||
Port: port,
|
||||
// Run forever by default
|
||||
MaxRuntime: 0,
|
||||
// Enable batching by default
|
||||
EnableBatchedDatabaseWrites: true,
|
||||
// For active route monitoring
|
||||
RouteExpirationTimeout: defaultRouteExpirationMinutes * time.Minute,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -89,7 +102,7 @@ func getStateDirectory() (string, error) {
|
||||
// the database somewhere unexpected.
|
||||
xdgData := os.Getenv("XDG_DATA_HOME")
|
||||
if xdgData != "" && !filepath.IsAbs(xdgData) {
|
||||
return "", fmt.Errorf("XDG_DATA_HOME must be an absolute path, got %q", xdgData)
|
||||
return "", fmt.Errorf("%w, got %q", errRelativeXDGDataHome, xdgData)
|
||||
}
|
||||
|
||||
// Unix-like: /var/lib/berlin.sneak.app.routewatch if root, else XDG_DATA_HOME
|
||||
@@ -111,7 +124,7 @@ func getStateDirectory() (string, error) {
|
||||
return filepath.Join(home, ".local", "share", AppIdentifier), nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
return "", fmt.Errorf("%w: %s", errUnsupportedOS, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +138,8 @@ func getPort() (int, error) {
|
||||
// ParseUint, unlike Atoi, refuses a sign: the health check URL cannot use "+9090"
|
||||
port, err := strconv.ParseUint(value, 10, 0)
|
||||
if err != nil || port < 1 || port > maxPort {
|
||||
return 0, fmt.Errorf("PORT must be a whole number from 1 to %d, got %q", maxPort, value)
|
||||
return 0, fmt.Errorf("%w from 1 to %d, got %q",
|
||||
errInvalidPort, maxPort, value)
|
||||
}
|
||||
|
||||
return int(port), nil
|
||||
@@ -134,7 +148,8 @@ func getPort() (int, error) {
|
||||
// EnsureDirectories creates all necessary directories if they don't exist
|
||||
func (c *Config) EnsureDirectories() error {
|
||||
// Ensure state directory exists
|
||||
if err := os.MkdirAll(c.StateDir, dirPermissions); err != nil {
|
||||
err := os.MkdirAll(c.StateDir, dirPermissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ func TestNewRefusesInvalidPort(t *testing.T) {
|
||||
t.Setenv("PORT", value)
|
||||
t.Setenv("XDG_DATA_HOME", "")
|
||||
|
||||
if _, err := New(); err == nil {
|
||||
_, err := New()
|
||||
if err == nil {
|
||||
t.Errorf("New() with PORT=%q returned no error", value)
|
||||
}
|
||||
})
|
||||
@@ -51,12 +52,16 @@ func TestNewRefusesRelativeXDGDataHome(t *testing.T) {
|
||||
t.Setenv("PORT", "")
|
||||
|
||||
t.Setenv("XDG_DATA_HOME", "relative/path")
|
||||
if _, err := New(); err == nil {
|
||||
|
||||
_, err := New()
|
||||
if err == nil {
|
||||
t.Error("New() with a relative XDG_DATA_HOME returned no error")
|
||||
}
|
||||
|
||||
t.Setenv("XDG_DATA_HOME", "/var/lib")
|
||||
if _, err := New(); err != nil {
|
||||
|
||||
_, err = New()
|
||||
if err != nil {
|
||||
t.Errorf("New() with XDG_DATA_HOME=/var/lib: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ func getSourceAttrs() []slog.Attr {
|
||||
|
||||
// Get the function name
|
||||
fn := runtime.FuncForPC(pc)
|
||||
|
||||
var funcName string
|
||||
if fn != nil {
|
||||
funcName = filepath.Base(fn.Name())
|
||||
|
||||
@@ -115,9 +115,11 @@ func (t *Tracker) GetStreamMetrics() StreamMetrics {
|
||||
if msgCount >= 0 {
|
||||
totalMessages = uint64(msgCount)
|
||||
}
|
||||
|
||||
if byteCount >= 0 {
|
||||
totalBytes = uint64(byteCount)
|
||||
}
|
||||
|
||||
if wireByteCount >= 0 {
|
||||
totalWireBytes = uint64(wireByteCount)
|
||||
}
|
||||
@@ -161,7 +163,7 @@ func (t *Tracker) RecordWithdrawal() {
|
||||
func (t *Tracker) SetBGPPeerCount(count int) {
|
||||
// BGP peer count is always small (< 1000), so int32 is safe
|
||||
if count > 0 && count < 1<<31 {
|
||||
t.bgpPeerCount.Store(int32(count)) //nolint:gosec // count is validated
|
||||
t.bgpPeerCount.Store(int32(count))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +211,8 @@ type StreamMetrics struct {
|
||||
TotalMessages uint64
|
||||
// TotalBytes is the total number of decompressed bytes received since startup
|
||||
TotalBytes uint64
|
||||
// TotalWireBytes is the total number of bytes received on the wire (before decompression)
|
||||
// TotalWireBytes is the total number of bytes received on the wire (before
|
||||
// decompression)
|
||||
TotalWireBytes uint64
|
||||
// ConnectedSince is the time when the current connection was established
|
||||
ConnectedSince time.Time
|
||||
@@ -217,9 +220,11 @@ type StreamMetrics struct {
|
||||
Connected bool
|
||||
// MessagesPerSec is the rate of messages received per second (1-minute average)
|
||||
MessagesPerSec float64
|
||||
// BitsPerSec is the rate of decompressed bits received per second (1-minute average)
|
||||
// BitsPerSec is the rate of decompressed bits received per second (1-minute
|
||||
// average)
|
||||
BitsPerSec float64
|
||||
// WireBitsPerSec is the rate of bits received on the wire per second (1-minute average)
|
||||
// WireBitsPerSec is the rate of bits received on the wire per second
|
||||
// (1-minute average)
|
||||
WireBitsPerSec float64
|
||||
// ReconnectCount is the number of reconnections since startup
|
||||
ReconnectCount uint64
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Package ristypes defines the data structures for RIS Live BGP messages and announcements.
|
||||
// Package ristypes defines the data structures for RIS Live BGP messages and
|
||||
// announcements.
|
||||
package ristypes
|
||||
|
||||
import (
|
||||
@@ -17,25 +18,30 @@ type ASPath []int
|
||||
func (p *ASPath) UnmarshalJSON(data []byte) error {
|
||||
// First try to unmarshal as a simple array of integers
|
||||
var simple []int
|
||||
if err := json.Unmarshal(data, &simple); err == nil {
|
||||
|
||||
err := json.Unmarshal(data, &simple)
|
||||
if err == nil {
|
||||
*p = ASPath(simple)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// If that fails, unmarshal as array of interfaces and flatten
|
||||
var raw []interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
var raw []any
|
||||
|
||||
err = json.Unmarshal(data, &raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Flatten the array
|
||||
result := make([]int, 0)
|
||||
|
||||
for _, item := range raw {
|
||||
switch v := item.(type) {
|
||||
case float64:
|
||||
result = append(result, int(v))
|
||||
case []interface{}:
|
||||
case []any:
|
||||
// Nested array - flatten it
|
||||
for _, nested := range v {
|
||||
if num, ok := nested.(float64); ok {
|
||||
@@ -62,6 +68,8 @@ type RISLiveMessage struct {
|
||||
// It contains metadata about the BGP session (peer, ASN, host) along with
|
||||
// the actual BGP update data including AS path, communities, announcements,
|
||||
// and withdrawals.
|
||||
//
|
||||
//nolint:tagliatelle // field names are the RIS Live feed's
|
||||
type RISMessage struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
@@ -90,6 +98,8 @@ type RISMessage struct {
|
||||
// RISAnnouncement represents a BGP route announcement within a RIS message.
|
||||
// It contains the next hop IP address and the list of prefixes being announced
|
||||
// via that next hop.
|
||||
//
|
||||
//nolint:tagliatelle // field names are the RIS Live feed's
|
||||
type RISAnnouncement struct {
|
||||
NextHop string `json:"next_hop"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package ristypes
|
||||
package ristypes_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||
)
|
||||
|
||||
// messageExamplesPath is the captured RIS Live feed used as decode fixtures,
|
||||
@@ -15,32 +17,43 @@ const messageExamplesPath = "../../docs/message-examples.json"
|
||||
// streamer does and checks that the fields no handler reads (Community, Raw)
|
||||
// stay empty while the fields handlers use (Path, Announcements) still decode.
|
||||
func TestDecodeDropsCommunityAndRaw(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, err := os.Open(messageExamplesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open fixtures: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
var messages, withPath, withAnnouncements int
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wrapper RISLiveMessage
|
||||
if err := json.Unmarshal(line, &wrapper); err != nil {
|
||||
var wrapper ristypes.RISLiveMessage
|
||||
|
||||
err := json.Unmarshal(line, &wrapper)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal message %d: %v", messages+1, err)
|
||||
}
|
||||
|
||||
messages++
|
||||
|
||||
msg := wrapper.Data
|
||||
if msg.Community != nil {
|
||||
t.Errorf("message %d: Community decoded, want empty: %v", messages, msg.Community)
|
||||
t.Errorf("message %d: Community decoded, want empty: %v",
|
||||
messages, msg.Community)
|
||||
}
|
||||
|
||||
if msg.Raw != "" {
|
||||
t.Errorf("message %d: Raw decoded, want empty", messages)
|
||||
}
|
||||
@@ -48,11 +61,14 @@ func TestDecodeDropsCommunityAndRaw(t *testing.T) {
|
||||
if len(msg.Path) > 0 {
|
||||
withPath++
|
||||
}
|
||||
|
||||
if len(msg.Announcements) > 0 {
|
||||
withAnnouncements++
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
|
||||
err = scanner.Err()
|
||||
if err != nil {
|
||||
t.Fatalf("scan fixtures: %v", err)
|
||||
}
|
||||
|
||||
@@ -62,6 +78,7 @@ func TestDecodeDropsCommunityAndRaw(t *testing.T) {
|
||||
// The fixtures include announcement messages; a used field must still decode,
|
||||
// otherwise an empty Community/Raw would prove nothing.
|
||||
if withPath == 0 || withAnnouncements == 0 {
|
||||
t.Fatalf("used fields did not decode: withPath=%d withAnnouncements=%d", withPath, withAnnouncements)
|
||||
t.Fatalf("used fields did not decode: withPath=%d withAnnouncements=%d",
|
||||
withPath, withAnnouncements)
|
||||
}
|
||||
}
|
||||
|
||||
+269
-201
@@ -1,5 +1,6 @@
|
||||
// Package streamer implements an HTTP client that connects to the RIPE RIS Live streaming API,
|
||||
// parses BGP UPDATE messages from the JSON stream, and dispatches them to registered handlers.
|
||||
// Package streamer implements an HTTP client that connects to the RIPE RIS Live
|
||||
// streaming API, parses BGP UPDATE messages from the JSON stream, and
|
||||
// dispatches them to registered handlers.
|
||||
package streamer
|
||||
|
||||
import (
|
||||
@@ -7,6 +8,7 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -24,20 +26,20 @@ import (
|
||||
// countingReader wraps an io.Reader and counts bytes read
|
||||
type countingReader struct {
|
||||
reader io.Reader
|
||||
count int64
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
// Read implements io.Reader and counts bytes
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.reader.Read(p)
|
||||
atomic.AddInt64(&c.count, int64(n))
|
||||
c.count.Add(int64(n))
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Count returns the total bytes read
|
||||
func (c *countingReader) Count() int64 {
|
||||
return atomic.LoadInt64(&c.count)
|
||||
return c.count.Load()
|
||||
}
|
||||
|
||||
// Configuration constants for the RIS Live streamer.
|
||||
@@ -58,11 +60,17 @@ const (
|
||||
backpressureSlope = 2.0 // Slope for linear drop probability increase
|
||||
)
|
||||
|
||||
var (
|
||||
errAlreadyRunning = errors.New("streamer already running")
|
||||
errUnexpectedStatus = errors.New("unexpected status code")
|
||||
)
|
||||
|
||||
// MessageHandler defines the interface for processing RIS messages.
|
||||
// Implementations must specify which message types they want to receive,
|
||||
// how to process messages, and their desired queue capacity.
|
||||
type MessageHandler interface {
|
||||
// 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.
|
||||
WantsMessage(messageType string) bool
|
||||
|
||||
// HandleMessage processes a RIS message. This method is called from a dedicated
|
||||
@@ -113,7 +121,7 @@ type Streamer struct {
|
||||
cancel context.CancelFunc
|
||||
running bool
|
||||
metrics *metrics.Tracker
|
||||
totalDropped uint64 // Total dropped messages across all handlers
|
||||
totalDropped atomic.Uint64 // Total dropped messages across all handlers
|
||||
random *rand.Rand // Random number generator for backpressure drops
|
||||
bgpPeers map[string]bool // Track active BGP peers by peer IP
|
||||
bgpPeersMu sync.RWMutex // Protects bgpPeers map
|
||||
@@ -121,7 +129,8 @@ type Streamer struct {
|
||||
|
||||
// New creates a new Streamer instance configured to connect to the RIS Live API.
|
||||
// The logger is used for structured logging of connection events and errors.
|
||||
// The metrics tracker is used to record message counts, bytes received, and connection status.
|
||||
// The metrics tracker is used to record message counts, bytes received, and
|
||||
// connection status.
|
||||
func New(logger *logger.Logger, metrics *metrics.Tracker) *Streamer {
|
||||
return &Streamer{
|
||||
logger: logger,
|
||||
@@ -153,7 +162,8 @@ func (s *Streamer) RegisterHandler(handler MessageHandler) {
|
||||
handler: handler,
|
||||
queue: make(chan *ristypes.RISMessage, handler.QueueCapacity()),
|
||||
metrics: handlerMetrics{
|
||||
minTime: time.Duration(math.MaxInt64), // Initialize to max so first value sets the floor
|
||||
// Initialize to max so first value sets the floor
|
||||
minTime: time.Duration(math.MaxInt64),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -171,6 +181,7 @@ func (s *Streamer) RegisterHandler(handler MessageHandler) {
|
||||
func (s *Streamer) RegisterRawHandler(handler RawMessageHandler) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.rawHandler = handler
|
||||
}
|
||||
|
||||
@@ -183,7 +194,7 @@ func (s *Streamer) Start() error {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("streamer already running")
|
||||
return errAlreadyRunning
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -216,44 +227,21 @@ func (s *Streamer) Stop() {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
// Close all handler queues to signal workers to stop
|
||||
for _, info := range s.handlers {
|
||||
close(info.queue)
|
||||
}
|
||||
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
s.metrics.SetConnected(false)
|
||||
}
|
||||
|
||||
// runHandlerWorker processes messages for a specific handler
|
||||
func (s *Streamer) runHandlerWorker(info *handlerInfo) {
|
||||
for msg := range info.queue {
|
||||
start := time.Now()
|
||||
info.handler.HandleMessage(msg)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Update metrics
|
||||
info.metrics.mu.Lock()
|
||||
info.metrics.processedCount++
|
||||
info.metrics.totalTime += elapsed
|
||||
|
||||
// Update min time
|
||||
if elapsed < info.metrics.minTime {
|
||||
info.metrics.minTime = elapsed
|
||||
}
|
||||
|
||||
// Update max time
|
||||
if elapsed > info.metrics.maxTime {
|
||||
info.metrics.maxTime = elapsed
|
||||
}
|
||||
info.metrics.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning reports whether the streamer is currently connected and processing messages.
|
||||
// This is safe to call concurrently from multiple goroutines.
|
||||
// IsRunning reports whether the streamer is currently connected and processing
|
||||
// messages. This is safe to call concurrently from multiple goroutines.
|
||||
func (s *Streamer) IsRunning() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -314,12 +302,15 @@ func (s *Streamer) GetHandlerStats() []HandlerStats {
|
||||
// Calculate average time
|
||||
if info.metrics.processedCount > 0 {
|
||||
processedCount := info.metrics.processedCount
|
||||
|
||||
const maxInt64 = 1<<63 - 1
|
||||
|
||||
if processedCount > maxInt64 {
|
||||
processedCount = maxInt64
|
||||
}
|
||||
//nolint:gosec // processedCount is explicitly bounded above
|
||||
hs.AvgProcessTime = info.metrics.totalTime / time.Duration(processedCount)
|
||||
|
||||
hs.AvgProcessTime = info.metrics.totalTime /
|
||||
time.Duration(processedCount)
|
||||
}
|
||||
|
||||
info.metrics.mu.Unlock()
|
||||
@@ -330,11 +321,38 @@ func (s *Streamer) GetHandlerStats() []HandlerStats {
|
||||
return stats
|
||||
}
|
||||
|
||||
// GetDroppedMessages returns the total number of messages dropped across all handlers
|
||||
// due to queue overflow or backpressure. This counter is monotonically increasing
|
||||
// and is safe to call concurrently.
|
||||
// GetDroppedMessages returns the total number of messages dropped across all
|
||||
// handlers due to queue overflow or backpressure. This counter is monotonically
|
||||
// increasing and is safe to call concurrently.
|
||||
func (s *Streamer) GetDroppedMessages() uint64 {
|
||||
return atomic.LoadUint64(&s.totalDropped)
|
||||
return s.totalDropped.Load()
|
||||
}
|
||||
|
||||
// runHandlerWorker processes messages for a specific handler
|
||||
func (s *Streamer) runHandlerWorker(info *handlerInfo) {
|
||||
for msg := range info.queue {
|
||||
start := time.Now()
|
||||
|
||||
info.handler.HandleMessage(msg)
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Update metrics
|
||||
info.metrics.mu.Lock()
|
||||
info.metrics.processedCount++
|
||||
info.metrics.totalTime += elapsed
|
||||
|
||||
// Update min time
|
||||
if elapsed < info.metrics.minTime {
|
||||
info.metrics.minTime = elapsed
|
||||
}
|
||||
|
||||
// Update max time
|
||||
if elapsed > info.metrics.maxTime {
|
||||
info.metrics.maxTime = elapsed
|
||||
}
|
||||
info.metrics.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// logMetrics logs the current streaming statistics
|
||||
@@ -343,7 +361,8 @@ func (s *Streamer) logMetrics() {
|
||||
uptime := time.Since(metrics.ConnectedSince)
|
||||
|
||||
const bitsPerMegabit = 1000000
|
||||
totalDropped := atomic.LoadUint64(&s.totalDropped)
|
||||
|
||||
totalDropped := s.totalDropped.Load()
|
||||
|
||||
s.logger.Info(
|
||||
"Stream statistics",
|
||||
@@ -369,16 +388,19 @@ func (s *Streamer) logMetrics() {
|
||||
|
||||
// Log per-handler statistics
|
||||
s.mu.RLock()
|
||||
|
||||
for i, info := range s.handlers {
|
||||
info.metrics.mu.Lock()
|
||||
if info.metrics.processedCount > 0 {
|
||||
// Safe conversion: processedCount is bounded by maxInt64
|
||||
processedCount := info.metrics.processedCount
|
||||
|
||||
const maxInt64 = 1<<63 - 1
|
||||
|
||||
if processedCount > maxInt64 {
|
||||
processedCount = maxInt64
|
||||
}
|
||||
//nolint:gosec // processedCount is explicitly bounded above
|
||||
|
||||
avgTime := info.metrics.totalTime / time.Duration(processedCount)
|
||||
s.logger.Info(
|
||||
"Handler statistics",
|
||||
@@ -395,6 +417,7 @@ func (s *Streamer) logMetrics() {
|
||||
}
|
||||
info.metrics.mu.Unlock()
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
@@ -403,7 +426,8 @@ func (s *Streamer) updateMetrics(messageBytes int) {
|
||||
s.metrics.RecordMessage(int64(messageBytes))
|
||||
}
|
||||
|
||||
// streamWithReconnect handles streaming with automatic reconnection and exponential backoff
|
||||
// streamWithReconnect handles streaming with automatic reconnection and
|
||||
// exponential backoff
|
||||
func (s *Streamer) streamWithReconnect(ctx context.Context) {
|
||||
backoffDelay := minBackoffDelay
|
||||
consecutiveFailures := 0
|
||||
@@ -444,6 +468,7 @@ func (s *Streamer) streamWithReconnect(ctx context.Context) {
|
||||
if streamDuration > 30*time.Second {
|
||||
s.logger.Info("Resetting backoff delay due to successful connection",
|
||||
"stream_duration", streamDuration)
|
||||
|
||||
backoffDelay = minBackoffDelay
|
||||
consecutiveFailures = 0
|
||||
} else {
|
||||
@@ -477,7 +502,7 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
connCtx, connCancel := context.WithCancel(ctx)
|
||||
defer connCancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -490,13 +515,14 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to connect to RIS Live: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
return fmt.Errorf("%w: %d", errUnexpectedStatus, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Wrap body with counting reader to track actual wire bytes
|
||||
@@ -510,7 +536,9 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer func() { _ = gzReader.Close() }()
|
||||
|
||||
reader = gzReader
|
||||
|
||||
s.logger.Info("Connected to RIS Live stream", "compression", "gzip")
|
||||
} else {
|
||||
s.logger.Info("Connected to RIS Live stream", "compression", "none")
|
||||
@@ -518,9 +546,6 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
|
||||
s.metrics.SetConnected(true)
|
||||
|
||||
// Track wire bytes for metrics updates
|
||||
var lastWireBytes int64
|
||||
|
||||
// Start metrics logging goroutine
|
||||
metricsTicker := time.NewTicker(metricsLogInterval)
|
||||
defer metricsTicker.Stop()
|
||||
@@ -537,25 +562,41 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
}()
|
||||
|
||||
// Wire byte update ticker - update metrics with actual wire bytes periodically
|
||||
go s.recordWireBytes(connCtx, wireCounter)
|
||||
|
||||
return s.readStream(ctx, reader)
|
||||
}
|
||||
|
||||
// recordWireBytes adds the bytes wireCounter has counted to the metrics once a
|
||||
// second until ctx is cancelled.
|
||||
func (s *Streamer) recordWireBytes(
|
||||
ctx context.Context, wireCounter *countingReader,
|
||||
) {
|
||||
wireUpdateTicker := time.NewTicker(time.Second)
|
||||
defer wireUpdateTicker.Stop()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-wireUpdateTicker.C:
|
||||
currentBytes := wireCounter.Count()
|
||||
delta := currentBytes - lastWireBytes
|
||||
if delta > 0 {
|
||||
s.metrics.RecordWireBytes(delta)
|
||||
lastWireBytes = currentBytes
|
||||
}
|
||||
case <-connCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
var lastWireBytes int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-wireUpdateTicker.C:
|
||||
currentBytes := wireCounter.Count()
|
||||
|
||||
delta := currentBytes - lastWireBytes
|
||||
if delta > 0 {
|
||||
s.metrics.RecordWireBytes(delta)
|
||||
|
||||
lastWireBytes = currentBytes
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readStream hands each line of the stream to processLine until the stream
|
||||
// ends, a line fails, or ctx is cancelled.
|
||||
func (s *Streamer) readStream(ctx context.Context, reader io.Reader) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
|
||||
for scanner.Scan() {
|
||||
@@ -572,149 +613,176 @@ func (s *Streamer) stream(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update metrics with decompressed message size
|
||||
s.updateMetrics(len(line))
|
||||
|
||||
// Call raw handler if registered
|
||||
s.mu.RLock()
|
||||
rawHandler := s.rawHandler
|
||||
s.mu.RUnlock()
|
||||
|
||||
if rawHandler != nil {
|
||||
// Call raw handler synchronously to preserve order
|
||||
rawHandler(string(line))
|
||||
err := s.processLine(ctx, line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse the message first
|
||||
var wrapper ristypes.RISLiveMessage
|
||||
if err := json.Unmarshal(line, &wrapper); err != nil {
|
||||
// Log the error and return to trigger reconnection
|
||||
s.logger.Error("Failed to parse JSON",
|
||||
"error", err,
|
||||
"line", string(line),
|
||||
"line_length", len(line))
|
||||
|
||||
return fmt.Errorf("JSON parse error: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a ris_message wrapper
|
||||
if wrapper.Type != "ris_message" {
|
||||
s.logger.Error("Unexpected wrapper type",
|
||||
"type", wrapper.Type,
|
||||
"line", string(line),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the actual message
|
||||
msg := wrapper.Data
|
||||
|
||||
// Parse the timestamp
|
||||
msg.ParsedTimestamp = time.Unix(int64(msg.Timestamp), 0).UTC()
|
||||
|
||||
// Process based on message type
|
||||
switch msg.Type {
|
||||
case "UPDATE":
|
||||
// Process BGP UPDATE messages
|
||||
// Will be dispatched to handlers
|
||||
case "RIS_PEER_STATE":
|
||||
// RIS peer state messages - silently ignore
|
||||
continue
|
||||
case "KEEPALIVE":
|
||||
// BGP keepalive messages - silently process
|
||||
continue
|
||||
case "OPEN":
|
||||
// BGP open messages - track peer as active
|
||||
s.bgpPeersMu.Lock()
|
||||
s.bgpPeers[msg.Peer] = true
|
||||
peerCount := len(s.bgpPeers)
|
||||
s.bgpPeersMu.Unlock()
|
||||
s.metrics.SetBGPPeerCount(peerCount)
|
||||
|
||||
s.logger.Info("BGP session opened",
|
||||
"peer", msg.Peer,
|
||||
"peer_asn", msg.PeerASN,
|
||||
"total_peers", peerCount,
|
||||
)
|
||||
|
||||
continue
|
||||
case "NOTIFICATION":
|
||||
// BGP notification messages (session closed)
|
||||
s.bgpPeersMu.Lock()
|
||||
delete(s.bgpPeers, msg.Peer)
|
||||
peerCount := len(s.bgpPeers)
|
||||
s.bgpPeersMu.Unlock()
|
||||
s.metrics.SetBGPPeerCount(peerCount)
|
||||
|
||||
s.logger.Warn("BGP notification",
|
||||
"peer", msg.Peer,
|
||||
"peer_asn", msg.PeerASN,
|
||||
"total_peers", peerCount,
|
||||
)
|
||||
|
||||
continue
|
||||
case "STATE":
|
||||
// Peer state changes - silently ignore
|
||||
continue
|
||||
default:
|
||||
s.logger.Warn("Unknown message type, skipping",
|
||||
"type", msg.Type,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Dispatch to interested handlers. Stop cancels ctx and closes the
|
||||
// queues under the write lock, so if ctx is cancelled here, under the
|
||||
// read lock, the queues are closed and must not be sent to.
|
||||
s.mu.RLock()
|
||||
if ctx.Err() != nil {
|
||||
s.mu.RUnlock()
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
for _, info := range s.handlers {
|
||||
if !info.handler.WantsMessage(msg.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we should drop due to backpressure
|
||||
if s.shouldDropForBackpressure(info) {
|
||||
atomic.AddUint64(&info.metrics.droppedCount, 1)
|
||||
atomic.AddUint64(&s.totalDropped, 1)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to queue the message
|
||||
select {
|
||||
case info.queue <- &msg:
|
||||
// Message queued successfully
|
||||
// Update high water mark if needed
|
||||
queueLen := len(info.queue)
|
||||
info.metrics.mu.Lock()
|
||||
if queueLen > info.metrics.queueHighWaterMark {
|
||||
info.metrics.queueHighWaterMark = queueLen
|
||||
}
|
||||
info.metrics.mu.Unlock()
|
||||
default:
|
||||
// Queue is full, drop the message
|
||||
atomic.AddUint64(&info.metrics.droppedCount, 1)
|
||||
atomic.AddUint64(&s.totalDropped, 1)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
err := scanner.Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldDropForBackpressure determines if a message should be dropped based on queue utilization
|
||||
// processLine passes one line of the stream to the raw handler, parses it,
|
||||
// and dispatches an UPDATE message to the handlers. An error ends the
|
||||
// connection.
|
||||
func (s *Streamer) processLine(ctx context.Context, line []byte) error {
|
||||
// Update metrics with decompressed message size
|
||||
s.updateMetrics(len(line))
|
||||
|
||||
// Call raw handler if registered
|
||||
s.mu.RLock()
|
||||
rawHandler := s.rawHandler
|
||||
s.mu.RUnlock()
|
||||
|
||||
if rawHandler != nil {
|
||||
// Call raw handler synchronously to preserve order
|
||||
rawHandler(string(line))
|
||||
}
|
||||
|
||||
// Parse the message first
|
||||
var wrapper ristypes.RISLiveMessage
|
||||
|
||||
err := json.Unmarshal(line, &wrapper)
|
||||
if err != nil {
|
||||
// Log the error and return to trigger reconnection
|
||||
s.logger.Error("Failed to parse JSON",
|
||||
"error", err,
|
||||
"line", string(line),
|
||||
"line_length", len(line))
|
||||
|
||||
return fmt.Errorf("JSON parse error: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a ris_message wrapper
|
||||
if wrapper.Type != "ris_message" {
|
||||
s.logger.Error("Unexpected wrapper type",
|
||||
"type", wrapper.Type,
|
||||
"line", string(line),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the actual message
|
||||
msg := wrapper.Data
|
||||
|
||||
// Parse the timestamp
|
||||
msg.ParsedTimestamp = time.Unix(int64(msg.Timestamp), 0).UTC()
|
||||
|
||||
// Process based on message type
|
||||
switch msg.Type {
|
||||
case "UPDATE":
|
||||
// Process BGP UPDATE messages
|
||||
// Will be dispatched to handlers
|
||||
case "RIS_PEER_STATE":
|
||||
// RIS peer state messages - silently ignore
|
||||
return nil
|
||||
case "KEEPALIVE":
|
||||
// BGP keepalive messages - silently process
|
||||
return nil
|
||||
case "OPEN":
|
||||
// BGP open messages - track peer as active
|
||||
s.bgpPeersMu.Lock()
|
||||
s.bgpPeers[msg.Peer] = true
|
||||
peerCount := len(s.bgpPeers)
|
||||
s.bgpPeersMu.Unlock()
|
||||
s.metrics.SetBGPPeerCount(peerCount)
|
||||
|
||||
s.logger.Info("BGP session opened",
|
||||
"peer", msg.Peer,
|
||||
"peer_asn", msg.PeerASN,
|
||||
"total_peers", peerCount,
|
||||
)
|
||||
|
||||
return nil
|
||||
case "NOTIFICATION":
|
||||
// BGP notification messages (session closed)
|
||||
s.bgpPeersMu.Lock()
|
||||
delete(s.bgpPeers, msg.Peer)
|
||||
peerCount := len(s.bgpPeers)
|
||||
s.bgpPeersMu.Unlock()
|
||||
s.metrics.SetBGPPeerCount(peerCount)
|
||||
|
||||
s.logger.Warn("BGP notification",
|
||||
"peer", msg.Peer,
|
||||
"peer_asn", msg.PeerASN,
|
||||
"total_peers", peerCount,
|
||||
)
|
||||
|
||||
return nil
|
||||
case "STATE":
|
||||
// Peer state changes - silently ignore
|
||||
return nil
|
||||
default:
|
||||
s.logger.Warn("Unknown message type, skipping",
|
||||
"type", msg.Type,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.dispatchMessage(ctx, &msg)
|
||||
}
|
||||
|
||||
// dispatchMessage queues msg for each handler that wants it, dropping it for
|
||||
// a handler whose queue is full or under backpressure.
|
||||
func (s *Streamer) dispatchMessage(
|
||||
ctx context.Context, msg *ristypes.RISMessage,
|
||||
) error {
|
||||
// Stop cancels ctx and closes the queues under the write lock, so if ctx
|
||||
// is cancelled here, under the read lock, the queues are closed and must
|
||||
// not be sent to.
|
||||
s.mu.RLock()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
s.mu.RUnlock()
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
for _, info := range s.handlers {
|
||||
if !info.handler.WantsMessage(msg.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we should drop due to backpressure
|
||||
if s.shouldDropForBackpressure(info) {
|
||||
atomic.AddUint64(&info.metrics.droppedCount, 1)
|
||||
s.totalDropped.Add(1)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to queue the message
|
||||
select {
|
||||
case info.queue <- msg:
|
||||
// Message queued successfully
|
||||
// Update high water mark if needed
|
||||
queueLen := len(info.queue)
|
||||
info.metrics.mu.Lock()
|
||||
if queueLen > info.metrics.queueHighWaterMark {
|
||||
info.metrics.queueHighWaterMark = queueLen
|
||||
}
|
||||
info.metrics.mu.Unlock()
|
||||
default:
|
||||
// Queue is full, drop the message
|
||||
atomic.AddUint64(&info.metrics.droppedCount, 1)
|
||||
s.totalDropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldDropForBackpressure determines if a message should be dropped based on
|
||||
// queue utilization
|
||||
func (s *Streamer) shouldDropForBackpressure(info *handlerInfo) bool {
|
||||
// Calculate queue utilization
|
||||
queueLen := len(info.queue)
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNewStreamer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := logger.New()
|
||||
metricsTracker := metrics.New()
|
||||
s := New(logger, metricsTracker)
|
||||
@@ -46,10 +48,13 @@ func TestNewStreamer(t *testing.T) {
|
||||
// goroutine count returns to its starting value. Each connection starts two
|
||||
// ticker goroutines; before the fix they lived until the streamer's lifetime
|
||||
// context was cancelled, so every reconnect leaked two.
|
||||
//
|
||||
//nolint:paralleltest // counts goroutines, so no other test may run alongside
|
||||
func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
|
||||
// The handler returns immediately, so the response body is empty and each
|
||||
// stream call ends at once, standing in for a dropped connection.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {}))
|
||||
defer srv.Close()
|
||||
|
||||
s := New(logger.New(), metrics.New())
|
||||
@@ -57,19 +62,23 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
|
||||
|
||||
// One warm-up connection so any persistent HTTP transport goroutine exists
|
||||
// before we take the baseline.
|
||||
if err := s.stream(context.Background()); err != nil {
|
||||
err := s.stream(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("warm-up stream returned error: %v", err)
|
||||
}
|
||||
|
||||
s.client.CloseIdleConnections()
|
||||
|
||||
baseline := settledGoroutineCount()
|
||||
|
||||
const reconnects = 20
|
||||
for range reconnects {
|
||||
if err := s.stream(context.Background()); err != nil {
|
||||
err := s.stream(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("stream returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.client.CloseIdleConnections()
|
||||
|
||||
if !waitForGoroutines(baseline) {
|
||||
@@ -81,9 +90,13 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
|
||||
// updateHandler wants UPDATE messages and does nothing with them.
|
||||
type updateHandler struct{}
|
||||
|
||||
func (updateHandler) WantsMessage(messageType string) bool { return messageType == "UPDATE" }
|
||||
func (updateHandler) HandleMessage(*ristypes.RISMessage) {}
|
||||
func (updateHandler) QueueCapacity() int { return 10 }
|
||||
func (updateHandler) WantsMessage(messageType string) bool {
|
||||
return messageType == "UPDATE"
|
||||
}
|
||||
|
||||
func (updateHandler) HandleMessage(*ristypes.RISMessage) {}
|
||||
|
||||
func (updateHandler) QueueCapacity() int { return 10 }
|
||||
|
||||
// TestStopBeforeMessageReachesQueues stops the streamer after the read loop
|
||||
// has checked for cancellation but before it hands the message to the handler
|
||||
@@ -91,11 +104,15 @@ func (updateHandler) QueueCapacity() int { return 10 }
|
||||
// used to end in "send on closed channel". The raw handler runs in that gap on
|
||||
// the read loop itself, so calling Stop from it hits the gap every time.
|
||||
func TestStopBeforeMessageReachesQueues(t *testing.T) {
|
||||
const line = `{"type":"ris_message","data":{"type":"UPDATE","peer":"192.0.2.1",` +
|
||||
`"peer_asn":"64496","timestamp":1700000000}}` + "\n"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, line)
|
||||
}))
|
||||
t.Parallel()
|
||||
|
||||
const line = `{"type":"ris_message","data":{"type":"UPDATE",` +
|
||||
`"peer":"192.0.2.1","peer_asn":"64496","timestamp":1700000000}}` + "\n"
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, line)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := New(logger.New(), metrics.New())
|
||||
@@ -109,7 +126,8 @@ func TestStopBeforeMessageReachesQueues(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
|
||||
if err := s.stream(ctx); !errors.Is(err, context.Canceled) {
|
||||
err := s.stream(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("stream returned %v, want %v", err, context.Canceled)
|
||||
}
|
||||
|
||||
@@ -121,12 +139,15 @@ func TestStopBeforeMessageReachesQueues(t *testing.T) {
|
||||
// current count.
|
||||
func settledGoroutineCount() int {
|
||||
prev := runtime.NumGoroutine()
|
||||
|
||||
for range 20 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
cur := runtime.NumGoroutine()
|
||||
if cur == prev {
|
||||
return cur
|
||||
}
|
||||
|
||||
prev = cur
|
||||
}
|
||||
|
||||
@@ -139,6 +160,7 @@ func waitForGoroutines(target int) bool {
|
||||
if runtime.NumGoroutine() <= target {
|
||||
return true
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ func timeSince(t time.Time) string {
|
||||
if duration < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
|
||||
if duration < time.Hour {
|
||||
minutes := int(duration.Minutes())
|
||||
if minutes == 1 {
|
||||
@@ -68,6 +69,7 @@ func timeSince(t time.Time) string {
|
||||
|
||||
return duration.Truncate(time.Minute).String() + " ago"
|
||||
}
|
||||
|
||||
if duration < hoursPerDay*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
@@ -76,10 +78,12 @@ func timeSince(t time.Time) string {
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -118,7 +122,7 @@ func initTemplates() {
|
||||
"appLicense": func() string { return version.License },
|
||||
"appRepoURL": func() string { return version.RepoURL },
|
||||
"appGitRevision": func() string { return version.GitRevisionShort },
|
||||
"appGitCommitURL": func() string { return version.CommitURL() },
|
||||
"appGitCommitURL": version.CommitURL,
|
||||
}
|
||||
|
||||
// Parse index template
|
||||
@@ -134,19 +138,22 @@ func initTemplates() {
|
||||
}
|
||||
|
||||
// Parse AS detail template
|
||||
defaultTemplates.ASDetail, err = template.New("asDetail").Funcs(funcs).Parse(asDetailHTML)
|
||||
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)
|
||||
defaultTemplates.PrefixDetail, err = template.New("prefixDetail").
|
||||
Funcs(funcs).Parse(prefixDetailHTML)
|
||||
if err != nil {
|
||||
panic("failed to parse prefix detail template: " + err.Error())
|
||||
}
|
||||
|
||||
// Parse prefix length template
|
||||
defaultTemplates.PrefixLength, err = template.New("prefixLength").Funcs(funcs).Parse(prefixLengthHTML)
|
||||
defaultTemplates.PrefixLength, err = template.New("prefixLength").
|
||||
Funcs(funcs).Parse(prefixLengthHTML)
|
||||
if err != nil {
|
||||
panic("failed to parse prefix length template: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package version
|
||||
|
||||
// Build-time variables set via ldflags
|
||||
//
|
||||
//nolint:gochecknoglobals // These must be variables to allow ldflags injection at build time
|
||||
//nolint:gochecknoglobals // variables so that ldflags can set them at build time
|
||||
var (
|
||||
// GitRevision is the git commit hash
|
||||
GitRevision = "unknown"
|
||||
|
||||
+106
-78
@@ -98,7 +98,8 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
|
||||
if err != nil {
|
||||
// Return partial data from IANA if RIR query fails
|
||||
info := c.parseResponse(asn, rir, ianaResp)
|
||||
info.RawResponse = ianaResp + "\n--- RIR query failed: " + err.Error() + " ---\n"
|
||||
info.RawResponse = fmt.Sprintf("%s\n--- RIR query failed: %v ---\n",
|
||||
ianaResp, err)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -120,28 +121,33 @@ func (c *Client) query(ctx context.Context, server, query string) (string, error
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Set deadlines
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
|
||||
err = conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("set write deadline: %w", err)
|
||||
}
|
||||
|
||||
// Send query
|
||||
if _, err := fmt.Fprintf(conn, "%s\r\n", query); err != nil {
|
||||
_, err = fmt.Fprintf(conn, "%s\r\n", query)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write query: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
|
||||
err = conn.SetReadDeadline(time.Now().Add(readTimeout))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
sb.WriteString(scanner.Text())
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
err = scanner.Err()
|
||||
if err != nil {
|
||||
return sb.String(), fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
@@ -149,10 +155,10 @@ func (c *Client) query(ctx context.Context, server, query string) (string, error
|
||||
}
|
||||
|
||||
// parseIANAReferral extracts the RIR and WHOIS server from an IANA response.
|
||||
func (c *Client) parseIANAReferral(response string) (rir, whoisServer string) {
|
||||
lines := strings.Split(response, "\n")
|
||||
func (c *Client) parseIANAReferral(response string) (string, string) {
|
||||
var rir string
|
||||
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(response, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Look for "refer:" line
|
||||
@@ -183,18 +189,9 @@ func (c *Client) parseIANAReferral(response string) (rir, whoisServer string) {
|
||||
|
||||
// Also check organisation line for RIR hints
|
||||
if strings.HasPrefix(strings.ToLower(line), "organisation:") {
|
||||
org := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.Contains(org, "arin"):
|
||||
rir = RIRARIN
|
||||
case strings.Contains(org, "ripe"):
|
||||
rir = RIRRIPE
|
||||
case strings.Contains(org, "apnic"):
|
||||
rir = RIRAPNIC
|
||||
case strings.Contains(org, "lacnic"):
|
||||
rir = RIRLACNIC
|
||||
case strings.Contains(org, "afrinic"):
|
||||
rir = RIRAFRNIC
|
||||
orgRIR := rirInOrganisationLine(strings.ToLower(line))
|
||||
if orgRIR != "" {
|
||||
rir = orgRIR
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +199,25 @@ func (c *Client) parseIANAReferral(response string) (rir, whoisServer string) {
|
||||
return rir, ""
|
||||
}
|
||||
|
||||
// rirInOrganisationLine returns the RIR a lowercased IANA "organisation:"
|
||||
// line names, or "" when it names none.
|
||||
func rirInOrganisationLine(org string) string {
|
||||
switch {
|
||||
case strings.Contains(org, "arin"):
|
||||
return RIRARIN
|
||||
case strings.Contains(org, "ripe"):
|
||||
return RIRRIPE
|
||||
case strings.Contains(org, "apnic"):
|
||||
return RIRAPNIC
|
||||
case strings.Contains(org, "lacnic"):
|
||||
return RIRLACNIC
|
||||
case strings.Contains(org, "afrinic"):
|
||||
return RIRAFRNIC
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parseResponse extracts ASN information from a WHOIS response.
|
||||
func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo {
|
||||
info := &ASNInfo{
|
||||
@@ -211,11 +227,13 @@ func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo {
|
||||
}
|
||||
|
||||
lines := strings.Split(response, "\n")
|
||||
|
||||
var addressLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
if line == "" || strings.HasPrefix(line, "%") ||
|
||||
strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -232,63 +250,14 @@ func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo {
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
// AS Name (varies by RIR)
|
||||
case "asname", "as-name":
|
||||
if info.ASName == "" {
|
||||
info.ASName = value
|
||||
}
|
||||
|
||||
// Organization
|
||||
case "orgname", "org-name", "owner":
|
||||
if info.OrgName == "" {
|
||||
info.OrgName = value
|
||||
}
|
||||
case "orgid", "org-id", "org":
|
||||
if info.OrgID == "" {
|
||||
info.OrgID = value
|
||||
}
|
||||
|
||||
// Address (collect multiple lines)
|
||||
case "address":
|
||||
if key == "address" {
|
||||
addressLines = append(addressLines, value)
|
||||
|
||||
// Country
|
||||
case "country":
|
||||
if info.CountryCode == "" && len(value) == 2 {
|
||||
info.CountryCode = strings.ToUpper(value)
|
||||
}
|
||||
|
||||
// Abuse contact
|
||||
case "orgabuseemail", "abuse-mailbox":
|
||||
if info.AbuseEmail == "" {
|
||||
info.AbuseEmail = value
|
||||
}
|
||||
case "orgabusephone":
|
||||
if info.AbusePhone == "" {
|
||||
info.AbusePhone = value
|
||||
}
|
||||
|
||||
// Tech contact
|
||||
case "orgtechemail":
|
||||
if info.TechEmail == "" {
|
||||
info.TechEmail = value
|
||||
}
|
||||
case "orgtechphone":
|
||||
if info.TechPhone == "" {
|
||||
info.TechPhone = value
|
||||
}
|
||||
|
||||
// Registration dates
|
||||
case "regdate", "created":
|
||||
if info.RegDate == nil {
|
||||
info.RegDate = c.parseDate(value)
|
||||
}
|
||||
case "updated", "last-modified", "changed":
|
||||
if info.LastMod == nil {
|
||||
info.LastMod = c.parseDate(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
c.setResponseField(info, key, value)
|
||||
}
|
||||
|
||||
// Combine address lines
|
||||
@@ -304,6 +273,65 @@ func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// setResponseField stores the value of one "key: value" line of a WHOIS
|
||||
// response in info, keeping the first value seen for each field.
|
||||
func (c *Client) setResponseField(info *ASNInfo, key, value string) {
|
||||
switch key {
|
||||
// Country
|
||||
case "country":
|
||||
if info.CountryCode == "" && len(value) == 2 {
|
||||
info.CountryCode = strings.ToUpper(value)
|
||||
}
|
||||
|
||||
// Registration dates
|
||||
case "regdate", "created":
|
||||
if info.RegDate == nil {
|
||||
info.RegDate = c.parseDate(value)
|
||||
}
|
||||
case "updated", "last-modified", "changed":
|
||||
if info.LastMod == nil {
|
||||
info.LastMod = c.parseDate(value)
|
||||
}
|
||||
|
||||
default:
|
||||
field := textFieldForKey(info, key)
|
||||
if field != nil && *field == "" {
|
||||
*field = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// textFieldForKey returns the text field of info that a WHOIS response key
|
||||
// fills, or nil when the key fills none.
|
||||
func textFieldForKey(info *ASNInfo, key string) *string {
|
||||
switch key {
|
||||
// AS Name (varies by RIR)
|
||||
case "asname", "as-name":
|
||||
return &info.ASName
|
||||
|
||||
// Organization
|
||||
case "orgname", "org-name", "owner":
|
||||
return &info.OrgName
|
||||
case "orgid", "org-id", "org":
|
||||
return &info.OrgID
|
||||
|
||||
// Abuse contact
|
||||
case "orgabuseemail", "abuse-mailbox":
|
||||
return &info.AbuseEmail
|
||||
case "orgabusephone":
|
||||
return &info.AbusePhone
|
||||
|
||||
// Tech contact
|
||||
case "orgtechemail":
|
||||
return &info.TechEmail
|
||||
case "orgtechphone":
|
||||
return &info.TechPhone
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseDate attempts to parse various date formats used in WHOIS responses.
|
||||
func (c *Client) parseDate(value string) *time.Time {
|
||||
// Common formats
|
||||
@@ -319,15 +347,15 @@ func (c *Client) parseDate(value string) *time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
// Handle "YYYYMMDD" format from LACNIC
|
||||
if len(value) == lacnicDateFormatLen {
|
||||
if _, err := time.Parse("20060102", value); err == nil {
|
||||
t, _ := time.Parse("20060102", value)
|
||||
|
||||
t, err := time.Parse("20060102", value)
|
||||
if err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, value); err == nil {
|
||||
t, err := time.Parse(format, value)
|
||||
if err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user