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")
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
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
|
||||
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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -13,6 +13,11 @@ 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
|
||||
@ -20,6 +25,7 @@ type Server struct {
|
||||
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
|
||||
}
|
||||
|
||||
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