Add IP information API with background WHOIS fetcher
- Add /ip and /ip/{addr} JSON endpoints returning comprehensive IP info
- Include ASN, netblock, country code, org name, abuse contact, RIR data
- Extend ASN schema with WHOIS fields (country, org, abuse contact, etc)
- Create background WHOIS fetcher for rate-limited ASN info updates
- Store raw WHOIS responses for debugging and data preservation
- Queue on-demand WHOIS lookups when stale data is requested
- Refactor handleIPInfo to serve all IP endpoints consistently
This commit is contained in:
parent
7e4dc528bd
commit
3b159454eb
@ -44,6 +44,8 @@ var (
|
|||||||
ErrInvalidIP = errors.New("invalid IP address")
|
ErrInvalidIP = errors.New("invalid IP address")
|
||||||
// ErrNoRoute is returned when no route is found for an IP
|
// ErrNoRoute is returned when no route is found for an IP
|
||||||
ErrNoRoute = errors.New("no route found")
|
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.
|
// Database manages the SQLite database connection and operations.
|
||||||
@ -1630,3 +1632,288 @@ func (d *Database) GetRandomPrefixesByLengthContext(
|
|||||||
|
|
||||||
return routes, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -61,6 +61,12 @@ type Store interface {
|
|||||||
// IP lookup operations
|
// IP lookup operations
|
||||||
GetASInfoForIP(ip string) (*ASInfo, error)
|
GetASInfoForIP(ip string) (*ASInfo, error)
|
||||||
GetASInfoForIPContext(ctx context.Context, 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
|
// AS and prefix detail operations
|
||||||
GetASDetails(asn int) (*ASN, []LiveRoute, error)
|
GetASDetails(asn int) (*ASN, []LiveRoute, error)
|
||||||
|
|||||||
@ -8,13 +8,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ASN represents an Autonomous System Number with its metadata including
|
// 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 {
|
type ASN struct {
|
||||||
ASN int `json:"asn"`
|
ASN int `json:"asn"`
|
||||||
Handle string `json:"handle"`
|
Handle string `json:"handle"`
|
||||||
Description string `json:"description"`
|
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"`
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
LastSeen time.Time `json:"last_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)
|
// 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"`
|
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 {
|
type ASInfo struct {
|
||||||
ASN int `json:"asn"`
|
ASN int `json:"asn"`
|
||||||
Handle string `json:"handle"`
|
Handle string `json:"handle"`
|
||||||
@ -82,6 +98,31 @@ type ASInfo struct {
|
|||||||
Age string `json:"age"`
|
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
|
// LiveRouteDeletion represents parameters for deleting a live route
|
||||||
type LiveRouteDeletion struct {
|
type LiveRouteDeletion struct {
|
||||||
Prefix string
|
Prefix string
|
||||||
@ -97,3 +138,21 @@ type PeerUpdate struct {
|
|||||||
MessageType string
|
MessageType string
|
||||||
Timestamp time.Time
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -6,8 +6,25 @@ CREATE TABLE IF NOT EXISTS asns (
|
|||||||
asn INTEGER PRIMARY KEY,
|
asn INTEGER PRIMARY KEY,
|
||||||
handle TEXT,
|
handle TEXT,
|
||||||
description 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,
|
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
|
-- IPv4 prefixes table
|
||||||
|
|||||||
@ -43,6 +43,7 @@ type RouteWatch struct {
|
|||||||
peerHandler *PeerHandler
|
peerHandler *PeerHandler
|
||||||
prefixHandler *PrefixHandler
|
prefixHandler *PrefixHandler
|
||||||
peeringHandler *PeeringHandler
|
peeringHandler *PeeringHandler
|
||||||
|
asnFetcher *ASNFetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new RouteWatch instance
|
// New creates a new RouteWatch instance
|
||||||
@ -109,6 +110,11 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
|||||||
return err
|
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
|
// Wait for context cancellation
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
@ -144,6 +150,11 @@ func (rw *RouteWatch) Shutdown() {
|
|||||||
rw.peeringHandler.Stop()
|
rw.peeringHandler.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop ASN WHOIS fetcher
|
||||||
|
if rw.asnFetcher != nil {
|
||||||
|
rw.asnFetcher.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// Stop services
|
// Stop services
|
||||||
rw.streamer.Stop()
|
rw.streamer.Stop()
|
||||||
|
|
||||||
|
|||||||
@ -302,6 +302,39 @@ func (m *mockStore) GetASPeersContext(ctx context.Context, asn int) ([]database.
|
|||||||
return m.GetASPeers(asn)
|
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
|
// UpsertLiveRouteBatch mock implementation
|
||||||
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
|
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
155
internal/routewatch/asnfetcher.go
Normal file
155
internal/routewatch/asnfetcher.go
Normal file
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -364,35 +364,66 @@ func (s *Server) handleStatusHTML() http.HandlerFunc {
|
|||||||
|
|
||||||
// handleIPLookup returns a handler that looks up AS information for an IP address
|
// handleIPLookup returns a handler that looks up AS information for an IP address
|
||||||
func (s *Server) handleIPLookup() http.HandlerFunc {
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get IP from URL param, falling back to client IP
|
||||||
ip := chi.URLParam(r, "ip")
|
ip := chi.URLParam(r, "ip")
|
||||||
if 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up AS information for the IP
|
// Look up comprehensive IP information
|
||||||
asInfo, err := s.db.GetASInfoForIPContext(r.Context(), ip)
|
ipInfo, err := s.db.GetIPInfoContext(r.Context(), ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if it's an invalid IP error
|
|
||||||
if errors.Is(err, database.ErrInvalidIP) {
|
if errors.Is(err, database.ErrInvalidIP) {
|
||||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
} else {
|
} else {
|
||||||
// All other errors (including ErrNoRoute) are 404
|
|
||||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue WHOIS refresh if data is stale (non-blocking)
|
||||||
|
if ipInfo.NeedsWHOISRefresh && s.asnFetcher != nil {
|
||||||
|
s.asnFetcher.QueueImmediate(ipInfo.ASN)
|
||||||
|
}
|
||||||
|
|
||||||
// Return successful response
|
// Return successful response
|
||||||
if err := writeJSONSuccess(w, asInfo); err != nil {
|
if err := writeJSONSuccess(w, ipInfo); err != nil {
|
||||||
s.logger.Error("Failed to encode AS info", "error", err)
|
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
|
// handleASDetailJSON returns AS details as JSON
|
||||||
func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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
|
// handlePrefixLength shows a random sample of IPv4 prefixes with the specified mask length
|
||||||
func (s *Server) handlePrefixLength() http.HandlerFunc {
|
func (s *Server) handlePrefixLength() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -30,7 +30,13 @@ func (s *Server) setupRoutes() {
|
|||||||
r.Get("/prefix/{prefix}", s.handlePrefixDetail())
|
r.Get("/prefix/{prefix}", s.handlePrefixDetail())
|
||||||
r.Get("/prefixlength/{length}", s.handlePrefixLength())
|
r.Get("/prefixlength/{length}", s.handlePrefixLength())
|
||||||
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
|
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
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
|||||||
@ -13,6 +13,11 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"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
|
// Server provides HTTP endpoints for status monitoring
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
@ -20,6 +25,7 @@ type Server struct {
|
|||||||
streamer *streamer.Streamer
|
streamer *streamer.Streamer
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
|
asnFetcher ASNFetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new HTTP server
|
// New creates a new HTTP server
|
||||||
@ -70,3 +76,8 @@ func (s *Server) Stop(ctx context.Context) error {
|
|||||||
|
|
||||||
return s.srv.Shutdown(ctx)
|
return s.srv.Shutdown(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups.
|
||||||
|
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
|
||||||
|
s.asnFetcher = fetcher
|
||||||
|
}
|
||||||
|
|||||||
347
internal/whois/whois.go
Normal file
347
internal/whois/whois.go
Normal file
@ -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 ""
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user