16 Commits

Author SHA1 Message Date
68cc06690b Use TRUNCATE mode for WAL checkpoints
PASSIVE checkpoints may not fully checkpoint when writers are active.
TRUNCATE mode blocks writers briefly but ensures complete checkpointing,
keeping the WAL small for consistent read performance under heavy load.
2026-01-01 06:49:58 -08:00
4f62b280c5 Reduce WAL checkpoint interval from 30s to 5s
Under heavy write load, 30 seconds is too long between checkpoints,
causing the WAL to grow and slow down read queries. More aggressive
checkpointing keeps the WAL small and maintains read performance.
2026-01-01 06:48:58 -08:00
f1d7c21478 Run WAL checkpoint on startup with logging
Explicitly checkpoint the WAL on database initialization to consolidate
any large WAL file from previous runs. Log the checkpoint results to
help diagnose issues.
2026-01-01 06:38:15 -08:00
a163449a28 Improve request logging and make health check lightweight
- Log slow requests (>1s) at WARNING level with slow=true flag
- Log request timeouts at WARNING level in TimeoutMiddleware
- Replace heavy GetStatsContext with lightweight Ping in health check
- Add Ping method to database interface (SELECT 1)
2026-01-01 06:06:20 -08:00
8f524485f7 Add periodic WAL checkpointing to fix slow queries
The WAL file was growing to 700MB+ which caused COUNT(*) queries to
timeout. Reads must scan the WAL to find current page versions, and
a large WAL makes this slow.

Add Checkpoint method to database interface and run PASSIVE checkpoints
every 30 seconds via the DBMaintainer. This keeps the WAL small and
maintains fast read performance under heavy write load.
2026-01-01 05:42:03 -08:00
c6fa2b0fbd Fix container to run app as routewatch user
Use runuser to drop privileges and execute the app as the routewatch
user (uid 1000). Fix data directory permissions at runtime since host
mounts may have incorrect ownership.
2025-12-31 16:17:59 -08:00
f788a0dbf9 set state dir properly in container 2025-12-31 16:08:17 -08:00
aebdd1b23e Add oldest and newest route timestamps to status page
Display oldest and newest route timestamps in the Routing Table card
on the /status page. Timestamps are shown as relative times (e.g.,
"5m ago", "2h 30m ago").

Changes:
- Add OldestRoute and NewestRoute fields to database.Stats
- Query MIN/MAX of last_updated across live_routes_v4 and v6 tables
- Include timestamps in both /status.json and /api/v1/stats responses
- Add formatRelativeTime JavaScript function for display
2025-12-31 15:47:57 -08:00
8fc10ae98d Fix NULL handling in GetWHOISStats query
When the asns table is empty, SUM() returns NULL which cannot be
scanned into an int. Wrap SUM expressions in COALESCE to return 0
instead of NULL.
2025-12-31 15:17:09 -08:00
d27536812f Remove heading from status page 2025-12-31 15:11:03 -08:00
58b5333c6c Fix navbar and simplify templates
- Fix nested anchor tags in navbar (invalid HTML)
- Hardcode app name and author in templates
- Remove hero section from index page
2025-12-31 15:10:24 -08:00
4284e923a6 Add navbar and home page with search functionality
- Create new home page (/) with overview stats, ASN lookup,
  AS name search, and IP address lookup with JSON display
- Add responsive navbar to all pages with app branding
- Navbar shows "routewatch by @sneak" with link to author
- Status page accessible via navbar link
- Remove redirect from / to /status, serve home page instead
2025-12-31 14:56:02 -08:00
45810e3fc8 Fix prefix URL routing for encoded CIDR notation
Change route from wildcard /prefix/* to explicit /prefix/{prefix}/{len}
to properly handle URL-encoded IPv6 addresses with CIDR notation.

- Separate prefix and length into individual path parameters
- Add prefixURL template function for generating correct links
- Remove url.QueryUnescape from handlers (chi handles decoding)
2026-01-01 05:37:37 +07:00
27909e021f ci 2025-12-31 06:14:44 +01:00
c116b035bd Add status page enhancements with new metrics and footer
- Add GC statistics (run count, total/last pause, heap usage)
- Add BGP peer count tracking from RIS Live OPEN/NOTIFICATION messages
- Add route churn rate metric (announcements + withdrawals per second)
- Add announcement and withdrawal counters
- Add footer with attribution, license, and git revision
- Embed git revision at build time via ldflags
- Update HTML template to display all new metrics
2025-12-30 14:50:54 +07:00
1115954827 Fix prefix URL routing to handle CIDR notation with slashes
- Use wildcard route pattern for /prefix/* endpoints
- Extract prefix parameter using chi.URLParam(r, "*")
- Fixes 400 error when accessing /prefix/x.x.x.x/32 directly
2025-12-30 14:41:57 +07:00
21 changed files with 1321 additions and 89 deletions

View File

@@ -39,9 +39,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Create non-root user
RUN useradd -r -u 1000 -m routewatch
# Create state directory
RUN mkdir -p /var/lib/routewatch && chown routewatch:routewatch /var/lib/routewatch
RUN mkdir -p /var/lib/berlin.sneak.app.routewatch && chown routewatch:routewatch /var/lib/berlin.sneak.app.routewatch
RUN mkdir /app
WORKDIR /app
# Copy binary and source archive from builder
@@ -51,16 +51,15 @@ COPY --from=builder /routewatch-source.tar.zst /app/source/routewatch-source.tar
# Set ownership
RUN chown -R routewatch:routewatch /app
USER routewatch
# Default state directory
ENV ROUTEWATCH_STATE_DIR=/var/lib/routewatch
ENV XDG_DATA_HOME=/var/lib
# Expose HTTP port
EXPOSE 8080
COPY ./entrypoint.sh /entrypoint.sh
# Health check using the health endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["/app/routewatch"]
ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ]

View File

@@ -1,5 +1,11 @@
export DEBUG = routewatch
# Git revision for version embedding
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
GIT_REVISION_SHORT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
VERSION_PKG := git.eeqj.de/sneak/routewatch/internal/version
LDFLAGS := -X $(VERSION_PKG).GitRevision=$(GIT_REVISION) -X $(VERSION_PKG).GitRevisionShort=$(GIT_REVISION_SHORT)
.PHONY: test fmt lint build clean run asupdate
all: test
@@ -15,7 +21,7 @@ lint:
golangci-lint run
build:
CGO_ENABLED=1 go build -o bin/routewatch cmd/routewatch/main.go
CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o bin/routewatch cmd/routewatch/main.go
clean:
rm -rf bin/

View File

@@ -2,6 +2,7 @@
RouteWatch is a real-time BGP routing table monitor that streams BGP UPDATE messages from the RIPE RIS Live service, maintains a live routing table in SQLite, and provides HTTP APIs for querying routing information.
## Features
- Real-time streaming of BGP updates from RIPE RIS Live

7
entrypoint.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
cd /var/lib/berlin.sneak.app.routewatch
chown -R routewatch:routewatch .
chmod 700 .
exec runuser -u routewatch -- /app/routewatch

View File

@@ -106,14 +106,13 @@ func New(cfg *config.Config, logger *logger.Logger) (*Database, error) {
func (d *Database) Initialize() error {
// Set SQLite pragmas for performance
pragmas := []string{
"PRAGMA journal_mode=WAL", // Write-Ahead Logging
"PRAGMA synchronous=OFF", // Don't wait for disk writes
"PRAGMA cache_size=-3145728", // 3GB cache (upper limit for 2.4GB DB)
"PRAGMA temp_store=MEMORY", // Use memory for temp tables
"PRAGMA wal_checkpoint(TRUNCATE)", // Checkpoint and truncate WAL now
"PRAGMA busy_timeout=5000", // 5 second busy timeout
"PRAGMA analysis_limit=0", // Disable automatic ANALYZE
"PRAGMA auto_vacuum=INCREMENTAL", // Enable incremental vacuum
"PRAGMA journal_mode=WAL", // Write-Ahead Logging
"PRAGMA synchronous=OFF", // Don't wait for disk writes
"PRAGMA cache_size=-3145728", // 3GB cache (upper limit for 2.4GB DB)
"PRAGMA temp_store=MEMORY", // Use memory for temp tables
"PRAGMA busy_timeout=5000", // 5 second busy timeout
"PRAGMA analysis_limit=0", // Disable automatic ANALYZE
"PRAGMA auto_vacuum=INCREMENTAL", // Enable incremental vacuum
}
for _, pragma := range pragmas {
@@ -122,7 +121,20 @@ func (d *Database) Initialize() error {
}
}
err := d.exec(dbSchema)
// Run WAL checkpoint on startup to consolidate any existing WAL
var walPages, checkpointedPages, movedPages int
err := d.db.QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&walPages, &checkpointedPages, &movedPages)
if err != nil {
d.logger.Warn("Failed to checkpoint WAL on startup", "error", err)
} else {
d.logger.Info("WAL checkpoint on startup",
"wal_pages", walPages,
"checkpointed", checkpointedPages,
"moved", movedPages,
)
}
err = d.exec(dbSchema)
if err != nil {
return err
}
@@ -946,6 +958,23 @@ func (d *Database) GetStatsContext(ctx context.Context) (Stats, error) {
}
stats.LiveRoutes = v4Count + v6Count
// Get oldest and newest route timestamps
routeTimestampQuery := `
SELECT MIN(last_updated), MAX(last_updated) FROM (
SELECT last_updated FROM live_routes_v4
UNION ALL
SELECT last_updated FROM live_routes_v6
)
`
var oldestRoute, newestRoute *time.Time
err = d.db.QueryRowContext(ctx, routeTimestampQuery).Scan(&oldestRoute, &newestRoute)
if err != nil {
d.logger.Warn("Failed to get route timestamps", "error", err)
} else {
stats.OldestRoute = oldestRoute
stats.NewestRoute = newestRoute
}
// Get prefix distribution
stats.IPv4PrefixDistribution, stats.IPv6PrefixDistribution, err = d.GetPrefixDistributionContext(ctx)
if err != nil {
@@ -1669,9 +1698,9 @@ func (d *Database) GetWHOISStats(ctx context.Context, staleThreshold time.Durati
query := `
SELECT
COUNT(*) as total,
SUM(CASE WHEN whois_updated_at IS NULL THEN 1 ELSE 0 END) as never_fetched,
SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at < ? THEN 1 ELSE 0 END) as stale,
SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at >= ? THEN 1 ELSE 0 END) as fresh
COALESCE(SUM(CASE WHEN whois_updated_at IS NULL THEN 1 ELSE 0 END), 0) as never_fetched,
COALESCE(SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at < ? THEN 1 ELSE 0 END), 0) as stale,
COALESCE(SUM(CASE WHEN whois_updated_at IS NOT NULL AND whois_updated_at >= ? THEN 1 ELSE 0 END), 0) as fresh
FROM asns
`
@@ -1969,3 +1998,26 @@ func (d *Database) Analyze(ctx context.Context) error {
return nil
}
// Checkpoint runs a WAL checkpoint to transfer data from the WAL to the main database.
// Uses TRUNCATE mode which blocks writers briefly but ensures complete checkpoint,
// keeping the WAL small for fast read performance.
func (d *Database) Checkpoint(ctx context.Context) error {
_, err := d.db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
if err != nil {
return fmt.Errorf("failed to checkpoint WAL: %w", err)
}
return nil
}
// Ping checks if the database connection is alive with a lightweight query.
func (d *Database) Ping(ctx context.Context) error {
var result int
err := d.db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
if err != nil {
return fmt.Errorf("database ping failed: %w", err)
}
return nil
}

View File

@@ -18,6 +18,8 @@ type Stats struct {
Peers int
FileSizeBytes int64
LiveRoutes int
OldestRoute *time.Time
NewestRoute *time.Time
IPv4PrefixDistribution []PrefixDistribution
IPv6PrefixDistribution []PrefixDistribution
}
@@ -85,6 +87,8 @@ type Store interface {
// Maintenance operations
Vacuum(ctx context.Context) error
Analyze(ctx context.Context) error
Checkpoint(ctx context.Context) error
Ping(ctx context.Context) error
}
// Ensure Database implements Store

View File

@@ -30,6 +30,14 @@ type Tracker struct {
// Route update metrics
ipv4UpdateRate metrics.Meter
ipv6UpdateRate metrics.Meter
// Announcement/withdrawal metrics
announcementCounter metrics.Counter
withdrawalCounter metrics.Counter
churnRate metrics.Meter // combined announcements + withdrawals per second
// BGP peer tracking
bgpPeerCount atomic.Int32
}
// New creates a new metrics tracker
@@ -37,15 +45,18 @@ func New() *Tracker {
registry := metrics.NewRegistry()
return &Tracker{
registry: registry,
messageCounter: metrics.NewCounter(),
byteCounter: metrics.NewCounter(),
messageRate: metrics.NewMeter(),
byteRate: metrics.NewMeter(),
wireByteCounter: metrics.NewCounter(),
wireByteRate: metrics.NewMeter(),
ipv4UpdateRate: metrics.NewMeter(),
ipv6UpdateRate: metrics.NewMeter(),
registry: registry,
messageCounter: metrics.NewCounter(),
byteCounter: metrics.NewCounter(),
messageRate: metrics.NewMeter(),
byteRate: metrics.NewMeter(),
wireByteCounter: metrics.NewCounter(),
wireByteRate: metrics.NewMeter(),
ipv4UpdateRate: metrics.NewMeter(),
ipv6UpdateRate: metrics.NewMeter(),
announcementCounter: metrics.NewCounter(),
withdrawalCounter: metrics.NewCounter(),
churnRate: metrics.NewMeter(),
}
}
@@ -134,6 +145,56 @@ func (t *Tracker) RecordIPv6Update() {
t.ipv6UpdateRate.Mark(1)
}
// RecordAnnouncement records a route announcement
func (t *Tracker) RecordAnnouncement() {
t.announcementCounter.Inc(1)
t.churnRate.Mark(1)
}
// RecordWithdrawal records a route withdrawal
func (t *Tracker) RecordWithdrawal() {
t.withdrawalCounter.Inc(1)
t.churnRate.Mark(1)
}
// SetBGPPeerCount updates the current BGP peer count
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
}
}
// GetBGPPeerCount returns the current BGP peer count
func (t *Tracker) GetBGPPeerCount() int {
return int(t.bgpPeerCount.Load())
}
// GetAnnouncementCount returns the total announcement count
func (t *Tracker) GetAnnouncementCount() uint64 {
count := t.announcementCounter.Count()
if count < 0 {
return 0
}
return uint64(count)
}
// GetWithdrawalCount returns the total withdrawal count
func (t *Tracker) GetWithdrawalCount() uint64 {
count := t.withdrawalCounter.Count()
if count < 0 {
return 0
}
return uint64(count)
}
// GetChurnRate returns the route churn rate per second
func (t *Tracker) GetChurnRate() float64 {
return t.churnRate.Rate1()
}
// GetRouteMetrics returns current route update metrics
func (t *Tracker) GetRouteMetrics() RouteMetrics {
return RouteMetrics{

View File

@@ -415,6 +415,16 @@ func (m *mockStore) Analyze(ctx context.Context) error {
return nil
}
// Checkpoint mock implementation
func (m *mockStore) Checkpoint(ctx context.Context) error {
return nil
}
// Ping mock implementation
func (m *mockStore) Ping(ctx context.Context) error {
return nil
}
func TestRouteWatchLiveFeed(t *testing.T) {
// Create mock database

View File

@@ -12,6 +12,11 @@ import (
// Database maintenance configuration constants.
const (
// checkpointInterval is how often to run WAL checkpoint.
// Frequent checkpoints keep the WAL small, improving read performance.
// Under heavy write load, we need aggressive checkpointing.
checkpointInterval = 5 * time.Second
// vacuumInterval is how often to run incremental vacuum.
// Since incremental vacuum only frees ~1000 pages (~4MB) per run,
// we run it frequently to keep up with deletions.
@@ -20,6 +25,9 @@ const (
// analyzeInterval is how often to run ANALYZE.
analyzeInterval = 1 * time.Hour
// checkpointTimeout is the max time for WAL checkpoint.
checkpointTimeout = 10 * time.Second
// vacuumTimeout is the max time for incremental vacuum (should be quick).
vacuumTimeout = 30 * time.Second
@@ -35,13 +43,16 @@ type DBMaintainer struct {
wg sync.WaitGroup
// Stats tracking
statsMu sync.Mutex
lastVacuum time.Time
lastAnalyze time.Time
vacuumCount int
analyzeCount int
lastVacuumError error
lastAnalyzeError error
statsMu sync.Mutex
lastCheckpoint time.Time
lastVacuum time.Time
lastAnalyze time.Time
checkpointCount int
vacuumCount int
analyzeCount int
lastCheckpointError error
lastVacuumError error
lastAnalyzeError error
}
// NewDBMaintainer creates a new database maintainer.
@@ -58,6 +69,7 @@ func (m *DBMaintainer) Start() {
m.wg.Add(1)
go m.run()
m.logger.Info("Database maintainer started",
"checkpoint_interval", checkpointInterval,
"vacuum_interval", vacuumInterval,
"analyze_interval", analyzeInterval,
)
@@ -75,8 +87,10 @@ func (m *DBMaintainer) run() {
defer m.wg.Done()
// Use different timers for each task
checkpointTimer := time.NewTimer(checkpointInterval)
vacuumTimer := time.NewTimer(vacuumInterval)
analyzeTimer := time.NewTimer(analyzeInterval)
defer checkpointTimer.Stop()
defer vacuumTimer.Stop()
defer analyzeTimer.Stop()
@@ -85,6 +99,10 @@ func (m *DBMaintainer) run() {
case <-m.stopCh:
return
case <-checkpointTimer.C:
m.runCheckpoint()
checkpointTimer.Reset(checkpointInterval)
case <-vacuumTimer.C:
m.runVacuum()
vacuumTimer.Reset(vacuumInterval)
@@ -96,6 +114,30 @@ func (m *DBMaintainer) run() {
}
}
// runCheckpoint performs a WAL checkpoint to keep the WAL file small.
func (m *DBMaintainer) runCheckpoint() {
ctx, cancel := context.WithTimeout(context.Background(), checkpointTimeout)
defer cancel()
startTime := time.Now()
err := m.db.Checkpoint(ctx)
m.statsMu.Lock()
m.lastCheckpoint = time.Now()
m.lastCheckpointError = err
if err == nil {
m.checkpointCount++
}
m.statsMu.Unlock()
if err != nil {
m.logger.Error("WAL checkpoint failed", "error", err, "duration", time.Since(startTime))
} else {
m.logger.Debug("WAL checkpoint completed", "duration", time.Since(startTime))
}
}
// runVacuum performs an incremental vacuum operation on the database.
func (m *DBMaintainer) runVacuum() {
ctx, cancel := context.WithTimeout(context.Background(), vacuumTimeout)

View File

@@ -113,6 +113,10 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
timestamp: timestamp,
path: msg.Path,
})
// Record announcement in metrics
if h.metrics != nil {
h.metrics.RecordAnnouncement()
}
}
}
@@ -126,6 +130,10 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
timestamp: timestamp,
path: msg.Path,
})
// Record withdrawal in metrics
if h.metrics != nil {
h.metrics.RecordWithdrawal()
}
}
// Check if we need to flush

View File

@@ -7,7 +7,6 @@ import (
"errors"
"net"
"net/http"
"net/url"
"runtime"
"sort"
"strconv"
@@ -38,6 +37,7 @@ type HealthCheckResponse struct {
// handleHealthCheck returns a handler that performs health checks.
// Returns 200 if healthy, 503 if any check fails.
// Uses lightweight checks to avoid timeout issues under load.
func (s *Server) handleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), healthCheckTimeout)
@@ -46,13 +46,11 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
checks := make(map[string]string)
healthy := true
// Check database connectivity
dbStats, err := s.db.GetStatsContext(ctx)
// Check database connectivity with lightweight ping
err := s.db.Ping(ctx)
if err != nil {
checks["database"] = "error: " + err.Error()
healthy = false
} else if dbStats.ASNs == 0 && dbStats.Prefixes == 0 {
checks["database"] = "warning: empty database"
} else {
checks["database"] = "ok"
}
@@ -88,10 +86,16 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
}
}
// 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)
// handleIndex returns a handler that serves the home page.
func (s *Server) handleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.IndexTemplate()
if err := tmpl.Execute(w, nil); err != nil {
s.logger.Error("Failed to render index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
}
@@ -159,6 +163,8 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
@@ -253,6 +259,8 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: ipv4Routes,
IPv6Routes: ipv6Routes,
OldestRoute: dbStats.OldestRoute,
NewestRoute: dbStats.NewestRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
@@ -320,6 +328,23 @@ func (s *Server) handleStats() http.HandlerFunc {
MaxProcessTimeMs float64 `json:"max_process_time_ms"`
}
// GCStats represents garbage collection statistics
type GCStats struct {
NumGC uint32 `json:"num_gc"`
TotalPauseMs uint64 `json:"total_pause_ms"`
LastPauseMs float64 `json:"last_pause_ms"`
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
HeapSysBytes uint64 `json:"heap_sys_bytes"`
}
// StreamStats represents stream statistics including announcements/withdrawals
type StreamStats struct {
Announcements uint64 `json:"announcements"`
Withdrawals uint64 `json:"withdrawals"`
RouteChurnPerSec float64 `json:"route_churn_per_sec"`
BGPPeerCount int `json:"bgp_peer_count"`
}
// StatsResponse represents the API statistics response
type StatsResponse struct {
Uptime string `json:"uptime"`
@@ -335,6 +360,8 @@ func (s *Server) handleStats() http.HandlerFunc {
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
@@ -345,6 +372,8 @@ func (s *Server) handleStats() http.HandlerFunc {
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
@@ -450,6 +479,25 @@ func (s *Server) handleStats() http.HandlerFunc {
connectionDuration = time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
}
// Get announcement/withdrawal stats from metrics tracker
metricsTracker := s.streamer.GetMetricsTracker()
announcements := metricsTracker.GetAnnouncementCount()
withdrawals := metricsTracker.GetWithdrawalCount()
churnRate := metricsTracker.GetChurnRate()
bgpPeerCount := metricsTracker.GetBGPPeerCount()
// Calculate last GC pause
const (
nanosecondsPerMillisecond = 1e6
gcPauseHistorySize = 256 // Size of runtime.MemStats.PauseNs circular buffer
)
var lastPauseMs float64
if memStats.NumGC > 0 {
// PauseNs is a circular buffer, get the most recent pause
lastPauseIdx := (memStats.NumGC + gcPauseHistorySize - 1) % gcPauseHistorySize
lastPauseMs = float64(memStats.PauseNs[lastPauseIdx]) / nanosecondsPerMillisecond
}
stats := StatsResponse{
Uptime: uptime,
TotalMessages: metrics.TotalMessages,
@@ -464,6 +512,19 @@ func (s *Server) handleStats() http.HandlerFunc {
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
GC: GCStats{
NumGC: memStats.NumGC,
TotalPauseMs: memStats.PauseTotalNs / uint64(nanosecondsPerMillisecond),
LastPauseMs: lastPauseMs,
HeapAllocBytes: memStats.HeapAlloc,
HeapSysBytes: memStats.HeapSys,
},
Stream: StreamStats{
Announcements: announcements,
Withdrawals: withdrawals,
RouteChurnPerSec: churnRate,
BGPPeerCount: bgpPeerCount,
},
ASNs: dbStats.ASNs,
Prefixes: dbStats.Prefixes,
IPv4Prefixes: dbStats.IPv4Prefixes,
@@ -474,6 +535,8 @@ func (s *Server) handleStats() http.HandlerFunc {
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: ipv4Routes,
IPv6Routes: ipv6Routes,
OldestRoute: dbStats.OldestRoute,
NewestRoute: dbStats.NewestRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
HandlerStats: handlerStatsInfo,
@@ -685,20 +748,18 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
// handlePrefixDetailJSON returns prefix details as JSON
func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get prefix and length from URL params
prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" {
writeJSONError(w, http.StatusBadRequest, "Prefix parameter is required")
lenParam := chi.URLParam(r, "len")
if prefixParam == "" || lenParam == "" {
writeJSONError(w, http.StatusBadRequest, "Prefix and length parameters are required")
return
}
// URL decode the prefix parameter
prefix, err := url.QueryUnescape(prefixParam)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid prefix parameter")
return
}
// Combine prefix and length into CIDR notation
prefix := prefixParam + "/" + lenParam
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil {
@@ -851,20 +912,18 @@ func (s *Server) handleASDetail() http.HandlerFunc {
// 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) {
// Get prefix and length from URL params
prefixParam := chi.URLParam(r, "prefix")
if prefixParam == "" {
http.Error(w, "Prefix parameter is required", http.StatusBadRequest)
lenParam := chi.URLParam(r, "len")
if prefixParam == "" || lenParam == "" {
http.Error(w, "Prefix and length parameters are 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
}
// Combine prefix and length into CIDR notation
prefix := prefixParam + "/" + lenParam
routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil {

View File

@@ -168,7 +168,7 @@ func (tw *timeoutWriter) markWritten() {
// The timeout parameter specifies the maximum duration allowed for request processing.
// The returned middleware handles panics from the wrapped handler by re-panicking
// after cleanup, and prevents concurrent writes to the response after timeout occurs.
func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
@@ -204,6 +204,14 @@ func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
tw.markWritten() // Prevent the handler from writing after timeout
execTime := time.Since(startTime)
// Log the timeout as a warning
logger.Warn("Request timeout",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", execTime.Milliseconds(),
"remote_addr", r.RemoteAddr,
)
// Write directly to the underlying writer since we've marked tw as written
// This is safe because markWritten() prevents the handler from writing
tw.mu.Lock()
@@ -350,10 +358,16 @@ func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handle
// Log request completion
duration := time.Since(start)
logLevel := slog.LevelInfo
// Slow query threshold (1 second)
const slowQueryThreshold = 1 * time.Second
if sw.statusCode >= http.StatusInternalServerError {
logLevel = slog.LevelError
} else if sw.statusCode >= http.StatusBadRequest {
logLevel = slog.LevelWarn
} else if duration >= slowQueryThreshold {
logLevel = slog.LevelWarn
}
logger.Log(r.Context(), logLevel, "HTTP request completed",
@@ -362,6 +376,7 @@ func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handle
"status", sw.statusCode,
"duration_ms", duration.Milliseconds(),
"remote_addr", r.RemoteAddr,
"slow", duration >= slowQueryThreshold,
)
})
}

View File

@@ -17,18 +17,18 @@ func (s *Server) setupRoutes() {
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
r.Use(middleware.Recoverer)
const requestTimeout = 30 * time.Second // Increased from 8s for slow queries
r.Use(TimeoutMiddleware(requestTimeout))
r.Use(TimeoutMiddleware(requestTimeout, s.logger.Logger))
r.Use(JSONResponseMiddleware)
// Routes
r.Get("/", s.handleRoot())
r.Get("/", s.handleIndex())
r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get("/.well-known/healthcheck.json", JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP)
// AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail())
r.Get("/prefix/{prefix}", s.handlePrefixDetail())
r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetail())
r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
@@ -45,7 +45,7 @@ func (s *Server) setupRoutes() {
r.Get("/stats", s.handleStats())
r.Get("/ip/{ip}", s.handleIPLookup())
r.Get("/as/{asn}", s.handleASDetailJSON())
r.Get("/prefix/{prefix}", s.handlePrefixDetailJSON())
r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetailJSON())
})
s.router = r

View File

@@ -114,6 +114,8 @@ type Streamer struct {
metrics *metrics.Tracker
totalDropped 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
}
// New creates a new Streamer instance configured to connect to the RIS Live API.
@@ -132,7 +134,8 @@ func New(logger *logger.Logger, metrics *metrics.Tracker) *Streamer {
handlers: make([]*handlerInfo, 0),
metrics: metrics,
//nolint:gosec // Non-cryptographic randomness is fine for backpressure
random: rand.New(rand.NewSource(time.Now().UnixNano())),
random: rand.New(rand.NewSource(time.Now().UnixNano())),
bgpPeers: make(map[string]bool),
}
}
@@ -608,18 +611,32 @@ func (s *Streamer) stream(ctx context.Context) error {
// BGP keepalive messages - silently process
continue
case "OPEN":
// BGP open messages
// 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 (errors)
// 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

View File

@@ -5,13 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AS{{.ASN.ASN}} - {{.ASN.Handle}} - RouteWatch</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
padding: 0;
background: #f5f5f5;
color: #333;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
@@ -133,8 +196,20 @@
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<div class="container">
<a href="/status" class="nav-link">← Back to Status</a>
<h1>AS{{.ASN.ASN}}{{if .ASN.Handle}} - {{.ASN.Handle}}{{end}}</h1>
{{if .ASN.Description}}
@@ -182,7 +257,7 @@
<tbody>
{{range .IPv4Prefixes}}
<tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td>
<td><a href="{{.Prefix | prefixURL}}" 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>
@@ -211,7 +286,7 @@
<tbody>
{{range .IPv6Prefixes}}
<tr>
<td><a href="/prefix/{{.Prefix | urlEncode}}" class="prefix-link">{{.Prefix}}</a></td>
<td><a href="{{.Prefix | prefixURL}}" 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>
@@ -266,5 +341,6 @@
</div>
{{end}}
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RouteWatch - BGP Route Monitor</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
color: #333;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 30px 20px;
}
/* Stats overview */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card.connected .stat-value {
color: #27ae60;
}
.stat-card.disconnected .stat-value {
color: #e74c3c;
}
/* Search section */
.search-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.search-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.search-card h2 {
margin: 0 0 16px 0;
font-size: 18px;
color: #2c3e50;
}
.search-input-group {
display: flex;
gap: 10px;
}
.search-input-group input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input-group input:focus {
border-color: #3498db;
}
.search-input-group button {
padding: 12px 24px;
background: #3498db;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.search-input-group button:hover {
background: #2980b9;
}
.search-input-group button:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.search-hint {
font-size: 12px;
color: #95a5a6;
margin-top: 8px;
}
/* IP Lookup result */
.ip-result {
margin-top: 16px;
display: none;
}
.ip-result.visible {
display: block;
}
.ip-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ip-result-header h3 {
margin: 0;
font-size: 14px;
color: #2c3e50;
}
.ip-result-header button {
background: none;
border: none;
color: #e74c3c;
cursor: pointer;
font-size: 12px;
}
.ip-result pre {
background: #2c3e50;
color: #ecf0f1;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
margin: 0;
max-height: 400px;
overflow-y: auto;
}
.ip-result .error {
background: #fee;
color: #c00;
padding: 12px;
border-radius: 6px;
font-size: 14px;
}
.ip-result .loading {
color: #7f8c8d;
font-style: italic;
}
/* Footer */
.footer {
margin-top: 40px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
text-align: center;
color: #7f8c8d;
font-size: 14px;
}
.footer a {
color: #3498db;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.footer .separator {
margin: 0 10px;
color: #ddd;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/" class="active">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<div class="stats-grid">
<div class="stat-card" id="status-card">
<div class="stat-value" id="stat-status">-</div>
<div class="stat-label">Status</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-routes">-</div>
<div class="stat-label">Live Routes</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-asns">-</div>
<div class="stat-label">Autonomous Systems</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-prefixes">-</div>
<div class="stat-label">Prefixes</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-peers">-</div>
<div class="stat-label">BGP Peers</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-updates">-</div>
<div class="stat-label">Updates/sec</div>
</div>
</div>
<div class="search-section">
<div class="search-card">
<h2>AS Number Lookup</h2>
<form id="asn-form" class="search-input-group">
<input type="text" id="asn-input" placeholder="e.g., 15169 or AS15169" autocomplete="off">
<button type="submit">Lookup</button>
</form>
<p class="search-hint">Enter an AS number to view its announced prefixes and peers</p>
</div>
<div class="search-card">
<h2>AS Name Search</h2>
<form id="asname-form" class="search-input-group">
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
<button type="submit">Search</button>
</form>
<p class="search-hint">Search for autonomous systems by organization name</p>
<div id="asname-results"></div>
</div>
<div class="search-card">
<h2>IP Address Lookup</h2>
<form id="ip-form" class="search-input-group">
<input type="text" id="ip-input" placeholder="e.g., 8.8.8.8 or 2001:4860:4860::8888" autocomplete="off">
<button type="submit">Lookup</button>
</form>
<p class="search-hint">Get routing information for any IP address</p>
<div id="ip-result" class="ip-result">
<div class="ip-result-header">
<h3>Result</h3>
<button type="button" id="ip-result-close">Clear</button>
</div>
<pre id="ip-result-content"></pre>
</div>
</div>
</div>
</main>
<footer class="footer">
<span><a href="{{appRepoURL}}">{{appName}}</a> by <a href="{{appAuthorURL}}">{{appAuthor}}</a></span>
<span class="separator">|</span>
<span>{{appLicense}}</span>
<span class="separator">|</span>
<span><a href="{{appGitCommitURL}}">{{appGitRevision}}</a></span>
</footer>
<script>
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
}
// Fetch and display stats
function updateStats() {
fetch('/api/v1/stats')
.then(response => response.json())
.then(response => {
if (response.status !== 'ok') return;
const data = response.data;
const statusCard = document.getElementById('status-card');
const statusEl = document.getElementById('stat-status');
statusEl.textContent = data.connected ? 'Connected' : 'Disconnected';
statusCard.className = 'stat-card ' + (data.connected ? 'connected' : 'disconnected');
document.getElementById('stat-routes').textContent = formatNumber(data.live_routes);
document.getElementById('stat-asns').textContent = formatNumber(data.asns);
document.getElementById('stat-prefixes').textContent = formatNumber(data.prefixes);
if (data.stream) {
document.getElementById('stat-peers').textContent = formatNumber(data.stream.bgp_peer_count);
}
const totalUpdates = data.ipv4_updates_per_sec + data.ipv6_updates_per_sec;
document.getElementById('stat-updates').textContent = totalUpdates.toFixed(1);
})
.catch(() => {
document.getElementById('stat-status').textContent = 'Error';
document.getElementById('status-card').className = 'stat-card disconnected';
});
}
// ASN lookup
document.getElementById('asn-form').addEventListener('submit', function(e) {
e.preventDefault();
let asn = document.getElementById('asn-input').value.trim();
// Remove 'AS' prefix if present
asn = asn.replace(/^AS/i, '');
if (asn && /^\d+$/.test(asn)) {
window.location.href = '/as/' + asn;
}
});
// AS name search
document.getElementById('asname-form').addEventListener('submit', function(e) {
e.preventDefault();
const query = document.getElementById('asname-input').value.trim();
if (!query) return;
const resultsDiv = document.getElementById('asname-results');
resultsDiv.innerHTML = '<p class="loading" style="color: #7f8c8d; margin-top: 12px;">Searching...</p>';
// Use a simple client-side search against the asinfo data
// For now, redirect to AS page if it looks like an ASN
if (/^\d+$/.test(query)) {
window.location.href = '/as/' + query;
return;
}
// Show a message that server-side search is coming
resultsDiv.innerHTML = '<p style="color: #7f8c8d; margin-top: 12px; font-size: 13px;">AS name search coming soon. For now, try an AS number.</p>';
});
// IP lookup
document.getElementById('ip-form').addEventListener('submit', function(e) {
e.preventDefault();
const ip = document.getElementById('ip-input').value.trim();
if (!ip) return;
const resultDiv = document.getElementById('ip-result');
const contentEl = document.getElementById('ip-result-content');
resultDiv.classList.add('visible');
contentEl.className = '';
contentEl.textContent = 'Loading...';
contentEl.classList.add('loading');
fetch('/ip/' + encodeURIComponent(ip))
.then(response => response.json())
.then(response => {
contentEl.classList.remove('loading');
if (response.status === 'error') {
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + response.error.msg;
} else {
contentEl.className = '';
contentEl.textContent = JSON.stringify(response.data, null, 2);
}
})
.catch(error => {
contentEl.classList.remove('loading');
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + error.message;
});
});
// Close IP result
document.getElementById('ip-result-close').addEventListener('click', function() {
document.getElementById('ip-result').classList.remove('visible');
document.getElementById('ip-input').value = '';
});
// Initial load and refresh stats every 5 seconds
updateStats();
setInterval(updateStats, 5000);
</script>
</body>
</html>

View File

@@ -5,13 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Prefix}} - RouteWatch</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
padding: 0;
background: #f5f5f5;
color: #333;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
padding: 20px;
}
.container {
width: 90%;
max-width: 1600px;
@@ -180,9 +243,20 @@
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<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>
@@ -255,5 +329,6 @@
</div>
{{end}}
</div>
</main>
</body>
</html>

View File

@@ -5,12 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prefixes with /{{ .MaskLength }} - RouteWatch</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #333;
@@ -78,7 +142,19 @@
</style>
</head>
<body>
<a href="/status" class="back-link">← Back to Status</a>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status">Status</a>
</div>
</nav>
<main class="main-content">
<h1>IPv{{ .IPVersion }} Prefixes with /{{ .MaskLength }}</h1>
<p class="subtitle">Showing {{ .Count }} randomly selected prefixes</p>
@@ -93,7 +169,7 @@
<tbody>
{{ range .Prefixes }}
<tr>
<td><a href="/prefix/{{ .Prefix | urlEncode }}" class="prefix-link">{{ .Prefix }}</a></td>
<td><a href="{{ .Prefix | prefixURL }}" class="prefix-link">{{ .Prefix }}</a></td>
<td class="age">{{ .Age }}</td>
<td>
<a href="/as/{{ .OriginASN }}" class="as-link">
@@ -104,5 +180,6 @@
{{ end }}
</tbody>
</table>
</main>
</body>
</html>

View File

@@ -5,12 +5,76 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RouteWatch Status</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
/* Navbar styles */
.navbar {
background: #2c3e50;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.navbar-brand a {
color: white;
text-decoration: none;
}
.navbar-brand a:hover {
color: #ecf0f1;
}
.navbar-brand .by {
font-weight: normal;
color: #95a5a6;
font-size: 14px;
}
.navbar-brand .author {
color: #3498db;
font-weight: normal;
}
.navbar-brand .author:hover {
text-decoration: underline;
}
.navbar-links {
display: flex;
gap: 20px;
}
.navbar-links a {
color: #ecf0f1;
text-decoration: none;
font-size: 14px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-links a:hover {
background: rgba(255,255,255,0.1);
}
.navbar-links a.active {
background: rgba(255,255,255,0.15);
}
/* Main content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #333;
@@ -72,10 +136,43 @@
border-radius: 4px;
margin-top: 20px;
}
.footer {
margin-top: 40px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
text-align: center;
color: #666;
font-size: 14px;
}
.footer a {
color: #0066cc;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.footer .separator {
margin: 0 10px;
color: #ccc;
}
</style>
</head>
<body>
<h1>RouteWatch Status</h1>
<nav class="navbar">
<div class="navbar-brand">
<a href="/">routewatch</a>
<span class="by">by</span>
<a href="https://sneak.berlin" class="author">@sneak</a>
</div>
<div class="navbar-links">
<a href="/">Home</a>
<a href="/status" class="active">Status</a>
</div>
</nav>
<main class="main-content">
<div id="error" class="error" style="display: none;"></div>
<div class="status-grid">
<div class="status-card">
@@ -112,6 +209,10 @@
<span class="metric-label">Reconnections</span>
<span class="metric-value" id="reconnect_count">-</span>
</div>
<div class="metric">
<span class="metric-label">BGP Peers</span>
<span class="metric-value" id="bgp_peer_count">-</span>
</div>
<div class="metric">
<span class="metric-label">Total Messages</span>
<span class="metric-value" id="total_messages">-</span>
@@ -120,6 +221,18 @@
<span class="metric-label">Messages/sec</span>
<span class="metric-value" id="messages_per_sec">-</span>
</div>
<div class="metric">
<span class="metric-label">Announcements</span>
<span class="metric-value" id="announcements">-</span>
</div>
<div class="metric">
<span class="metric-label">Withdrawals</span>
<span class="metric-value" id="withdrawals">-</span>
</div>
<div class="metric">
<span class="metric-label">Route Churn/sec</span>
<span class="metric-value" id="route_churn_per_sec">-</span>
</div>
<div class="metric">
<span class="metric-label">Total Data</span>
<span class="metric-value" id="total_wire_bytes">-</span>
@@ -129,6 +242,30 @@
<span class="metric-value" id="wire_mbits_per_sec">-</span>
</div>
</div>
<div class="status-card">
<h2>GC Statistics</h2>
<div class="metric">
<span class="metric-label">GC Runs</span>
<span class="metric-value" id="gc_num">-</span>
</div>
<div class="metric">
<span class="metric-label">Total Pause</span>
<span class="metric-value" id="gc_total_pause">-</span>
</div>
<div class="metric">
<span class="metric-label">Last Pause</span>
<span class="metric-value" id="gc_last_pause">-</span>
</div>
<div class="metric">
<span class="metric-label">Heap Alloc</span>
<span class="metric-value" id="gc_heap_alloc">-</span>
</div>
<div class="metric">
<span class="metric-label">Heap Sys</span>
<span class="metric-value" id="gc_heap_sys">-</span>
</div>
</div>
<div class="status-card">
<h2>Database Statistics</h2>
@@ -184,6 +321,14 @@
<span class="metric-label">IPv6 Updates/sec</span>
<span class="metric-value" id="ipv6_updates_per_sec">-</span>
</div>
<div class="metric">
<span class="metric-label">Oldest Route</span>
<span class="metric-value" id="oldest_route">-</span>
</div>
<div class="metric">
<span class="metric-label">Newest Route</span>
<span class="metric-value" id="newest_route">-</span>
</div>
</div>
<div class="status-card">
@@ -263,7 +408,23 @@
return ms.toFixed(2) + ' ms';
}
}
function formatRelativeTime(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return diffSec + 's ago';
if (diffMin < 60) return diffMin + 'm ago';
if (diffHour < 24) return diffHour + 'h ' + (diffMin % 60) + 'm ago';
return diffDay + 'd ' + (diffHour % 24) + 'h ago';
}
function updatePrefixDistribution(elementId, distribution) {
const container = document.getElementById(elementId);
container.innerHTML = '';
@@ -344,10 +505,19 @@
document.getElementById('memory_usage').textContent = '-';
document.getElementById('connection_duration').textContent = '-';
document.getElementById('reconnect_count').textContent = '-';
document.getElementById('bgp_peer_count').textContent = '-';
document.getElementById('total_messages').textContent = '-';
document.getElementById('messages_per_sec').textContent = '-';
document.getElementById('announcements').textContent = '-';
document.getElementById('withdrawals').textContent = '-';
document.getElementById('route_churn_per_sec').textContent = '-';
document.getElementById('total_wire_bytes').textContent = '-';
document.getElementById('wire_mbits_per_sec').textContent = '-';
document.getElementById('gc_num').textContent = '-';
document.getElementById('gc_total_pause').textContent = '-';
document.getElementById('gc_last_pause').textContent = '-';
document.getElementById('gc_heap_alloc').textContent = '-';
document.getElementById('gc_heap_sys').textContent = '-';
document.getElementById('asns').textContent = '-';
document.getElementById('prefixes').textContent = '-';
document.getElementById('ipv4_prefixes').textContent = '-';
@@ -360,6 +530,8 @@
document.getElementById('ipv6_routes').textContent = '-';
document.getElementById('ipv4_updates_per_sec').textContent = '-';
document.getElementById('ipv6_updates_per_sec').textContent = '-';
document.getElementById('oldest_route').textContent = '-';
document.getElementById('newest_route').textContent = '-';
document.getElementById('whois_fresh').textContent = '-';
document.getElementById('whois_stale').textContent = '-';
document.getElementById('whois_never').textContent = '-';
@@ -420,6 +592,25 @@
document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_routes);
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);
document.getElementById('oldest_route').textContent = formatRelativeTime(data.oldest_route);
document.getElementById('newest_route').textContent = formatRelativeTime(data.newest_route);
// Update stream stats
if (data.stream) {
document.getElementById('bgp_peer_count').textContent = formatNumber(data.stream.bgp_peer_count);
document.getElementById('announcements').textContent = formatNumber(data.stream.announcements);
document.getElementById('withdrawals').textContent = formatNumber(data.stream.withdrawals);
document.getElementById('route_churn_per_sec').textContent = data.stream.route_churn_per_sec.toFixed(1);
}
// Update GC stats
if (data.gc) {
document.getElementById('gc_num').textContent = formatNumber(data.gc.num_gc);
document.getElementById('gc_total_pause').textContent = data.gc.total_pause_ms + ' ms';
document.getElementById('gc_last_pause').textContent = data.gc.last_pause_ms.toFixed(3) + ' ms';
document.getElementById('gc_heap_alloc').textContent = formatBytes(data.gc.heap_alloc_bytes);
document.getElementById('gc_heap_sys').textContent = formatBytes(data.gc.heap_sys_bytes);
}
// Update WHOIS stats
if (data.whois_stats) {
@@ -455,5 +646,14 @@
updateStatus();
setInterval(updateStatus, 2000);
</script>
</main>
<footer class="footer">
<span><a href="{{appRepoURL}}">{{appName}}</a> by <a href="{{appAuthorURL}}">{{appAuthor}}</a></span>
<span class="separator">|</span>
<span>{{appLicense}}</span>
<span class="separator">|</span>
<span><a href="{{appGitCommitURL}}">{{appGitRevision}}</a></span>
</footer>
</body>
</html>

View File

@@ -5,10 +5,16 @@ import (
_ "embed"
"html/template"
"net/url"
"strings"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/version"
)
//go:embed index.html
var indexHTML string
//go:embed status.html
var statusHTML string
@@ -23,6 +29,8 @@ var prefixLengthHTML string
// Templates contains all parsed templates
type Templates struct {
// Index is the template for the home page
Index *template.Template
// Status is the template for the main status page
Status *template.Template
// ASDetail is the template for displaying AS (Autonomous System) details
@@ -41,8 +49,9 @@ var (
)
const (
hoursPerDay = 24
daysPerMonth = 30
hoursPerDay = 24
daysPerMonth = 30
cidrPartCount = 2 // A CIDR has two parts: prefix and length
)
// timeSince returns a human-readable duration since the given time
@@ -78,6 +87,20 @@ func timeSince(t time.Time) string {
return t.Format("2006-01-02")
}
// prefixURL generates a URL path for a prefix in CIDR notation.
// Takes a prefix like "192.168.1.0/24" and returns "/prefix/192.168.1.0/24"
// with the prefix part URL-encoded to handle IPv6 colons.
func prefixURL(cidr string) string {
// Split CIDR into prefix and length
parts := strings.SplitN(cidr, "/", cidrPartCount)
if len(parts) != cidrPartCount {
// Fallback if no slash found
return "/prefix/" + url.PathEscape(cidr) + "/0"
}
return "/prefix/" + url.PathEscape(parts[0]) + "/" + parts[1]
}
// initTemplates parses all embedded templates
func initTemplates() {
var err error
@@ -86,12 +109,26 @@ func initTemplates() {
// Create common template functions
funcs := template.FuncMap{
"timeSince": timeSince,
"urlEncode": url.QueryEscape,
"timeSince": timeSince,
"urlEncode": url.QueryEscape,
"prefixURL": prefixURL,
"appName": func() string { return version.Name },
"appAuthor": func() string { return version.Author },
"appAuthorURL": func() string { return version.AuthorURL },
"appLicense": func() string { return version.License },
"appRepoURL": func() string { return version.RepoURL },
"appGitRevision": func() string { return version.GitRevisionShort },
"appGitCommitURL": func() string { return version.CommitURL() },
}
// Parse index template
defaultTemplates.Index, err = template.New("index").Funcs(funcs).Parse(indexHTML)
if err != nil {
panic("failed to parse index template: " + err.Error())
}
// Parse status template
defaultTemplates.Status, err = template.New("status").Parse(statusHTML)
defaultTemplates.Status, err = template.New("status").Funcs(funcs).Parse(statusHTML)
if err != nil {
panic("failed to parse status template: " + err.Error())
}
@@ -122,6 +159,11 @@ func Get() *Templates {
return defaultTemplates
}
// IndexTemplate returns the parsed index template
func IndexTemplate() *template.Template {
return Get().Index
}
// StatusTemplate returns the parsed status template
func StatusTemplate() *template.Template {
return Get().Status

View File

@@ -0,0 +1,34 @@
// Package version provides build version information
package version
// Build-time variables set via ldflags
//
//nolint:gochecknoglobals // These must be variables to allow ldflags injection at build time
var (
// GitRevision is the git commit hash
GitRevision = "unknown"
// GitRevisionShort is the short git commit hash (7 chars)
GitRevisionShort = "unknown"
)
const (
// Name is the program name
Name = "routewatch"
// Author is the program author
Author = "@sneak"
// AuthorURL is the author's website
AuthorURL = "https://sneak.berlin"
// License is the program license
License = "WTFPL"
// RepoURL is the git repository URL
RepoURL = "https://git.eeqj.de/sneak/routewatch"
)
// CommitURL returns the URL to view the current commit
func CommitURL() string {
if GitRevision == "unknown" {
return RepoURL
}
return RepoURL + "/commit/" + GitRevision
}