Remove BGP keepalive logging and add peer tracking

- Created bgp_peers table to track all BGP peers
- Added PeerHandler to update peer last seen times for all message types
- Removed verbose BGP keepalive debug logging
- BGP keepalive messages now silently update peer tracking

Refactor HTML templates to use go:embed

- Created internal/templates package with embedded templates
- Moved status.html from inline const to separate file
- Templates are parsed once on startup
- Server now uses parsed template instead of raw string

Optimize AS data embedding with gzip compression

- Changed asinfo package to embed gzipped data (2.4MB vs 12MB)
- Updated Makefile to gzip AS data during update
- Added decompression during initialization
- Raw JSON file excluded from git
This commit is contained in:
Jeffrey Paul 2025-07-27 21:54:58 +02:00
parent ee80311ba1
commit 585ff63fae
18 changed files with 428 additions and 652241 deletions

5
.gitignore vendored
View File

@ -28,4 +28,7 @@ go.work.sum
*.db-wal *.db-wal
# Temporary files # Temporary files
*.tmp *.tmp
# Raw AS data (we only commit the gzipped version)
pkg/asinfo/asdata.json

View File

@ -25,5 +25,5 @@ run: build
asupdate: asupdate:
@echo "Updating AS info data..." @echo "Updating AS info data..."
@go run cmd/asinfo-gen/main.go > pkg/asinfo/asdata.json.tmp && \ @go run cmd/asinfo-gen/main.go | gzip > pkg/asinfo/asdata.json.gz.tmp && \
mv pkg/asinfo/asdata.json.tmp pkg/asinfo/asdata.json mv pkg/asinfo/asdata.json.gz.tmp pkg/asinfo/asdata.json.gz

View File

@ -24,14 +24,16 @@ type ASInfo struct {
func main() { func main() {
// Configure logger to write to stderr // Configure logger to write to stderr
log.SetOutput(os.Stderr) log.SetOutput(os.Stderr)
// Fetch the CSV data // Fetch the CSV data
log.Println("Fetching AS CSV data...") log.Println("Fetching AS CSV data...")
resp, err := http.Get(asCsvURL) resp, err := http.Get(asCsvURL)
if err != nil { if err != nil {
log.Fatalf("Failed to fetch CSV: %v", err) log.Fatalf("Failed to fetch CSV: %v", err)
} }
defer resp.Body.Close() defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
log.Fatalf("Failed to fetch CSV: HTTP %d", resp.StatusCode) log.Fatalf("Failed to fetch CSV: HTTP %d", resp.StatusCode)
@ -39,13 +41,13 @@ func main() {
// Parse CSV // Parse CSV
reader := csv.NewReader(resp.Body) reader := csv.NewReader(resp.Body)
// Read header // Read header
header, err := reader.Read() header, err := reader.Read()
if err != nil { if err != nil {
log.Fatalf("Failed to read CSV header: %v", err) log.Fatalf("Failed to read CSV header: %v", err)
} }
if len(header) != 3 || header[0] != "asn" || header[1] != "handle" || header[2] != "description" { if len(header) != 3 || header[0] != "asn" || header[1] != "handle" || header[2] != "description" {
log.Fatalf("Unexpected CSV header: %v", header) log.Fatalf("Unexpected CSV header: %v", header)
} }
@ -59,17 +61,21 @@ func main() {
} }
if err != nil { if err != nil {
log.Printf("Error reading CSV record: %v", err) log.Printf("Error reading CSV record: %v", err)
continue continue
} }
if len(record) != 3 { const expectedFields = 3
if len(record) != expectedFields {
log.Printf("Skipping invalid record: %v", record) log.Printf("Skipping invalid record: %v", record)
continue continue
} }
asn, err := strconv.Atoi(record[0]) asn, err := strconv.Atoi(record[0])
if err != nil { if err != nil {
log.Printf("Invalid ASN %q: %v", record[0], err) log.Printf("Invalid ASN %q: %v", record[0], err)
continue continue
} }
@ -88,4 +94,4 @@ func main() {
if err := encoder.Encode(asInfos); err != nil { if err := encoder.Encode(asInfos); err != nil {
log.Fatalf("Failed to encode JSON: %v", err) log.Fatalf("Failed to encode JSON: %v", err)
} }
} }

View File

@ -57,6 +57,16 @@ CREATE TABLE IF NOT EXISTS asn_peerings (
UNIQUE(from_asn_id, to_asn_id) UNIQUE(from_asn_id, to_asn_id)
); );
-- BGP peers that send us messages
CREATE TABLE IF NOT EXISTS bgp_peers (
id TEXT PRIMARY KEY,
peer_ip TEXT UNIQUE NOT NULL,
peer_asn INTEGER NOT NULL,
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
last_message_type TEXT
);
-- Live routing table: current state of announced routes -- Live routing table: current state of announced routes
CREATE TABLE IF NOT EXISTS live_routes ( CREATE TABLE IF NOT EXISTS live_routes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -91,6 +101,10 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_origin
CREATE INDEX IF NOT EXISTS idx_live_routes_prefix CREATE INDEX IF NOT EXISTS idx_live_routes_prefix
ON live_routes(prefix_id) ON live_routes(prefix_id)
WHERE withdrawn_at IS NULL; WHERE withdrawn_at IS NULL;
-- Indexes for bgp_peers table
CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn);
CREATE INDEX IF NOT EXISTS idx_bgp_peers_last_seen ON bgp_peers(last_seen);
` `
) )
@ -468,6 +482,52 @@ func (d *Database) GetActiveLiveRoutes() ([]LiveRoute, error) {
return routes, rows.Err() return routes, rows.Err()
} }
// UpdatePeer updates or creates a BGP peer record
func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
tx, err := d.db.Begin()
if err != nil {
return err
}
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
d.logger.Error("Failed to rollback transaction", "error", err)
}
}()
var exists bool
err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM bgp_peers WHERE peer_ip = ?)", peerIP).Scan(&exists)
if err != nil {
return err
}
if exists {
_, err = tx.Exec(
"UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, last_message_type = ? WHERE peer_ip = ?",
peerASN, timestamp, messageType, peerIP,
)
} else {
_, err = tx.Exec(
"INSERT INTO bgp_peers (id, peer_ip, peer_asn, first_seen, last_seen, last_message_type) VALUES (?, ?, ?, ?, ?, ?)",
generateUUID().String(), peerIP, peerASN, timestamp, timestamp, messageType,
)
}
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
d.logger.Error("Failed to commit transaction for peer update",
"peer_ip", peerIP,
"peer_asn", peerASN,
"error", err,
)
return err
}
return nil
}
// GetStats returns database statistics // GetStats returns database statistics
func (d *Database) GetStats() (Stats, error) { func (d *Database) GetStats() (Stats, error) {
var stats Stats var stats Stats

View File

@ -38,6 +38,9 @@ type Store interface {
// Statistics // Statistics
GetStats() (Stats, error) GetStats() (Stats, error)
// Peer operations
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
// Lifecycle // Lifecycle
Close() error Close() error
} }

View File

@ -76,6 +76,10 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
dbHandler := NewDatabaseHandler(rw.db, rw.logger) dbHandler := NewDatabaseHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(dbHandler) rw.streamer.RegisterHandler(dbHandler)
// Register peer tracking handler to track all peers
peerHandler := NewPeerHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(peerHandler)
// Start streaming // Start streaming
if err := rw.streamer.Start(); err != nil { if err := rw.streamer.Start(); err != nil {
return err return err

View File

@ -158,6 +158,12 @@ func (m *mockStore) GetActiveLiveRoutes() ([]database.LiveRoute, error) {
return []database.LiveRoute{}, nil return []database.LiveRoute{}, nil
} }
// UpdatePeer mock implementation
func (m *mockStore) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
// Simple mock - just return nil
return nil
}
// Close mock implementation // Close mock implementation
func (m *mockStore) Close() error { func (m *mockStore) Close() error {
return nil return nil

View File

@ -0,0 +1,49 @@
package routewatch
import (
"log/slog"
"strconv"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
// PeerHandler tracks BGP peers from all message types
type PeerHandler struct {
db database.Store
logger *slog.Logger
}
// NewPeerHandler creates a new peer tracking handler
func NewPeerHandler(db database.Store, logger *slog.Logger) *PeerHandler {
return &PeerHandler{
db: db,
logger: logger,
}
}
// WantsMessage returns true for all message types since we track peers from all messages
func (h *PeerHandler) WantsMessage(_ string) bool {
return true
}
// HandleMessage processes a message to track peer information
func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) {
// Parse peer ASN from string
peerASN := 0
if msg.PeerASN != "" {
if asn, err := strconv.Atoi(msg.PeerASN); err == nil {
peerASN = asn
}
}
// Update peer in database
if err := h.db.UpdatePeer(msg.Peer, peerASN, msg.Type, msg.ParsedTimestamp); err != nil {
h.logger.Error("Failed to update peer",
"peer", msg.Peer,
"peer_asn", peerASN,
"message_type", msg.Type,
"error", err,
)
}
}

View File

@ -4,7 +4,6 @@ package server
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@ -12,6 +11,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/streamer" "git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
) )
@ -226,191 +226,11 @@ func (s *Server) handleStats() http.HandlerFunc {
func (s *Server) handleStatusHTML() http.HandlerFunc { func (s *Server) handleStatusHTML() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) { return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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) tmpl := templates.StatusTemplate()
if err := tmpl.Execute(w, nil); err != nil {
s.logger.Error("Failed to render template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
} }
} }
} }
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>
`

View File

@ -255,11 +255,7 @@ func (s *Streamer) stream(ctx context.Context) error {
case "RIS_PEER_STATE": case "RIS_PEER_STATE":
// RIS peer state messages - silently ignore // RIS peer state messages - silently ignore
case "KEEPALIVE": case "KEEPALIVE":
// BGP keepalive messages - just log at debug level // BGP keepalive messages - silently process
s.logger.Debug("BGP keepalive",
"peer", msg.Peer,
"peer_asn", msg.PeerASN,
)
case "OPEN": case "OPEN":
// BGP open messages // BGP open messages
s.logger.Info("BGP session opened", s.logger.Info("BGP session opened",

View File

@ -0,0 +1,181 @@
<!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>

View File

@ -0,0 +1,48 @@
// Package templates provides embedded HTML templates for the RouteWatch application
package templates
import (
_ "embed"
"html/template"
"sync"
)
//go:embed status.html
var statusHTML string
// Templates contains all parsed templates
type Templates struct {
Status *template.Template
}
var (
//nolint:gochecknoglobals // Singleton pattern for templates
defaultTemplates *Templates
//nolint:gochecknoglobals // Singleton pattern for templates
once sync.Once
)
// initTemplates parses all embedded templates
func initTemplates() {
var err error
defaultTemplates = &Templates{}
// Parse status template
defaultTemplates.Status, err = template.New("status").Parse(statusHTML)
if err != nil {
panic("failed to parse status template: " + err.Error())
}
}
// Get returns the singleton Templates instance
func Get() *Templates {
once.Do(initTemplates)
return defaultTemplates
}
// StatusTemplate returns the parsed status template
func StatusTemplate() *template.Template {
return Get().Status
}

File diff suppressed because it is too large Load Diff

BIN
pkg/asinfo/asdata.json.gz Normal file

Binary file not shown.

View File

@ -2,13 +2,16 @@
package asinfo package asinfo
import ( import (
"bytes"
"compress/gzip"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"io"
"sync" "sync"
) )
//go:embed asdata.json //go:embed asdata.json.gz
var asDataJSON []byte var asDataGZ []byte
// Info represents information about an Autonomous System // Info represents information about an Autonomous System
type Info struct { type Info struct {
@ -24,8 +27,10 @@ type Registry struct {
} }
var ( var (
//nolint:gochecknoglobals // Singleton pattern for embedded data
defaultRegistry *Registry defaultRegistry *Registry
once sync.Once //nolint:gochecknoglobals // Singleton pattern for embedded data
once sync.Once
) )
// initRegistry initializes the default registry with embedded data // initRegistry initializes the default registry with embedded data
@ -33,12 +38,26 @@ func initRegistry() {
defaultRegistry = &Registry{ defaultRegistry = &Registry{
byASN: make(map[int]*Info), byASN: make(map[int]*Info),
} }
// Decompress the gzipped data
gzReader, err := gzip.NewReader(bytes.NewReader(asDataGZ))
if err != nil {
panic("failed to create gzip reader: " + err.Error())
}
defer func() {
_ = gzReader.Close()
}()
jsonData, err := io.ReadAll(gzReader)
if err != nil {
panic("failed to decompress AS data: " + err.Error())
}
var asInfos []Info var asInfos []Info
if err := json.Unmarshal(asDataJSON, &asInfos); err != nil { if err := json.Unmarshal(jsonData, &asInfos); err != nil {
panic("failed to unmarshal embedded AS data: " + err.Error()) panic("failed to unmarshal embedded AS data: " + err.Error())
} }
for i := range asInfos { for i := range asInfos {
info := &asInfos[i] info := &asInfos[i]
defaultRegistry.byASN[info.ASN] = info defaultRegistry.byASN[info.ASN] = info
@ -48,18 +67,19 @@ func initRegistry() {
// Get returns AS information for the given ASN // Get returns AS information for the given ASN
func Get(asn int) (*Info, bool) { func Get(asn int) (*Info, bool) {
once.Do(initRegistry) once.Do(initRegistry)
defaultRegistry.mu.RLock() defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock() defer defaultRegistry.mu.RUnlock()
info, ok := defaultRegistry.byASN[asn] info, ok := defaultRegistry.byASN[asn]
if !ok { if !ok {
return nil, false return nil, false
} }
// Return a copy to prevent mutation // Return a copy to prevent mutation
copy := *info infoCopy := *info
return &copy, true
return &infoCopy, true
} }
// GetDescription returns just the description for an ASN // GetDescription returns just the description for an ASN
@ -68,6 +88,7 @@ func GetDescription(asn int) string {
if !ok { if !ok {
return "" return ""
} }
return info.Description return info.Description
} }
@ -77,16 +98,17 @@ func GetHandle(asn int) string {
if !ok { if !ok {
return "" return ""
} }
return info.Handle return info.Handle
} }
// Total returns the total number of AS entries in the registry // Total returns the total number of AS entries in the registry
func Total() int { func Total() int {
once.Do(initRegistry) once.Do(initRegistry)
defaultRegistry.mu.RLock() defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock() defer defaultRegistry.mu.RUnlock()
return len(defaultRegistry.byASN) return len(defaultRegistry.byASN)
} }
@ -94,15 +116,15 @@ func Total() int {
// Note: This creates a copy of all data, use sparingly // Note: This creates a copy of all data, use sparingly
func All() []Info { func All() []Info {
once.Do(initRegistry) once.Do(initRegistry)
defaultRegistry.mu.RLock() defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock() defer defaultRegistry.mu.RUnlock()
result := make([]Info, 0, len(defaultRegistry.byASN)) result := make([]Info, 0, len(defaultRegistry.byASN))
for _, info := range defaultRegistry.byASN { for _, info := range defaultRegistry.byASN {
result = append(result, *info) result = append(result, *info)
} }
return result return result
} }
@ -110,17 +132,17 @@ func All() []Info {
// This is a simple case-sensitive substring search // This is a simple case-sensitive substring search
func Search(query string) []Info { func Search(query string) []Info {
once.Do(initRegistry) once.Do(initRegistry)
defaultRegistry.mu.RLock() defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock() defer defaultRegistry.mu.RUnlock()
var results []Info var results []Info
for _, info := range defaultRegistry.byASN { for _, info := range defaultRegistry.byASN {
if contains(info.Handle, query) || contains(info.Description, query) { if contains(info.Handle, query) || contains(info.Description, query) {
results = append(results, *info) results = append(results, *info)
} }
} }
return results return results
} }
@ -135,5 +157,6 @@ func containsImpl(s, substr string) bool {
return true return true
} }
} }
return false return false
} }

View File

@ -43,7 +43,7 @@ func TestGet(t *testing.T) {
t.Errorf("Get(%d) ok = %v, want %v", tt.asn, ok, tt.wantOK) t.Errorf("Get(%d) ok = %v, want %v", tt.asn, ok, tt.wantOK)
return return
} }
if ok && info.Description != tt.wantDesc { if ok && info.Description != tt.wantDesc {
t.Errorf("Get(%d) description = %q, want %q", tt.asn, info.Description, tt.wantDesc) t.Errorf("Get(%d) description = %q, want %q", tt.asn, info.Description, tt.wantDesc)
} }
@ -93,7 +93,7 @@ func TestTotal(t *testing.T) {
if total < 100000 { if total < 100000 {
t.Errorf("Total() = %d, expected > 100000", total) t.Errorf("Total() = %d, expected > 100000", total)
} }
// Verify it's consistent // Verify it's consistent
if total2 := Total(); total2 != total { if total2 := Total(); total2 != total {
t.Errorf("Total() returned different values: %d vs %d", total, total2) t.Errorf("Total() returned different values: %d vs %d", total, total2)
@ -134,7 +134,7 @@ func TestSearch(t *testing.T) {
if len(results) < tt.wantMin { if len(results) < tt.wantMin {
t.Errorf("Search(%q) returned %d results, want at least %d", tt.query, len(results), tt.wantMin) t.Errorf("Search(%q) returned %d results, want at least %d", tt.query, len(results), tt.wantMin)
} }
// Verify all results contain the query // Verify all results contain the query
if tt.query != "" { if tt.query != "" {
for _, r := range results { for _, r := range results {
@ -157,7 +157,7 @@ func TestDataIntegrity(t *testing.T) {
} }
seen[info.ASN] = true seen[info.ASN] = true
} }
// Verify all entries have required fields // Verify all entries have required fields
for _, info := range all { for _, info := range all {
if info.Handle == "" && info.ASN != 0 { if info.Handle == "" && info.ASN != 0 {
@ -172,7 +172,7 @@ func TestDataIntegrity(t *testing.T) {
func BenchmarkGet(b *testing.B) { func BenchmarkGet(b *testing.B) {
// Common ASNs to lookup // Common ASNs to lookup
asns := []int{1, 15169, 13335, 32934, 8075, 16509} asns := []int{1, 15169, 13335, 32934, 8075, 16509}
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
Get(asns[i%len(asns)]) Get(asns[i%len(asns)])
@ -181,9 +181,9 @@ func BenchmarkGet(b *testing.B) {
func BenchmarkSearch(b *testing.B) { func BenchmarkSearch(b *testing.B) {
queries := []string{"Google", "Amazon", "Microsoft", "University"} queries := []string{"Google", "Amazon", "Microsoft", "University"}
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
Search(queries[i%len(queries)]) Search(queries[i%len(queries)])
} }
} }

View File

@ -25,4 +25,4 @@ Basic usage:
The data is loaded lazily on first access and cached in memory for the lifetime The data is loaded lazily on first access and cached in memory for the lifetime
of the program. All getter methods are safe for concurrent use. of the program. All getter methods are safe for concurrent use.
*/ */
package asinfo package asinfo

View File

@ -26,4 +26,4 @@ func ExampleSearch() {
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description) fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
} }
// Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology // Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
} }