diff --git a/internal/database/database.go b/internal/database/database.go index 0b3a627..91c93bd 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -44,6 +44,8 @@ var ( ErrInvalidIP = errors.New("invalid IP address") // ErrNoRoute is returned when no route is found for an IP ErrNoRoute = errors.New("no route found") + // ErrNoStaleASN is returned when no ASN needs WHOIS refresh + ErrNoStaleASN = errors.New("no stale ASN found") ) // Database manages the SQLite database connection and operations. @@ -1630,3 +1632,288 @@ func (d *Database) GetRandomPrefixesByLengthContext( return routes, nil } + +// GetNextStaleASN returns an ASN that needs WHOIS data refresh. +// Priority: ASNs with no whois_updated_at, then oldest whois_updated_at. +func (d *Database) GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error) { + cutoff := time.Now().Add(-staleThreshold) + + query := ` + SELECT asn FROM asns + WHERE whois_updated_at IS NULL + OR whois_updated_at < ? + ORDER BY + CASE WHEN whois_updated_at IS NULL THEN 0 ELSE 1 END, + whois_updated_at ASC + LIMIT 1 + ` + + var asn int + err := d.db.QueryRowContext(ctx, query, cutoff).Scan(&asn) + if err != nil { + if err == sql.ErrNoRows { + return 0, ErrNoStaleASN + } + + return 0, fmt.Errorf("failed to get stale ASN: %w", err) + } + + return asn, nil +} + +// UpdateASNWHOIS updates an ASN record with WHOIS data. +func (d *Database) UpdateASNWHOIS(ctx context.Context, update *ASNWHOISUpdate) error { + d.lock("UpdateASNWHOIS") + defer d.unlock() + + query := ` + UPDATE asns SET + as_name = ?, + org_name = ?, + org_id = ?, + address = ?, + country_code = ?, + abuse_email = ?, + abuse_phone = ?, + tech_email = ?, + tech_phone = ?, + rir = ?, + rir_registration_date = ?, + rir_last_modified = ?, + whois_raw = ?, + whois_updated_at = ? + WHERE asn = ? + ` + + _, err := d.db.ExecContext(ctx, query, + update.ASName, + update.OrgName, + update.OrgID, + update.Address, + update.CountryCode, + update.AbuseEmail, + update.AbusePhone, + update.TechEmail, + update.TechPhone, + update.RIR, + update.RIRRegDate, + update.RIRLastMod, + update.WHOISRaw, + time.Now(), + update.ASN, + ) + if err != nil { + return fmt.Errorf("failed to update ASN WHOIS: %w", err) + } + + return nil +} + +// GetIPInfo returns comprehensive IP information for the /ip endpoint. +func (d *Database) GetIPInfo(ip string) (*IPInfo, error) { + return d.GetIPInfoContext(context.Background(), ip) +} + +// GetIPInfoContext returns comprehensive IP information with context support. +func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) { + // Parse the IP to validate it + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip) + } + + // Determine IP version + ipv4 := parsedIP.To4() + if ipv4 != nil { + return d.getIPv4Info(ctx, ip, ipv4) + } + + return d.getIPv6Info(ctx, ip, parsedIP) +} + +// getIPv4Info returns comprehensive IP information for an IPv4 address. +func (d *Database) getIPv4Info(ctx context.Context, ip string, ipv4 net.IP) (*IPInfo, error) { + info := &IPInfo{ + IP: ip, + IPVersion: ipVersionV4, + } + + ipUint := ipToUint32(ipv4) + + // Get route info with peer count and prefix first_seen + query := ` + SELECT + lr.prefix, + lr.mask_length, + lr.origin_asn, + lr.last_updated, + (SELECT COUNT(DISTINCT peer_ip) FROM live_routes_v4 WHERE prefix = lr.prefix) as num_peers, + p.first_seen, + a.handle, + a.description, + a.as_name, + a.org_name, + a.org_id, + a.address, + a.country_code, + a.abuse_email, + a.rir, + a.whois_updated_at + FROM live_routes_v4 lr + LEFT JOIN prefixes_v4 p ON p.prefix = lr.prefix + LEFT JOIN asns a ON a.asn = lr.origin_asn + WHERE lr.ip_start <= ? AND lr.ip_end >= ? + ORDER BY lr.mask_length DESC + LIMIT 1 + ` + + var handle, description, asName, orgName, orgID, address, countryCode, abuseEmail, rir sql.NullString + var prefixFirstSeen sql.NullTime + var whoisUpdatedAt sql.NullTime + + err := d.db.QueryRowContext(ctx, query, ipUint, ipUint).Scan( + &info.Netblock, + &info.MaskLength, + &info.ASN, + &info.LastSeen, + &info.NumPeers, + &prefixFirstSeen, + &handle, + &description, + &asName, + &orgName, + &orgID, + &address, + &countryCode, + &abuseEmail, + &rir, + &whoisUpdatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) + } + + return nil, fmt.Errorf("failed to query routes: %w", err) + } + + info.Handle = handle.String + info.Description = description.String + info.ASName = asName.String + info.OrgName = orgName.String + info.OrgID = orgID.String + info.Address = address.String + info.CountryCode = countryCode.String + info.AbuseEmail = abuseEmail.String + info.RIR = rir.String + + if prefixFirstSeen.Valid { + info.FirstSeen = prefixFirstSeen.Time + } + + // Check if WHOIS data needs refresh (never fetched or older than 30 days) + const staleThreshold = 30 * 24 * time.Hour + info.NeedsWHOISRefresh = !whoisUpdatedAt.Valid || time.Since(whoisUpdatedAt.Time) > staleThreshold + + return info, nil +} + +// getIPv6Info returns comprehensive IP information for an IPv6 address. +func (d *Database) getIPv6Info(ctx context.Context, ip string, parsedIP net.IP) (*IPInfo, error) { + info := &IPInfo{ + IP: ip, + IPVersion: ipVersionV6, + } + + // For IPv6, scan all routes and find best match + query := ` + SELECT DISTINCT + lr.prefix, + lr.mask_length, + lr.origin_asn, + lr.last_updated, + a.handle, + a.description, + a.as_name, + a.org_name, + a.org_id, + a.address, + a.country_code, + a.abuse_email, + a.rir, + a.whois_updated_at + FROM live_routes_v6 lr + LEFT JOIN asns a ON a.asn = lr.origin_asn + ORDER BY lr.mask_length DESC + ` + + rows, err := d.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query routes: %w", err) + } + defer func() { _ = rows.Close() }() + + bestMaskLength := -1 + + for rows.Next() { + var prefix string + var maskLength, originASN int + var lastUpdated time.Time + var handle, description, asName, orgName, orgID, address, countryCode, abuseEmail, rir sql.NullString + var whoisUpdatedAt sql.NullTime + + if err := rows.Scan( + &prefix, &maskLength, &originASN, &lastUpdated, + &handle, &description, &asName, &orgName, &orgID, + &address, &countryCode, &abuseEmail, &rir, &whoisUpdatedAt, + ); err != nil { + continue + } + + _, ipNet, err := net.ParseCIDR(prefix) + if err != nil { + continue + } + + if ipNet.Contains(parsedIP) && maskLength > bestMaskLength { + info.Netblock = prefix + info.MaskLength = maskLength + info.ASN = originASN + info.LastSeen = lastUpdated + info.Handle = handle.String + info.Description = description.String + info.ASName = asName.String + info.OrgName = orgName.String + info.OrgID = orgID.String + info.Address = address.String + info.CountryCode = countryCode.String + info.AbuseEmail = abuseEmail.String + info.RIR = rir.String + bestMaskLength = maskLength + + if !whoisUpdatedAt.Valid { + info.NeedsWHOISRefresh = true + } else { + const staleThreshold = 30 * 24 * time.Hour + info.NeedsWHOISRefresh = time.Since(whoisUpdatedAt.Time) > staleThreshold + } + } + } + + if bestMaskLength == -1 { + return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) + } + + // Get peer count and first_seen for IPv6 + countQuery := `SELECT COUNT(DISTINCT peer_ip) FROM live_routes_v6 WHERE prefix = ?` + _ = d.db.QueryRowContext(ctx, countQuery, info.Netblock).Scan(&info.NumPeers) + + firstSeenQuery := `SELECT first_seen FROM prefixes_v6 WHERE prefix = ?` + var prefixFirstSeen sql.NullTime + err = d.db.QueryRowContext(ctx, firstSeenQuery, info.Netblock).Scan(&prefixFirstSeen) + if err == nil && prefixFirstSeen.Valid { + info.FirstSeen = prefixFirstSeen.Time + } + + return info, nil +} diff --git a/internal/database/interface.go b/internal/database/interface.go index a083fe4..8874b6f 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -61,6 +61,12 @@ type Store interface { // IP lookup operations GetASInfoForIP(ip string) (*ASInfo, error) GetASInfoForIPContext(ctx context.Context, ip string) (*ASInfo, error) + GetIPInfo(ip string) (*IPInfo, error) + GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) + + // ASN WHOIS operations + GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error) + UpdateASNWHOIS(ctx context.Context, update *ASNWHOISUpdate) error // AS and prefix detail operations GetASDetails(asn int) (*ASN, []LiveRoute, error) diff --git a/internal/database/models.go b/internal/database/models.go index cd7ef4b..dca706d 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -8,13 +8,29 @@ import ( ) // ASN represents an Autonomous System Number with its metadata including -// handle, description, and first/last seen timestamps. +// handle, description, WHOIS data, and first/last seen timestamps. type ASN struct { - ASN int `json:"asn"` - Handle string `json:"handle"` - Description string `json:"description"` - FirstSeen time.Time `json:"first_seen"` - LastSeen time.Time `json:"last_seen"` + ASN int `json:"asn"` + Handle string `json:"handle"` + Description string `json:"description"` + // WHOIS parsed fields + ASName string `json:"as_name,omitempty"` + OrgName string `json:"org_name,omitempty"` + OrgID string `json:"org_id,omitempty"` + Address string `json:"address,omitempty"` + CountryCode string `json:"country_code,omitempty"` + AbuseEmail string `json:"abuse_email,omitempty"` + AbusePhone string `json:"abuse_phone,omitempty"` + TechEmail string `json:"tech_email,omitempty"` + TechPhone string `json:"tech_phone,omitempty"` + RIR string `json:"rir,omitempty"` // ARIN, RIPE, APNIC, LACNIC, AFRINIC + RIRRegDate *time.Time `json:"rir_registration_date,omitempty"` + RIRLastMod *time.Time `json:"rir_last_modified,omitempty"` + WHOISRaw string `json:"whois_raw,omitempty"` + // Timestamps + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"` } // Prefix represents an IP prefix (CIDR block) with its IP version (4 or 6) @@ -72,7 +88,7 @@ type PrefixDistribution struct { Count int `json:"count"` } -// ASInfo represents AS information for an IP lookup +// ASInfo represents AS information for an IP lookup (legacy format) type ASInfo struct { ASN int `json:"asn"` Handle string `json:"handle"` @@ -82,6 +98,31 @@ type ASInfo struct { Age string `json:"age"` } +// IPInfo represents comprehensive IP information for the /ip endpoint +type IPInfo struct { + IP string `json:"ip"` + Netblock string `json:"netblock"` + MaskLength int `json:"mask_length"` + IPVersion int `json:"ip_version"` + NumPeers int `json:"num_peers"` + // AS information + ASN int `json:"asn"` + ASName string `json:"as_name,omitempty"` + Handle string `json:"handle,omitempty"` + Description string `json:"description,omitempty"` + OrgName string `json:"org_name,omitempty"` + OrgID string `json:"org_id,omitempty"` + Address string `json:"address,omitempty"` + CountryCode string `json:"country_code,omitempty"` + AbuseEmail string `json:"abuse_email,omitempty"` + RIR string `json:"rir,omitempty"` + // Timestamps + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + // Indicates if WHOIS data needs refresh (not serialized) + NeedsWHOISRefresh bool `json:"-"` +} + // LiveRouteDeletion represents parameters for deleting a live route type LiveRouteDeletion struct { Prefix string @@ -97,3 +138,21 @@ type PeerUpdate struct { MessageType string Timestamp time.Time } + +// ASNWHOISUpdate contains WHOIS data for updating an ASN record. +type ASNWHOISUpdate struct { + ASN int + ASName string + OrgName string + OrgID string + Address string + CountryCode string + AbuseEmail string + AbusePhone string + TechEmail string + TechPhone string + RIR string + RIRRegDate *time.Time + RIRLastMod *time.Time + WHOISRaw string +} diff --git a/internal/database/schema.sql b/internal/database/schema.sql index d0d3227..366525c 100644 --- a/internal/database/schema.sql +++ b/internal/database/schema.sql @@ -6,8 +6,25 @@ CREATE TABLE IF NOT EXISTS asns ( asn INTEGER PRIMARY KEY, handle TEXT, description TEXT, + -- WHOIS parsed fields + as_name TEXT, + org_name TEXT, + org_id TEXT, + address TEXT, -- full address (may be multi-line) + country_code TEXT, + abuse_email TEXT, + abuse_phone TEXT, + tech_email TEXT, + tech_phone TEXT, + rir TEXT, -- ARIN, RIPE, APNIC, LACNIC, AFRINIC + rir_registration_date DATETIME, + rir_last_modified DATETIME, + -- Raw WHOIS response + whois_raw TEXT, -- complete WHOIS response text + -- Timestamps first_seen DATETIME NOT NULL, - last_seen DATETIME NOT NULL + last_seen DATETIME NOT NULL, + whois_updated_at DATETIME -- when we last fetched WHOIS data ); -- IPv4 prefixes table diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index e6c8614..791df47 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -102,14 +102,14 @@ func (t *Tracker) GetStreamMetrics() StreamMetrics { } return StreamMetrics{ - TotalMessages: totalMessages, - TotalBytes: totalBytes, - TotalWireBytes: totalWireBytes, - ConnectedSince: connectedSince, - Connected: t.isConnected.Load(), - MessagesPerSec: t.messageRate.Rate1(), - BitsPerSec: t.byteRate.Rate1() * bitsPerByte, - WireBitsPerSec: t.wireByteRate.Rate1() * bitsPerByte, + TotalMessages: totalMessages, + TotalBytes: totalBytes, + TotalWireBytes: totalWireBytes, + ConnectedSince: connectedSince, + Connected: t.isConnected.Load(), + MessagesPerSec: t.messageRate.Rate1(), + BitsPerSec: t.byteRate.Rate1() * bitsPerByte, + WireBitsPerSec: t.wireByteRate.Rate1() * bitsPerByte, } } diff --git a/internal/routewatch/app.go b/internal/routewatch/app.go index 7692da2..6aaaebb 100644 --- a/internal/routewatch/app.go +++ b/internal/routewatch/app.go @@ -43,6 +43,7 @@ type RouteWatch struct { peerHandler *PeerHandler prefixHandler *PrefixHandler peeringHandler *PeeringHandler + asnFetcher *ASNFetcher } // New creates a new RouteWatch instance @@ -109,6 +110,11 @@ func (rw *RouteWatch) Run(ctx context.Context) error { return err } + // Start ASN WHOIS fetcher for background updates + rw.asnFetcher = NewASNFetcher(rw.db, rw.logger.Logger) + rw.asnFetcher.Start() + rw.server.SetASNFetcher(rw.asnFetcher) + // Wait for context cancellation <-ctx.Done() @@ -144,6 +150,11 @@ func (rw *RouteWatch) Shutdown() { rw.peeringHandler.Stop() } + // Stop ASN WHOIS fetcher + if rw.asnFetcher != nil { + rw.asnFetcher.Stop() + } + // Stop services rw.streamer.Stop() diff --git a/internal/routewatch/app_integration_test.go b/internal/routewatch/app_integration_test.go index a5cc5fa..2e0fe39 100644 --- a/internal/routewatch/app_integration_test.go +++ b/internal/routewatch/app_integration_test.go @@ -302,6 +302,39 @@ func (m *mockStore) GetASPeersContext(ctx context.Context, asn int) ([]database. return m.GetASPeers(asn) } +// GetIPInfo mock implementation +func (m *mockStore) GetIPInfo(ip string) (*database.IPInfo, error) { + return m.GetIPInfoContext(context.Background(), ip) +} + +// GetIPInfoContext mock implementation with context support +func (m *mockStore) GetIPInfoContext(ctx context.Context, ip string) (*database.IPInfo, error) { + now := time.Now() + return &database.IPInfo{ + IP: ip, + Netblock: "8.8.8.0/24", + MaskLength: 24, + IPVersion: 4, + NumPeers: 3, + ASN: 15169, + Handle: "GOOGLE", + Description: "Google LLC", + CountryCode: "US", + FirstSeen: now.Add(-24 * time.Hour), + LastSeen: now, + }, nil +} + +// GetNextStaleASN mock implementation +func (m *mockStore) GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error) { + return 0, database.ErrNoStaleASN +} + +// UpdateASNWHOIS mock implementation +func (m *mockStore) UpdateASNWHOIS(ctx context.Context, update *database.ASNWHOISUpdate) error { + return nil +} + // UpsertLiveRouteBatch mock implementation func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error { m.mu.Lock() diff --git a/internal/routewatch/asnfetcher.go b/internal/routewatch/asnfetcher.go new file mode 100644 index 0000000..f883a56 --- /dev/null +++ b/internal/routewatch/asnfetcher.go @@ -0,0 +1,155 @@ +// Package routewatch contains the ASN WHOIS fetcher for background updates. +package routewatch + +import ( + "context" + "log/slog" + "sync" + "time" + + "git.eeqj.de/sneak/routewatch/internal/database" + "git.eeqj.de/sneak/routewatch/internal/whois" +) + +// ASN fetcher configuration constants. +const ( + // backgroundFetchInterval is how often the background fetcher runs. + backgroundFetchInterval = time.Minute + + // whoisStaleThreshold is how old WHOIS data can be before refresh. + whoisStaleThreshold = 30 * 24 * time.Hour // 30 days + + // immediateQueueSize is the buffer size for immediate fetch requests. + immediateQueueSize = 100 +) + +// ASNFetcher handles background WHOIS lookups for ASNs. +type ASNFetcher struct { + db database.Store + whoisClient *whois.Client + logger *slog.Logger + immediateQueue chan int + stopCh chan struct{} + wg sync.WaitGroup +} + +// NewASNFetcher creates a new ASN fetcher. +func NewASNFetcher(db database.Store, logger *slog.Logger) *ASNFetcher { + return &ASNFetcher{ + db: db, + whoisClient: whois.NewClient(), + logger: logger.With("component", "asn_fetcher"), + immediateQueue: make(chan int, immediateQueueSize), + stopCh: make(chan struct{}), + } +} + +// Start begins the background ASN fetcher goroutine. +func (f *ASNFetcher) Start() { + f.wg.Add(1) + go f.run() + f.logger.Info("ASN fetcher started", "interval", backgroundFetchInterval) +} + +// Stop gracefully shuts down the fetcher. +func (f *ASNFetcher) Stop() { + close(f.stopCh) + f.wg.Wait() + f.logger.Info("ASN fetcher stopped") +} + +// QueueImmediate queues an ASN for immediate WHOIS lookup. +// Non-blocking - if queue is full, the request is dropped. +func (f *ASNFetcher) QueueImmediate(asn int) { + select { + case f.immediateQueue <- asn: + f.logger.Debug("Queued immediate WHOIS lookup", "asn", asn) + default: + f.logger.Debug("Immediate queue full, dropping request", "asn", asn) + } +} + +// run is the main background loop. +func (f *ASNFetcher) run() { + defer f.wg.Done() + + ticker := time.NewTicker(backgroundFetchInterval) + defer ticker.Stop() + + for { + select { + case <-f.stopCh: + return + + case asn := <-f.immediateQueue: + // Process immediate request + f.fetchAndUpdate(asn) + + case <-ticker.C: + // Background fetch of stale/missing ASN + f.fetchNextStale() + } + } +} + +// fetchNextStale finds and fetches the next ASN needing WHOIS data. +func (f *ASNFetcher) fetchNextStale() { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + asn, err := f.db.GetNextStaleASN(ctx, whoisStaleThreshold) + if err != nil { + if err != database.ErrNoStaleASN { + f.logger.Error("Failed to get stale ASN", "error", err) + } + + return + } + + f.fetchAndUpdate(asn) +} + +// fetchAndUpdate performs a WHOIS lookup and updates the database. +func (f *ASNFetcher) fetchAndUpdate(asn int) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + f.logger.Info("Fetching WHOIS data", "asn", asn) + + info, err := f.whoisClient.LookupASN(ctx, asn) + if err != nil { + f.logger.Error("WHOIS lookup failed", "asn", asn, "error", err) + + return + } + + // Update database with WHOIS data + err = f.db.UpdateASNWHOIS(ctx, &database.ASNWHOISUpdate{ + ASN: asn, + ASName: info.ASName, + OrgName: info.OrgName, + OrgID: info.OrgID, + Address: info.Address, + CountryCode: info.CountryCode, + AbuseEmail: info.AbuseEmail, + AbusePhone: info.AbusePhone, + TechEmail: info.TechEmail, + TechPhone: info.TechPhone, + RIR: info.RIR, + RIRRegDate: info.RegDate, + RIRLastMod: info.LastMod, + WHOISRaw: info.RawResponse, + }) + if err != nil { + f.logger.Error("Failed to update ASN WHOIS data", "asn", asn, "error", err) + + return + } + + f.logger.Info("Updated ASN WHOIS data", + "asn", asn, + "org_name", info.OrgName, + "country", info.CountryCode, + "rir", info.RIR, + ) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 38a4692..36c5fc4 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -364,35 +364,66 @@ func (s *Server) handleStatusHTML() http.HandlerFunc { // handleIPLookup returns a handler that looks up AS information for an IP address func (s *Server) handleIPLookup() http.HandlerFunc { + return s.handleIPInfo() +} + +// handleIPInfo returns a handler that provides comprehensive IP information. +// Used for /ip, /ip/{addr}, and /api/v1/ip/{ip} endpoints. +func (s *Server) handleIPInfo() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // Get IP from URL param, falling back to client IP ip := chi.URLParam(r, "ip") if ip == "" { - writeJSONError(w, http.StatusBadRequest, "IP parameter is required") + ip = chi.URLParam(r, "addr") + } + if ip == "" { + // Use client IP (RealIP middleware has already processed this) + ip = extractClientIP(r) + } + + if ip == "" { + writeJSONError(w, http.StatusBadRequest, "Could not determine IP address") return } - // Look up AS information for the IP - asInfo, err := s.db.GetASInfoForIPContext(r.Context(), ip) + // Look up comprehensive IP information + ipInfo, err := s.db.GetIPInfoContext(r.Context(), ip) if err != nil { - // Check if it's an invalid IP error if errors.Is(err, database.ErrInvalidIP) { writeJSONError(w, http.StatusBadRequest, err.Error()) } else { - // All other errors (including ErrNoRoute) are 404 writeJSONError(w, http.StatusNotFound, err.Error()) } return } + // Queue WHOIS refresh if data is stale (non-blocking) + if ipInfo.NeedsWHOISRefresh && s.asnFetcher != nil { + s.asnFetcher.QueueImmediate(ipInfo.ASN) + } + // Return successful response - if err := writeJSONSuccess(w, asInfo); err != nil { - s.logger.Error("Failed to encode AS info", "error", err) + if err := writeJSONSuccess(w, ipInfo); err != nil { + s.logger.Error("Failed to encode IP info", "error", err) } } } +// extractClientIP extracts the client IP from the request. +// Works with chi's RealIP middleware which sets RemoteAddr. +func extractClientIP(r *http.Request) string { + // RemoteAddr is in the form "IP:port" or just "IP" for unix sockets + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // Might be just an IP without port + return r.RemoteAddr + } + + return host +} + // handleASDetailJSON returns AS details as JSON func (s *Server) handleASDetailJSON() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -750,36 +781,6 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc { } } -// handleIPRedirect looks up the prefix containing the IP and redirects to its detail page -func (s *Server) handleIPRedirect() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ip := chi.URLParam(r, "ip") - if ip == "" { - http.Error(w, "IP parameter is required", http.StatusBadRequest) - - return - } - - // Look up AS information for the IP (which includes the prefix) - asInfo, err := s.db.GetASInfoForIP(ip) - if err != nil { - if errors.Is(err, database.ErrInvalidIP) { - http.Error(w, "Invalid IP address", http.StatusBadRequest) - } else if errors.Is(err, database.ErrNoRoute) { - http.Error(w, "No route found for this IP", http.StatusNotFound) - } else { - s.logger.Error("Failed to look up IP", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - } - - return - } - - // Redirect to the prefix detail page (URL encode the prefix) - http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther) - } -} - // handlePrefixLength shows a random sample of IPv4 prefixes with the specified mask length func (s *Server) handlePrefixLength() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/routes.go b/internal/server/routes.go index 848aeca..b628c88 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -30,7 +30,13 @@ func (s *Server) setupRoutes() { r.Get("/prefix/{prefix}", s.handlePrefixDetail()) r.Get("/prefixlength/{length}", s.handlePrefixLength()) r.Get("/prefixlength6/{length}", s.handlePrefixLength6()) - r.Get("/ip/{ip}", s.handleIPRedirect()) + + // IP info JSON endpoints (replaces old /ip redirect) + r.Route("/ip", func(r chi.Router) { + r.Use(JSONValidationMiddleware) + r.Get("/", s.handleIPInfo()) // Client IP + r.Get("/{addr}", s.handleIPInfo()) // Specified IP + }) // API routes r.Route("/api/v1", func(r chi.Router) { diff --git a/internal/server/server.go b/internal/server/server.go index f8b0370..ace5df6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,13 +13,19 @@ import ( "github.com/go-chi/chi/v5" ) +// ASNFetcher is an interface for queuing ASN WHOIS lookups. +type ASNFetcher interface { + QueueImmediate(asn int) +} + // Server provides HTTP endpoints for status monitoring type Server struct { - router *chi.Mux - db database.Store - streamer *streamer.Streamer - logger *logger.Logger - srv *http.Server + router *chi.Mux + db database.Store + streamer *streamer.Streamer + logger *logger.Logger + srv *http.Server + asnFetcher ASNFetcher } // New creates a new HTTP server @@ -70,3 +76,8 @@ func (s *Server) Stop(ctx context.Context) error { return s.srv.Shutdown(ctx) } + +// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. +func (s *Server) SetASNFetcher(fetcher ASNFetcher) { + s.asnFetcher = fetcher +} diff --git a/internal/whois/whois.go b/internal/whois/whois.go new file mode 100644 index 0000000..1c4e55b --- /dev/null +++ b/internal/whois/whois.go @@ -0,0 +1,347 @@ +// Package whois provides WHOIS lookup functionality for ASN information. +package whois + +import ( + "bufio" + "context" + "fmt" + "net" + "regexp" + "strings" + "time" +) + +// Timeout constants for WHOIS queries. +const ( + dialTimeout = 10 * time.Second + readTimeout = 30 * time.Second + writeTimeout = 5 * time.Second +) + +// Parsing constants. +const ( + keyValueParts = 2 // Expected parts when splitting "key: value" + lacnicDateFormatLen = 8 // Length of YYYYMMDD date format +) + +// WHOIS server addresses. +const ( + whoisServerIANA = "whois.iana.org:43" + whoisServerARIN = "whois.arin.net:43" + whoisServerRIPE = "whois.ripe.net:43" + whoisServerAPNIC = "whois.apnic.net:43" + whoisServerLACNIC = "whois.lacnic.net:43" + whoisServerAFRINIC = "whois.afrinic.net:43" +) + +// RIR identifiers. +const ( + RIRARIN = "ARIN" + RIRRIPE = "RIPE" + RIRAPNIC = "APNIC" + RIRLACNIC = "LACNIC" + RIRAFRNIC = "AFRINIC" +) + +// ASNInfo contains parsed WHOIS information for an ASN. +type ASNInfo struct { + ASN int + ASName string + OrgName string + OrgID string + Address string + CountryCode string + AbuseEmail string + AbusePhone string + TechEmail string + TechPhone string + RIR string + RegDate *time.Time + LastMod *time.Time + RawResponse string +} + +// Client performs WHOIS lookups for ASNs. +type Client struct { + // Dialer for creating connections (can be overridden for testing) + dialer *net.Dialer +} + +// NewClient creates a new WHOIS client. +func NewClient() *Client { + return &Client{ + dialer: &net.Dialer{ + Timeout: dialTimeout, + }, + } +} + +// LookupASN queries WHOIS for the given ASN and returns parsed information. +func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) { + // Query IANA first to find the authoritative RIR + query := fmt.Sprintf("AS%d", asn) + + ianaResp, err := c.query(ctx, whoisServerIANA, query) + if err != nil { + return nil, fmt.Errorf("IANA query failed: %w", err) + } + + // Determine RIR from IANA response + rir, whoisServer := c.parseIANAReferral(ianaResp) + if whoisServer == "" { + // No referral, try to parse what we have + return c.parseResponse(asn, rir, ianaResp), nil + } + + // Query the authoritative RIR + rirResp, err := c.query(ctx, whoisServer, query) + if err != nil { + // Return partial data from IANA if RIR query fails + info := c.parseResponse(asn, rir, ianaResp) + info.RawResponse = ianaResp + "\n--- RIR query failed: " + err.Error() + " ---\n" + + return info, nil + } + + // Combine responses and parse + fullResponse := ianaResp + "\n" + rirResp + info := c.parseResponse(asn, rir, fullResponse) + info.RawResponse = fullResponse + + return info, nil +} + +// query performs a raw WHOIS query to the specified server. +func (c *Client) query(ctx context.Context, server, query string) (string, error) { + conn, err := c.dialer.DialContext(ctx, "tcp", server) + if err != nil { + return "", fmt.Errorf("dial %s: %w", server, err) + } + defer func() { _ = conn.Close() }() + + // Set deadlines + if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { + return "", fmt.Errorf("set write deadline: %w", err) + } + + // Send query + if _, err := fmt.Fprintf(conn, "%s\r\n", query); err != nil { + return "", fmt.Errorf("write query: %w", err) + } + + // Read response + if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil { + return "", fmt.Errorf("set read deadline: %w", err) + } + + var sb strings.Builder + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + sb.WriteString(scanner.Text()) + sb.WriteString("\n") + } + + if err := scanner.Err(); err != nil { + return sb.String(), fmt.Errorf("read response: %w", err) + } + + return sb.String(), nil +} + +// parseIANAReferral extracts the RIR and WHOIS server from an IANA response. +func (c *Client) parseIANAReferral(response string) (rir, whoisServer string) { + lines := strings.Split(response, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Look for "refer:" line + if strings.HasPrefix(strings.ToLower(line), "refer:") { + server := strings.TrimSpace(strings.TrimPrefix(line, "refer:")) + server = strings.TrimSpace(strings.TrimPrefix(server, "Refer:")) + + switch { + case strings.Contains(server, "arin"): + return RIRARIN, whoisServerARIN + case strings.Contains(server, "ripe"): + return RIRRIPE, whoisServerRIPE + case strings.Contains(server, "apnic"): + return RIRAPNIC, whoisServerAPNIC + case strings.Contains(server, "lacnic"): + return RIRLACNIC, whoisServerLACNIC + case strings.Contains(server, "afrinic"): + return RIRAFRNIC, whoisServerAFRINIC + default: + // Unknown server, add port if missing + if !strings.Contains(server, ":") { + server += ":43" + } + + return "", server + } + } + + // Also check organisation line for RIR hints + if strings.HasPrefix(strings.ToLower(line), "organisation:") { + org := strings.ToLower(line) + switch { + case strings.Contains(org, "arin"): + rir = RIRARIN + case strings.Contains(org, "ripe"): + rir = RIRRIPE + case strings.Contains(org, "apnic"): + rir = RIRAPNIC + case strings.Contains(org, "lacnic"): + rir = RIRLACNIC + case strings.Contains(org, "afrinic"): + rir = RIRAFRNIC + } + } + } + + return rir, "" +} + +// parseResponse extracts ASN information from a WHOIS response. +func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo { + info := &ASNInfo{ + ASN: asn, + RIR: rir, + RawResponse: response, + } + + lines := strings.Split(response, "\n") + var addressLines []string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") { + continue + } + + // Split on first colon + parts := strings.SplitN(line, ":", keyValueParts) + if len(parts) != keyValueParts { + continue + } + + key := strings.TrimSpace(strings.ToLower(parts[0])) + value := strings.TrimSpace(parts[1]) + + if value == "" { + continue + } + + switch key { + // AS Name (varies by RIR) + case "asname", "as-name": + if info.ASName == "" { + info.ASName = value + } + + // Organization + case "orgname", "org-name", "owner": + if info.OrgName == "" { + info.OrgName = value + } + case "orgid", "org-id", "org": + if info.OrgID == "" { + info.OrgID = value + } + + // Address (collect multiple lines) + case "address": + addressLines = append(addressLines, value) + + // Country + case "country": + if info.CountryCode == "" && len(value) == 2 { + info.CountryCode = strings.ToUpper(value) + } + + // Abuse contact + case "orgabuseemail", "abuse-mailbox": + if info.AbuseEmail == "" { + info.AbuseEmail = value + } + case "orgabusephone": + if info.AbusePhone == "" { + info.AbusePhone = value + } + + // Tech contact + case "orgtechemail": + if info.TechEmail == "" { + info.TechEmail = value + } + case "orgtechphone": + if info.TechPhone == "" { + info.TechPhone = value + } + + // Registration dates + case "regdate", "created": + if info.RegDate == nil { + info.RegDate = c.parseDate(value) + } + case "updated", "last-modified", "changed": + if info.LastMod == nil { + info.LastMod = c.parseDate(value) + } + } + } + + // Combine address lines + if len(addressLines) > 0 { + info.Address = strings.Join(addressLines, "\n") + } + + // Extract abuse email from comment lines (common in ARIN responses) + if info.AbuseEmail == "" { + info.AbuseEmail = c.extractAbuseEmail(response) + } + + return info +} + +// parseDate attempts to parse various date formats used in WHOIS responses. +func (c *Client) parseDate(value string) *time.Time { + // Common formats + formats := []string{ + "2006-01-02", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05-07:00", + "20060102", + "02-Jan-2006", + } + + // Clean up value + value = strings.TrimSpace(value) + // Handle "YYYYMMDD" format from LACNIC + if len(value) == lacnicDateFormatLen { + if _, err := time.Parse("20060102", value); err == nil { + t, _ := time.Parse("20060102", value) + + return &t + } + } + + for _, format := range formats { + if t, err := time.Parse(format, value); err == nil { + return &t + } + } + + return nil +} + +// extractAbuseEmail extracts abuse email from response using regex. +func (c *Client) extractAbuseEmail(response string) string { + // Look for "Abuse contact for 'AS...' is 'email@domain'" + re := regexp.MustCompile(`[Aa]buse contact.*?is\s+['"]?([^\s'"]+@[^\s'"]+)['"]?`) + if matches := re.FindStringSubmatch(response); len(matches) > 1 { + return matches[1] + } + + return "" +}