Compare commits
	
		
			No commits in common. "d15a5e91b949bd447afa066f035d62a2efecb7db" and "6593a7be763434fde61d0e08bc51b8c2301d3406" have entirely different histories.
		
	
	
		
			d15a5e91b9
			...
			6593a7be76
		
	
		
@ -1,91 +0,0 @@
 | 
			
		||||
// Package config provides centralized configuration management for RouteWatch
 | 
			
		||||
package config
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// AppIdentifier is the reverse domain name identifier for the app
 | 
			
		||||
	AppIdentifier = "berlin.sneak.app.routewatch"
 | 
			
		||||
 | 
			
		||||
	// dirPermissions for creating directories
 | 
			
		||||
	dirPermissions = 0750 // rwxr-x---
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Config holds configuration for the entire application
 | 
			
		||||
type Config struct {
 | 
			
		||||
	// StateDir is the directory for all application state (database, snapshots)
 | 
			
		||||
	StateDir string
 | 
			
		||||
 | 
			
		||||
	// MaxRuntime is the maximum runtime (0 = run forever)
 | 
			
		||||
	MaxRuntime time.Duration
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// New creates a new Config with default paths based on the OS
 | 
			
		||||
func New() (*Config, error) {
 | 
			
		||||
	stateDir, err := getStateDirectory()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to determine state directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &Config{
 | 
			
		||||
		StateDir:   stateDir,
 | 
			
		||||
		MaxRuntime: 0, // Run forever by default
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetStateDir returns the state directory path
 | 
			
		||||
func (c *Config) GetStateDir() string {
 | 
			
		||||
	return c.StateDir
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getStateDirectory returns the appropriate state directory based on the OS
 | 
			
		||||
func getStateDirectory() (string, error) {
 | 
			
		||||
	switch runtime.GOOS {
 | 
			
		||||
	case "darwin":
 | 
			
		||||
		// macOS: ~/Library/Application Support/berlin.sneak.app.routewatch
 | 
			
		||||
		home, err := os.UserHomeDir()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(home, "Library", "Application Support", AppIdentifier), nil
 | 
			
		||||
 | 
			
		||||
	case "linux", "freebsd", "openbsd", "netbsd":
 | 
			
		||||
		// Unix-like: /var/lib/berlin.sneak.app.routewatch if root, else XDG_DATA_HOME
 | 
			
		||||
		if os.Geteuid() == 0 {
 | 
			
		||||
			return filepath.Join("/var/lib", AppIdentifier), nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check XDG_DATA_HOME first
 | 
			
		||||
		if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
 | 
			
		||||
			return filepath.Join(xdgData, AppIdentifier), nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Fall back to ~/.local/share/berlin.sneak.app.routewatch
 | 
			
		||||
		home, err := os.UserHomeDir()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(home, ".local", "share", AppIdentifier), nil
 | 
			
		||||
 | 
			
		||||
	default:
 | 
			
		||||
		return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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 {
 | 
			
		||||
		return fmt.Errorf("failed to create state directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
@ -8,9 +8,9 @@ import (
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/config"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/pkg/asinfo"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
	_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
 | 
			
		||||
@ -28,22 +28,77 @@ type Database struct {
 | 
			
		||||
	path   string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// New creates a new database connection and initializes the schema.
 | 
			
		||||
func New(cfg *config.Config, logger *slog.Logger) (*Database, error) {
 | 
			
		||||
	dbPath := filepath.Join(cfg.GetStateDir(), "db.sqlite")
 | 
			
		||||
// Config holds database configuration
 | 
			
		||||
type Config struct {
 | 
			
		||||
	Path string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getDefaultDatabasePath returns the appropriate database path for the OS
 | 
			
		||||
func getDefaultDatabasePath() string {
 | 
			
		||||
	const dbFilename = "db.sqlite"
 | 
			
		||||
 | 
			
		||||
	switch runtime.GOOS {
 | 
			
		||||
	case "darwin":
 | 
			
		||||
		// macOS: ~/Library/Application Support/berlin.sneak.app.routewatch/db.sqlite
 | 
			
		||||
		home, err := os.UserHomeDir()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return dbFilename
 | 
			
		||||
		}
 | 
			
		||||
		appSupport := filepath.Join(home, "Library", "Application Support", "berlin.sneak.app.routewatch")
 | 
			
		||||
		if err := os.MkdirAll(appSupport, dirPermissions); err != nil {
 | 
			
		||||
			return dbFilename
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(appSupport, dbFilename)
 | 
			
		||||
	default:
 | 
			
		||||
		// Linux and others: /var/lib/routewatch/db.sqlite
 | 
			
		||||
		dbDir := "/var/lib/routewatch"
 | 
			
		||||
		if err := os.MkdirAll(dbDir, dirPermissions); err != nil {
 | 
			
		||||
			// Fall back to user's home directory if can't create system directory
 | 
			
		||||
			home, err := os.UserHomeDir()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return dbFilename
 | 
			
		||||
			}
 | 
			
		||||
			userDir := filepath.Join(home, ".local", "share", "routewatch")
 | 
			
		||||
			if err := os.MkdirAll(userDir, dirPermissions); err != nil {
 | 
			
		||||
				return dbFilename
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return filepath.Join(userDir, dbFilename)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(dbDir, dbFilename)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewConfig provides default database configuration
 | 
			
		||||
func NewConfig() Config {
 | 
			
		||||
	return Config{
 | 
			
		||||
		Path: getDefaultDatabasePath(),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// New creates a new database connection and initializes the schema.
 | 
			
		||||
func New(logger *slog.Logger) (*Database, error) {
 | 
			
		||||
	config := NewConfig()
 | 
			
		||||
 | 
			
		||||
	return NewWithConfig(config, logger)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewWithConfig creates a new database connection with custom configuration
 | 
			
		||||
func NewWithConfig(config Config, logger *slog.Logger) (*Database, error) {
 | 
			
		||||
	// Log database path
 | 
			
		||||
	logger.Info("Opening database", "path", dbPath)
 | 
			
		||||
	logger.Info("Opening database", "path", config.Path)
 | 
			
		||||
 | 
			
		||||
	// Ensure directory exists
 | 
			
		||||
	dir := filepath.Dir(dbPath)
 | 
			
		||||
	dir := filepath.Dir(config.Path)
 | 
			
		||||
	if err := os.MkdirAll(dir, dirPermissions); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create database directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Add connection parameters for go-sqlite3
 | 
			
		||||
	// Enable WAL mode and other performance optimizations
 | 
			
		||||
	dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&cache=shared", dbPath)
 | 
			
		||||
	dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&cache=shared", config.Path)
 | 
			
		||||
	db, err := sql.Open("sqlite3", dsn)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to open database: %w", err)
 | 
			
		||||
@ -58,7 +113,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Database, error) {
 | 
			
		||||
	db.SetMaxIdleConns(1)
 | 
			
		||||
	db.SetConnMaxLifetime(0)
 | 
			
		||||
 | 
			
		||||
	database := &Database{db: db, logger: logger, path: dbPath}
 | 
			
		||||
	database := &Database{db: db, logger: logger, path: config.Path}
 | 
			
		||||
 | 
			
		||||
	if err := database.Initialize(); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to initialize database: %w", err)
 | 
			
		||||
 | 
			
		||||
@ -11,7 +11,6 @@ import (
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/config"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/database"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/metrics"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/routingtable"
 | 
			
		||||
@ -22,11 +21,23 @@ import (
 | 
			
		||||
	"go.uber.org/fx"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Config contains runtime configuration for RouteWatch
 | 
			
		||||
type Config struct {
 | 
			
		||||
	MaxRuntime time.Duration // Maximum runtime (0 = run forever)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	// routingTableStatsInterval is how often we log routing table statistics
 | 
			
		||||
	routingTableStatsInterval = 15 * time.Second
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// NewConfig provides default configuration
 | 
			
		||||
func NewConfig() Config {
 | 
			
		||||
	return Config{
 | 
			
		||||
		MaxRuntime: 0, // Run forever by default
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Dependencies contains all dependencies for RouteWatch
 | 
			
		||||
type Dependencies struct {
 | 
			
		||||
	fx.In
 | 
			
		||||
@ -36,7 +47,7 @@ type Dependencies struct {
 | 
			
		||||
	Streamer     *streamer.Streamer
 | 
			
		||||
	Server       *server.Server
 | 
			
		||||
	Logger       *slog.Logger
 | 
			
		||||
	Config       *config.Config
 | 
			
		||||
	Config       Config `optional:"true"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RouteWatch represents the main application instance
 | 
			
		||||
@ -52,17 +63,6 @@ type RouteWatch struct {
 | 
			
		||||
	mu           sync.Mutex
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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
 | 
			
		||||
func New(deps Dependencies) *RouteWatch {
 | 
			
		||||
	rw := &RouteWatch{
 | 
			
		||||
@ -74,9 +74,9 @@ func New(deps Dependencies) *RouteWatch {
 | 
			
		||||
		maxRuntime:   deps.Config.MaxRuntime,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create snapshotter if enabled
 | 
			
		||||
	if isSnapshotterEnabled() {
 | 
			
		||||
		snap, err := snapshotter.New(deps.RoutingTable, deps.Config, deps.Logger)
 | 
			
		||||
	// Create snapshotter unless disabled (for tests)
 | 
			
		||||
	if os.Getenv("ROUTEWATCH_DISABLE_SNAPSHOTTER") != "1" {
 | 
			
		||||
		snap, err := snapshotter.New(deps.RoutingTable, deps.Logger)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			deps.Logger.Error("Failed to create snapshotter", "error", err)
 | 
			
		||||
			// Continue without snapshotter
 | 
			
		||||
@ -235,10 +235,13 @@ func getModule() fx.Option {
 | 
			
		||||
	return fx.Options(
 | 
			
		||||
		fx.Provide(
 | 
			
		||||
			NewLogger,
 | 
			
		||||
			config.New,
 | 
			
		||||
			NewConfig,
 | 
			
		||||
			metrics.New,
 | 
			
		||||
			database.New,
 | 
			
		||||
			fx.Annotate(
 | 
			
		||||
				database.New,
 | 
			
		||||
				func(db *database.Database) database.Store {
 | 
			
		||||
					return db
 | 
			
		||||
				},
 | 
			
		||||
				fx.As(new(database.Store)),
 | 
			
		||||
			),
 | 
			
		||||
			routingtable.New,
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,6 @@ import (
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/config"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/database"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/metrics"
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/routingtable"
 | 
			
		||||
@ -172,14 +171,8 @@ func TestRouteWatchLiveFeed(t *testing.T) {
 | 
			
		||||
	// Create streamer
 | 
			
		||||
	s := streamer.New(logger, metricsTracker)
 | 
			
		||||
 | 
			
		||||
	// Create test config with empty state dir (no snapshot loading)
 | 
			
		||||
	cfg := &config.Config{
 | 
			
		||||
		StateDir:   "",
 | 
			
		||||
		MaxRuntime: 5 * time.Second,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create routing table
 | 
			
		||||
	rt := routingtable.New(cfg, logger)
 | 
			
		||||
	rt := routingtable.New(logger)
 | 
			
		||||
 | 
			
		||||
	// Create server
 | 
			
		||||
	srv := server.New(mockDB, rt, s, logger)
 | 
			
		||||
@ -191,7 +184,9 @@ func TestRouteWatchLiveFeed(t *testing.T) {
 | 
			
		||||
		Streamer:     s,
 | 
			
		||||
		Server:       srv,
 | 
			
		||||
		Logger:       logger,
 | 
			
		||||
		Config:       cfg,
 | 
			
		||||
		Config: Config{
 | 
			
		||||
			MaxRuntime: 5 * time.Second,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	rw := New(deps)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -8,12 +8,12 @@ import (
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"sync/atomic"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/config"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,7 @@ const (
 | 
			
		||||
	routeStalenessThreshold = 30 * time.Minute
 | 
			
		||||
 | 
			
		||||
	// snapshotFilename is the name of the snapshot file
 | 
			
		||||
	snapshotFilename = "routingtable.json.gz"
 | 
			
		||||
	snapshotFilename = "routewatch-snapshot.json.gz"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Route represents a single route entry in the routing table
 | 
			
		||||
@ -62,20 +62,16 @@ type RoutingTable struct {
 | 
			
		||||
	ipv4Updates      uint64 // Updates counter for rate calculation
 | 
			
		||||
	ipv6Updates      uint64 // Updates counter for rate calculation
 | 
			
		||||
	lastMetricsReset time.Time
 | 
			
		||||
 | 
			
		||||
	// Configuration
 | 
			
		||||
	snapshotDir string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// New creates a new routing table, loading from snapshot if available
 | 
			
		||||
func New(cfg *config.Config, logger *slog.Logger) *RoutingTable {
 | 
			
		||||
func New(logger *slog.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(),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Try to load from snapshot
 | 
			
		||||
@ -451,18 +447,51 @@ func isIPv6(prefix string) bool {
 | 
			
		||||
	return strings.Contains(prefix, ":")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getStateDirectory returns the appropriate state directory based on the OS
 | 
			
		||||
func getStateDirectory() (string, error) {
 | 
			
		||||
	switch runtime.GOOS {
 | 
			
		||||
	case "darwin":
 | 
			
		||||
		// macOS: Use ~/Library/Application Support/routewatch
 | 
			
		||||
		home, err := os.UserHomeDir()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(home, "Library", "Application Support", "routewatch"), nil
 | 
			
		||||
	case "linux", "freebsd", "openbsd", "netbsd":
 | 
			
		||||
		// Unix-like: Use /var/lib/routewatch if running as root, otherwise use XDG_STATE_HOME
 | 
			
		||||
		if os.Geteuid() == 0 {
 | 
			
		||||
			return "/var/lib/routewatch", nil
 | 
			
		||||
		}
 | 
			
		||||
		// Check XDG_STATE_HOME first
 | 
			
		||||
		if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" {
 | 
			
		||||
			return filepath.Join(xdgState, "routewatch"), nil
 | 
			
		||||
		}
 | 
			
		||||
		// Fall back to ~/.local/state/routewatch
 | 
			
		||||
		home, err := os.UserHomeDir()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(home, ".local", "state", "routewatch"), nil
 | 
			
		||||
	default:
 | 
			
		||||
		return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// loadFromSnapshot attempts to load the routing table from a snapshot file
 | 
			
		||||
func (rt *RoutingTable) loadFromSnapshot(logger *slog.Logger) error {
 | 
			
		||||
	// If no snapshot directory specified, nothing to load
 | 
			
		||||
	if rt.snapshotDir == "" {
 | 
			
		||||
		return nil
 | 
			
		||||
	stateDir, err := getStateDirectory()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to determine state directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	snapshotPath := filepath.Join(rt.snapshotDir, snapshotFilename)
 | 
			
		||||
	snapshotPath := filepath.Join(stateDir, 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
 | 
			
		||||
		logger.Info("No snapshot file found, starting with empty routing table")
 | 
			
		||||
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,20 +6,13 @@ import (
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/routewatch/internal/config"
 | 
			
		||||
	"github.com/google/uuid"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestRoutingTable(t *testing.T) {
 | 
			
		||||
	// Create a test logger
 | 
			
		||||
	logger := slog.Default()
 | 
			
		||||
 | 
			
		||||
	// Create test config with empty state dir (no snapshot loading)
 | 
			
		||||
	cfg := &config.Config{
 | 
			
		||||
		StateDir: "",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rt := New(cfg, logger)
 | 
			
		||||
	rt := New(logger)
 | 
			
		||||
 | 
			
		||||
	// Test data
 | 
			
		||||
	prefixID1 := uuid.New()
 | 
			
		||||
@ -130,13 +123,7 @@ func TestRoutingTable(t *testing.T) {
 | 
			
		||||
func TestRoutingTableConcurrency(t *testing.T) {
 | 
			
		||||
	// Create a test logger
 | 
			
		||||
	logger := slog.Default()
 | 
			
		||||
 | 
			
		||||
	// Create test config with empty state dir (no snapshot loading)
 | 
			
		||||
	cfg := &config.Config{
 | 
			
		||||
		StateDir: "",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rt := New(cfg, logger)
 | 
			
		||||
	rt := New(logger)
 | 
			
		||||
 | 
			
		||||
	// Test concurrent access
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
@ -190,13 +177,7 @@ func TestRoutingTableConcurrency(t *testing.T) {
 | 
			
		||||
func TestRouteUpdate(t *testing.T) {
 | 
			
		||||
	// Create a test logger
 | 
			
		||||
	logger := slog.Default()
 | 
			
		||||
 | 
			
		||||
	// Create test config with empty state dir (no snapshot loading)
 | 
			
		||||
	cfg := &config.Config{
 | 
			
		||||
		StateDir: "",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rt := New(cfg, logger)
 | 
			
		||||
	rt := New(logger)
 | 
			
		||||
 | 
			
		||||
	prefixID := uuid.New()
 | 
			
		||||
	originASNID := uuid.New()
 | 
			
		||||
 | 
			
		||||
@ -232,38 +232,25 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
 | 
			
		||||
 | 
			
		||||
// 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"`
 | 
			
		||||
		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"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
@ -325,23 +312,6 @@ func (s *Server) handleStats() http.HandlerFunc {
 | 
			
		||||
		// 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,
 | 
			
		||||
@ -360,7 +330,6 @@ func (s *Server) handleStats() http.HandlerFunc {
 | 
			
		||||
			IPv6Routes:        rtStats.IPv6Routes,
 | 
			
		||||
			IPv4UpdatesPerSec: rtStats.IPv4UpdatesRate,
 | 
			
		||||
			IPv6UpdatesPerSec: rtStats.IPv6UpdatesRate,
 | 
			
		||||
			HandlerStats:      handlerStatsInfo,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
 | 
			
		||||
@ -10,16 +10,16 @@ import (
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"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"
 | 
			
		||||
	snapshotFilename = "routewatch-snapshot.json.gz"
 | 
			
		||||
	tempFileSuffix   = ".tmp"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -36,15 +36,16 @@ type Snapshotter struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// New creates a new Snapshotter instance
 | 
			
		||||
func New(rt *routingtable.RoutingTable, cfg *config.Config, logger *slog.Logger) (*Snapshotter, error) {
 | 
			
		||||
	stateDir := cfg.GetStateDir()
 | 
			
		||||
func New(rt *routingtable.RoutingTable, logger *slog.Logger) (*Snapshotter, error) {
 | 
			
		||||
	stateDir, err := getStateDirectory()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to determine state directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 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)
 | 
			
		||||
		}
 | 
			
		||||
	// Ensure state directory exists
 | 
			
		||||
	const stateDirPerms = 0750
 | 
			
		||||
	if err := os.MkdirAll(stateDir, stateDirPerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create state directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	s := &Snapshotter{
 | 
			
		||||
@ -75,6 +76,38 @@ func (s *Snapshotter) Start(ctx context.Context) {
 | 
			
		||||
	go s.periodicSnapshot()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getStateDirectory returns the appropriate state directory based on the OS
 | 
			
		||||
func getStateDirectory() (string, error) {
 | 
			
		||||
	switch runtime.GOOS {
 | 
			
		||||
	case "darwin":
 | 
			
		||||
		// macOS: Use ~/Library/Application Support/routewatch
 | 
			
		||||
		home, err := os.UserHomeDir()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(home, "Library", "Application Support", "routewatch"), nil
 | 
			
		||||
	case "linux", "freebsd", "openbsd", "netbsd":
 | 
			
		||||
		// Unix-like: Use /var/lib/routewatch if running as root, otherwise use XDG_STATE_HOME
 | 
			
		||||
		if os.Geteuid() == 0 {
 | 
			
		||||
			return "/var/lib/routewatch", nil
 | 
			
		||||
		}
 | 
			
		||||
		// Check XDG_STATE_HOME first
 | 
			
		||||
		if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" {
 | 
			
		||||
			return filepath.Join(xdgState, "routewatch"), nil
 | 
			
		||||
		}
 | 
			
		||||
		// Fall back to ~/.local/state/routewatch
 | 
			
		||||
		home, err := os.UserHomeDir()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return filepath.Join(home, ".local", "state", "routewatch"), nil
 | 
			
		||||
	default:
 | 
			
		||||
		return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// periodicSnapshot runs periodic snapshots
 | 
			
		||||
func (s *Snapshotter) periodicSnapshot() {
 | 
			
		||||
	defer s.wg.Done()
 | 
			
		||||
@ -97,11 +130,6 @@ func (s *Snapshotter) periodicSnapshot() {
 | 
			
		||||
 | 
			
		||||
// 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()
 | 
			
		||||
 | 
			
		||||
@ -195,57 +195,6 @@ func (s *Streamer) GetMetrics() metrics.StreamMetrics {
 | 
			
		||||
	return s.metrics.GetStreamMetrics()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// HandlerStats represents metrics for a single handler
 | 
			
		||||
type HandlerStats struct {
 | 
			
		||||
	Name           string
 | 
			
		||||
	QueueLength    int
 | 
			
		||||
	QueueCapacity  int
 | 
			
		||||
	ProcessedCount uint64
 | 
			
		||||
	DroppedCount   uint64
 | 
			
		||||
	AvgProcessTime time.Duration
 | 
			
		||||
	MinProcessTime time.Duration
 | 
			
		||||
	MaxProcessTime time.Duration
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetHandlerStats returns current handler statistics
 | 
			
		||||
func (s *Streamer) GetHandlerStats() []HandlerStats {
 | 
			
		||||
	s.mu.RLock()
 | 
			
		||||
	defer s.mu.RUnlock()
 | 
			
		||||
 | 
			
		||||
	stats := make([]HandlerStats, 0, len(s.handlers))
 | 
			
		||||
 | 
			
		||||
	for _, info := range s.handlers {
 | 
			
		||||
		info.metrics.mu.Lock()
 | 
			
		||||
 | 
			
		||||
		hs := HandlerStats{
 | 
			
		||||
			Name:           fmt.Sprintf("%T", info.handler),
 | 
			
		||||
			QueueLength:    len(info.queue),
 | 
			
		||||
			QueueCapacity:  cap(info.queue),
 | 
			
		||||
			ProcessedCount: info.metrics.processedCount,
 | 
			
		||||
			DroppedCount:   info.metrics.droppedCount,
 | 
			
		||||
			MinProcessTime: info.metrics.minTime,
 | 
			
		||||
			MaxProcessTime: info.metrics.maxTime,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 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)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		info.metrics.mu.Unlock()
 | 
			
		||||
 | 
			
		||||
		stats = append(stats, hs)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return stats
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetDroppedMessages returns the total number of dropped messages
 | 
			
		||||
func (s *Streamer) GetDroppedMessages() uint64 {
 | 
			
		||||
	return atomic.LoadUint64(&s.totalDropped)
 | 
			
		||||
 | 
			
		||||
@ -153,10 +153,6 @@
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div id="handler-stats-container" class="status-grid">
 | 
			
		||||
        <!-- Handler stats will be dynamically added here -->
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <script>
 | 
			
		||||
        function formatBytes(bytes) {
 | 
			
		||||
            if (bytes === 0) return '0 B';
 | 
			
		||||
@ -170,45 +166,6 @@
 | 
			
		||||
            return num.toLocaleString();
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        function updateHandlerStats(handlerStats) {
 | 
			
		||||
            const container = document.getElementById('handler-stats-container');
 | 
			
		||||
            container.innerHTML = '';
 | 
			
		||||
            
 | 
			
		||||
            handlerStats.forEach(handler => {
 | 
			
		||||
                const card = document.createElement('div');
 | 
			
		||||
                card.className = 'status-card';
 | 
			
		||||
                
 | 
			
		||||
                // Extract handler name (remove package prefix)
 | 
			
		||||
                const handlerName = handler.name.split('.').pop();
 | 
			
		||||
                
 | 
			
		||||
                card.innerHTML = `
 | 
			
		||||
                    <h2>${handlerName}</h2>
 | 
			
		||||
                    <div class="metric">
 | 
			
		||||
                        <span class="metric-label">Queue</span>
 | 
			
		||||
                        <span class="metric-value">${handler.queue_length}/${handler.queue_capacity}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="metric">
 | 
			
		||||
                        <span class="metric-label">Processed</span>
 | 
			
		||||
                        <span class="metric-value">${formatNumber(handler.processed_count)}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="metric">
 | 
			
		||||
                        <span class="metric-label">Dropped</span>
 | 
			
		||||
                        <span class="metric-value ${handler.dropped_count > 0 ? 'disconnected' : ''}">${formatNumber(handler.dropped_count)}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="metric">
 | 
			
		||||
                        <span class="metric-label">Avg Time</span>
 | 
			
		||||
                        <span class="metric-value">${handler.avg_process_time_ms.toFixed(2)} ms</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="metric">
 | 
			
		||||
                        <span class="metric-label">Min/Max Time</span>
 | 
			
		||||
                        <span class="metric-value">${handler.min_process_time_ms.toFixed(2)} / ${handler.max_process_time_ms.toFixed(2)} ms</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                `;
 | 
			
		||||
                
 | 
			
		||||
                container.appendChild(card);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        function updateStatus() {
 | 
			
		||||
            fetch('/api/v1/stats')
 | 
			
		||||
                .then(response => response.json())
 | 
			
		||||
@ -246,9 +203,6 @@
 | 
			
		||||
                    document.getElementById('ipv4_updates_per_sec').textContent = data.ipv4_updates_per_sec.toFixed(1);
 | 
			
		||||
                    document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1);
 | 
			
		||||
                    
 | 
			
		||||
                    // Update handler stats
 | 
			
		||||
                    updateHandlerStats(data.handler_stats || []);
 | 
			
		||||
                    
 | 
			
		||||
                    // Clear any errors
 | 
			
		||||
                    document.getElementById('error').style.display = 'none';
 | 
			
		||||
                })
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user