Initial commit: RouteWatch BGP stream monitor
- Connects to RIPE RIS Live stream to receive real-time BGP updates - Stores BGP data in SQLite database: - ASNs with first/last seen timestamps - Prefixes with IPv4/IPv6 classification - BGP announcements and withdrawals - AS-to-AS peering relationships from AS paths - Live routing table tracking active routes - HTTP server with statistics endpoints - Metrics tracking with go-metrics - Custom JSON unmarshaling to handle nested AS sets in paths - Dependency injection with uber/fx - Pure Go implementation (no CGO) - Includes streamdumper utility for debugging raw messages
This commit is contained in:
416
internal/server/server.go
Normal file
416
internal/server/server.go
Normal file
@@ -0,0 +1,416 @@
|
||||
// Package server provides HTTP endpoints for status monitoring and statistics
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// Server provides HTTP endpoints for status monitoring
|
||||
type Server struct {
|
||||
router *chi.Mux
|
||||
db database.Store
|
||||
streamer *streamer.Streamer
|
||||
logger *slog.Logger
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
// New creates a new HTTP server
|
||||
func New(db database.Store, streamer *streamer.Streamer, logger *slog.Logger) *Server {
|
||||
s := &Server{
|
||||
db: db,
|
||||
streamer: streamer,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// setupRoutes configures the HTTP routes
|
||||
func (s *Server) setupRoutes() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
const requestTimeout = 60 * time.Second
|
||||
r.Use(middleware.Timeout(requestTimeout))
|
||||
|
||||
// Routes
|
||||
r.Get("/", s.handleRoot())
|
||||
r.Get("/status", s.handleStatusHTML())
|
||||
r.Get("/status.json", s.handleStatusJSON())
|
||||
|
||||
// API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/stats", s.handleStats())
|
||||
})
|
||||
|
||||
s.router = r
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start() error {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
const readHeaderTimeout = 10 * time.Second
|
||||
s.srv = &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: s.router,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
}
|
||||
|
||||
s.logger.Info("Starting HTTP server", "port", port)
|
||||
|
||||
go func() {
|
||||
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
s.logger.Error("HTTP server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the HTTP server
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if s.srv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Stopping HTTP server")
|
||||
|
||||
return s.srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// handleRoot returns a handler that redirects to /status
|
||||
func (s *Server) handleRoot() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/status", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatusJSON returns a handler that serves JSON statistics
|
||||
func (s *Server) handleStatusJSON() http.HandlerFunc {
|
||||
// Stats represents the statistics response
|
||||
type Stats struct {
|
||||
Uptime string `json:"uptime"`
|
||||
TotalMessages uint64 `json:"total_messages"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
MessagesPerSec float64 `json:"messages_per_sec"`
|
||||
MbitsPerSec float64 `json:"mbits_per_sec"`
|
||||
Connected bool `json:"connected"`
|
||||
ASNs int `json:"asns"`
|
||||
Prefixes int `json:"prefixes"`
|
||||
IPv4Prefixes int `json:"ipv4_prefixes"`
|
||||
IPv6Prefixes int `json:"ipv6_prefixes"`
|
||||
Peerings int `json:"peerings"`
|
||||
LiveRoutes int `json:"live_routes"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
metrics := s.streamer.GetMetrics()
|
||||
|
||||
// Get database stats
|
||||
dbStats, err := s.db.GetStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
|
||||
if metrics.ConnectedSince.IsZero() {
|
||||
uptime = "0s"
|
||||
}
|
||||
|
||||
const bitsPerMegabit = 1000000.0
|
||||
|
||||
stats := Stats{
|
||||
Uptime: uptime,
|
||||
TotalMessages: metrics.TotalMessages,
|
||||
TotalBytes: metrics.TotalBytes,
|
||||
MessagesPerSec: metrics.MessagesPerSec,
|
||||
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
|
||||
Connected: metrics.Connected,
|
||||
ASNs: dbStats.ASNs,
|
||||
Prefixes: dbStats.Prefixes,
|
||||
IPv4Prefixes: dbStats.IPv4Prefixes,
|
||||
IPv6Prefixes: dbStats.IPv6Prefixes,
|
||||
Peerings: dbStats.Peerings,
|
||||
LiveRoutes: dbStats.LiveRoutes,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||
s.logger.Error("Failed to encode stats", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleStats returns a handler that serves API v1 statistics
|
||||
func (s *Server) handleStats() http.HandlerFunc {
|
||||
// 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"`
|
||||
LiveRoutes int `json:"live_routes"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
metrics := s.streamer.GetMetrics()
|
||||
|
||||
// Get database stats
|
||||
dbStats, err := s.db.GetStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
|
||||
if metrics.ConnectedSince.IsZero() {
|
||||
uptime = "0s"
|
||||
}
|
||||
|
||||
const bitsPerMegabit = 1000000.0
|
||||
|
||||
stats := StatsResponse{
|
||||
Uptime: uptime,
|
||||
TotalMessages: metrics.TotalMessages,
|
||||
TotalBytes: metrics.TotalBytes,
|
||||
MessagesPerSec: metrics.MessagesPerSec,
|
||||
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
|
||||
Connected: metrics.Connected,
|
||||
ASNs: dbStats.ASNs,
|
||||
Prefixes: dbStats.Prefixes,
|
||||
IPv4Prefixes: dbStats.IPv4Prefixes,
|
||||
IPv6Prefixes: dbStats.IPv6Prefixes,
|
||||
Peerings: dbStats.Peerings,
|
||||
LiveRoutes: dbStats.LiveRoutes,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||
s.logger.Error("Failed to encode stats", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatusHTML returns a handler that serves the HTML status page
|
||||
func (s *Server) handleStatusHTML() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if _, err := fmt.Fprint(w, statusHTML); err != nil {
|
||||
s.logger.Error("Failed to write HTML", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statusHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RouteWatch Status</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.status-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.status-card h2 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.metric:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.metric-label {
|
||||
color: #666;
|
||||
}
|
||||
.metric-value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.connected {
|
||||
color: #22c55e;
|
||||
}
|
||||
.disconnected {
|
||||
color: #ef4444;
|
||||
}
|
||||
.error {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RouteWatch Status</h1>
|
||||
<div id="error" class="error" style="display: none;"></div>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<h2>Connection Status</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Status</span>
|
||||
<span class="metric-value" id="connected">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Uptime</span>
|
||||
<span class="metric-value" id="uptime">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-card">
|
||||
<h2>Stream Statistics</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Total Messages</span>
|
||||
<span class="metric-value" id="total_messages">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Messages/sec</span>
|
||||
<span class="metric-value" id="messages_per_sec">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Total Data</span>
|
||||
<span class="metric-value" id="total_bytes">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Throughput</span>
|
||||
<span class="metric-value" id="mbits_per_sec">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-card">
|
||||
<h2>Database Statistics</h2>
|
||||
<div class="metric">
|
||||
<span class="metric-label">ASNs</span>
|
||||
<span class="metric-value" id="asns">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Total Prefixes</span>
|
||||
<span class="metric-value" id="prefixes">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">IPv4 Prefixes</span>
|
||||
<span class="metric-value" id="ipv4_prefixes">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">IPv6 Prefixes</span>
|
||||
<span class="metric-value" id="ipv6_prefixes">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Peerings</span>
|
||||
<span class="metric-value" id="peerings">-</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Live Routes</span>
|
||||
<span class="metric-value" id="live_routes">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
fetch('/api/v1/stats')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Connection status
|
||||
const connectedEl = document.getElementById('connected');
|
||||
connectedEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
||||
connectedEl.className = 'metric-value ' + (data.connected ? 'connected' : 'disconnected');
|
||||
|
||||
// Update all metrics
|
||||
document.getElementById('uptime').textContent = data.uptime;
|
||||
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
|
||||
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
|
||||
document.getElementById('total_bytes').textContent = formatBytes(data.total_bytes);
|
||||
document.getElementById('mbits_per_sec').textContent = data.mbits_per_sec.toFixed(2) + ' Mbps';
|
||||
document.getElementById('asns').textContent = formatNumber(data.asns);
|
||||
document.getElementById('prefixes').textContent = formatNumber(data.prefixes);
|
||||
document.getElementById('ipv4_prefixes').textContent = formatNumber(data.ipv4_prefixes);
|
||||
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
||||
document.getElementById('peerings').textContent = formatNumber(data.peerings);
|
||||
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
||||
|
||||
// Clear any errors
|
||||
document.getElementById('error').style.display = 'none';
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('error').textContent = 'Error fetching status: ' + error;
|
||||
document.getElementById('error').style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
// Update immediately and then every 500ms
|
||||
updateStatus();
|
||||
setInterval(updateStatus, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
Reference in New Issue
Block a user