backtrace/bgptools/report.go

585 lines
19 KiB
Go

package bgptools
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
// ReportStatus is the provider-neutral status used by the unified IP/BGP
// report. A report deliberately keeps source-level failures instead of
// turning them into an empty successful result.
type ReportStatus string
const (
ReportAvailable ReportStatus = "available"
ReportPartial ReportStatus = "partial"
ReportRateLimited ReportStatus = "rate_limited"
ReportTimeout ReportStatus = "timeout"
ReportMissingFields ReportStatus = "missing_fields"
ReportError ReportStatus = "error"
ReportUnsupported ReportStatus = "unsupported"
)
// IPBGPSourceStatus records the outcome of one logical source in a unified
// report. Error is intentionally a short diagnostic, never a raw response.
type IPBGPSourceStatus struct {
Source string `json:"source"`
Status ReportStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// RIRInfo describes the registry inference and its evidence source. The
// inference is conservative: country and address ownership are not used as a
// substitute for an explicit RDAP/WHOIS registry signal.
type RIRInfo struct {
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
Status ReportStatus `json:"status"`
}
// GeofeedResult contains an RDAP/WHOIS geofeed URL and, when requested, the
// bounded fetch result. The payload itself is not retained in the report.
type GeofeedResult struct {
URL string `json:"url"`
Status ReportStatus `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
Bytes int64 `json:"bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// WHOISRecord is the small, structured subset used when RDAP is unavailable
// or missing required fields. Raw port-43 text is never returned.
type WHOISRecord struct {
Server string `json:"server"`
Status ReportStatus `json:"status"`
Prefixes []string `json:"prefixes,omitempty"`
RegistrationDate *time.Time `json:"registration_date,omitempty"`
GeofeedURLs []string `json:"geofeed_urls,omitempty"`
RIR RIRInfo `json:"rir"`
}
// IPBGPReport combines RDAP, registry metadata, optional geofeed fetches and
// ASN relationship data. Relationships are requested only when ASN or an
// injected ASN resolver supplies a target ASN.
type IPBGPReport struct {
IP string `json:"ip"`
ASN string `json:"asn,omitempty"`
Status ReportStatus `json:"status"`
RDAP *RDAPRecord `json:"rdap,omitempty"`
WHOIS *WHOISRecord `json:"whois,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
PrefixSource string `json:"prefix_source,omitempty"`
RegistrationDate *time.Time `json:"registration_date,omitempty"`
RIR RIRInfo `json:"rir"`
Geofeeds []GeofeedResult `json:"geofeeds,omitempty"`
Relationships *ASNRelationshipReport `json:"relationships,omitempty"`
Sources []IPBGPSourceStatus `json:"sources"`
}
// ASNResolver allows a caller to supply an existing, structured IP-to-ASN
// result without making this package invent a provider or read environment
// variables.
type ASNResolver func(context.Context, string) (string, error)
// WHOISDialContext is injectable so port-43 fallback can be tested entirely
// with a local TCP fixture.
type WHOISDialContext func(context.Context, string, string) (net.Conn, error)
// IPBGPReportConfig keeps every network dependency explicit and injectable.
// WHOIS fallback is opt-in; when enabled it always has its own finite timeout.
type IPBGPReportConfig struct {
Timeout time.Duration
RDAPClient *http.Client
RDAPBaseURL string
ASN string
ResolveASN ASNResolver
Relationships RelationshipConfig
FetchGeofeed bool
GeofeedClient *http.Client
GeofeedTimeout time.Duration
MaxGeofeedBytes int64
EnableWHOISFallback bool
WHOISServer string
WHOISTimeout time.Duration
MaxWHOISBytes int64
WHOISDialContext WHOISDialContext
WHOISBootstrapClient *http.Client
WHOISBootstrapURL string
MaxWHOISBootstrapBytes int64
}
const (
defaultIPBGPReportTimeout = 15 * time.Second
defaultGeofeedTimeout = 3 * time.Second
defaultWHOISTimeout = 3 * time.Second
defaultGeofeedBytes = 1 << 20
defaultWHOISBytes = 1 << 20
)
// QueryIPBGPReport executes the structured IP/BGP collection with a bounded
// context. Provider failures produce a partial report; invalid input and
// caller cancellation are returned as errors for compatibility with the
// lower-level APIs.
func QueryIPBGPReport(parent context.Context, ip string, cfg IPBGPReportConfig) (*IPBGPReport, error) {
if parent == nil {
parent = context.Background()
}
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return nil, ErrInvalidIPAddress
}
if cfg.Timeout <= 0 {
cfg.Timeout = defaultIPBGPReportTimeout
}
ctx, cancel := context.WithTimeout(parent, cfg.Timeout)
defer cancel()
report := &IPBGPReport{IP: parsed.String(), Status: ReportError}
rdap, rdapErr := QueryRDAP(ctx, report.IP, cfg.RDAPClient, cfg.RDAPBaseURL)
rdapStatus := reportStatusForError(rdapErr)
if rdapErr == nil {
report.RDAP = &rdap
report.Prefixes = uniqueSortedStrings(rdap.Prefixes)
if len(report.Prefixes) > 0 {
report.PrefixSource = "rdap"
}
report.RegistrationDate = rdap.RegistrationDate
rdapStatus = ReportAvailable
if len(report.Prefixes) == 0 || report.RegistrationDate == nil {
rdapStatus = ReportMissingFields
}
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "rdap", Status: rdapStatus})
} else {
report.Sources = append(report.Sources, sourceStatusForError("rdap", rdapStatus, rdapErr))
}
report.RIR = inferRIR(rdap, rdapErr == nil)
if report.RIR.Status == "" {
report.RIR.Status = ReportMissingFields
}
if cfg.EnableWHOISFallback && (rdapErr != nil || len(report.Prefixes) == 0 || report.RegistrationDate == nil || report.RIR.Status != ReportAvailable) {
server := strings.TrimSpace(cfg.WHOISServer)
if server == "" {
server = strings.TrimSpace(rdap.Port43)
}
if server == "" {
resolved, err := resolveWHOISServer(ctx, report.IP, cfg)
if err != nil {
report.Sources = append(report.Sources, sourceStatusForError("whois_bootstrap", reportStatusForError(err), err))
} else {
server = resolved
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "whois_bootstrap", Status: ReportAvailable})
}
}
if server == "" {
status := IPBGPSourceStatus{Source: "whois", Status: ReportUnsupported, Error: "no port-43 server resolved"}
report.Sources = append(report.Sources, status)
} else {
whois, err := queryWHOIS(ctx, report.IP, server, cfg)
if err != nil {
report.Sources = append(report.Sources, sourceStatusForError("whois", reportStatusForError(err), err))
} else {
report.WHOIS = &whois
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "whois", Status: whois.Status})
if len(report.Prefixes) == 0 && len(whois.Prefixes) > 0 {
report.Prefixes = append([]string(nil), whois.Prefixes...)
report.PrefixSource = "whois"
}
if report.RegistrationDate == nil {
report.RegistrationDate = whois.RegistrationDate
}
if report.RIR.Status != ReportAvailable && whois.RIR.Status == ReportAvailable {
report.RIR = whois.RIR
}
}
}
}
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "rir", Status: report.RIR.Status})
geofeedURLs := append([]string(nil), rdap.GeofeedURLs...)
if report.WHOIS != nil {
geofeedURLs = append(geofeedURLs, report.WHOIS.GeofeedURLs...)
}
geofeedURLs = uniqueSortedStrings(geofeedURLs)
for _, geofeedURL := range geofeedURLs {
if !cfg.FetchGeofeed {
report.Geofeeds = append(report.Geofeeds, GeofeedResult{URL: geofeedURL, Status: ReportUnsupported, Error: "fetch disabled"})
continue
}
result := fetchGeofeed(ctx, geofeedURL, cfg)
report.Geofeeds = append(report.Geofeeds, result)
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "geofeed:" + geofeedURL, Status: result.Status, Error: result.Error})
}
asn := strings.TrimSpace(cfg.ASN)
if asn == "" && cfg.ResolveASN != nil {
resolved, err := cfg.ResolveASN(ctx, report.IP)
if err != nil {
report.Sources = append(report.Sources, sourceStatusForError("asn_resolver", reportStatusForError(err), err))
} else {
asn = strings.TrimSpace(resolved)
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_resolver", Status: ReportAvailable})
}
}
if asn != "" {
normalizedASN, err := normalizeASN(asn)
if err != nil {
return report, err
}
report.ASN = normalizedASN
relationships, err := QueryASNRelationships(ctx, normalizedASN, cfg.Relationships)
if relationships != nil {
report.Relationships = relationships
}
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return report, err
}
report.Sources = append(report.Sources, sourceStatusForError("asn_relationships", reportStatusForError(err), err))
} else {
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_relationships", Status: reportStatusFromRelationship(relationships.Status)})
}
} else {
report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "asn_relationships", Status: ReportUnsupported, Error: "ASN not supplied"})
}
if err := ctx.Err(); err != nil {
return report, err
}
report.Prefixes = uniqueSortedStrings(report.Prefixes)
sort.SliceStable(report.Sources, func(i, j int) bool { return report.Sources[i].Source < report.Sources[j].Source })
report.Status = aggregateReportStatus(report.Sources)
return report, nil
}
// QueryIPBGP is a short alias for callers that prefer the domain name over the
// implementation-oriented Report suffix.
func QueryIPBGP(ctx context.Context, ip string, cfg IPBGPReportConfig) (*IPBGPReport, error) {
return QueryIPBGPReport(ctx, ip, cfg)
}
func inferRIR(record RDAPRecord, available bool) RIRInfo {
if !available {
return RIRInfo{Status: ReportMissingFields}
}
text := strings.Join([]string{record.Source, record.Port43, record.Handle, record.ParentHandle}, " ")
if name := rirNameFromText(text); name != "" {
return RIRInfo{Name: name, Source: "rdap", Status: ReportAvailable}
}
return RIRInfo{Source: "rdap", Status: ReportMissingFields}
}
func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) GeofeedResult {
result := GeofeedResult{URL: rawURL}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
result.Status = ReportUnsupported
result.Error = "unsupported geofeed URL"
return result
}
timeout := cfg.GeofeedTimeout
if timeout <= 0 {
timeout = defaultGeofeedTimeout
}
maxBytes := cfg.MaxGeofeedBytes
if maxBytes <= 0 {
maxBytes = defaultGeofeedBytes
}
client := cfg.GeofeedClient
if client == nil {
client = &http.Client{}
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
result.Status = ReportError
result.Error = err.Error()
return result
}
req.Header.Set("Accept", "text/csv, text/plain, */*")
req.Header.Set("User-Agent", "oneclickvirt-backtrace-geofeed/1")
resp, err := client.Do(req)
if err != nil {
result.Status = reportStatusForError(err)
result.Error = err.Error()
return result
}
defer resp.Body.Close()
result.HTTPStatus = resp.StatusCode
if resp.StatusCode == http.StatusTooManyRequests {
result.Status = ReportRateLimited
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout {
result.Status = ReportTimeout
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Status = ReportError
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return result
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
result.Status = reportStatusForError(err)
result.Error = err.Error()
return result
}
if int64(len(body)) > maxBytes {
result.Status = ReportError
result.Error = fmt.Sprintf("response exceeds %d bytes", maxBytes)
return result
}
result.Status = ReportAvailable
result.Bytes = int64(len(body))
return result
}
func queryWHOIS(parent context.Context, ip, server string, cfg IPBGPReportConfig) (WHOISRecord, error) {
address, err := normalizeWHOISAddress(server)
if err != nil {
return WHOISRecord{}, err
}
timeout := cfg.WHOISTimeout
if timeout <= 0 {
timeout = defaultWHOISTimeout
}
maxBytes := cfg.MaxWHOISBytes
if maxBytes <= 0 {
maxBytes = defaultWHOISBytes
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
dial := cfg.WHOISDialContext
if dial == nil {
dialer := &net.Dialer{}
dial = dialer.DialContext
}
conn, err := dial(ctx, "tcp", address)
if err != nil {
return WHOISRecord{}, err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if _, err := fmt.Fprintf(conn, "%s\r\n", ip); err != nil {
return WHOISRecord{}, err
}
body, err := io.ReadAll(io.LimitReader(conn, maxBytes+1))
if err != nil {
return WHOISRecord{}, err
}
if int64(len(body)) > maxBytes {
return WHOISRecord{}, fmt.Errorf("whois response exceeds %d bytes", maxBytes)
}
result, err := parseWHOISRecord(address, body)
if err != nil {
return WHOISRecord{}, err
}
if len(result.Prefixes) == 0 && result.RegistrationDate == nil && result.RIR.Status != ReportAvailable && len(result.GeofeedURLs) == 0 {
result.Status = ReportMissingFields
} else {
result.Status = ReportAvailable
}
return result, nil
}
func normalizeWHOISAddress(server string) (string, error) {
server = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(server, "whois://"), "tcp://"))
if server == "" || strings.ContainsAny(server, "/ \t\r\n") {
return "", errors.New("invalid WHOIS server")
}
if host, port, err := net.SplitHostPort(server); err == nil {
if host == "" || port == "" {
return "", errors.New("invalid WHOIS server")
}
return net.JoinHostPort(host, port), nil
}
if strings.Contains(server, ":") && net.ParseIP(server) == nil {
return "", errors.New("invalid WHOIS server")
}
return net.JoinHostPort(server, "43"), nil
}
func parseWHOISRecord(server string, body []byte) (WHOISRecord, error) {
result := WHOISRecord{Server: server, RIR: RIRInfo{Status: ReportMissingFields}}
scanner := bufio.NewScanner(strings.NewReader(string(body)))
scanner.Buffer(make([]byte, 1024), 1<<20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
switch key {
case "cidr", "route", "route6", "inet6route", "prefix":
for _, item := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\t' }) {
if normalized, ok := normalizeWHOISPrefix(item); ok {
result.Prefixes = append(result.Prefixes, normalized)
}
}
case "registration date", "regdate", "created", "created date", "creation date":
if parsed, ok := parseWHOISDate(value); ok {
result.RegistrationDate = &parsed
}
case "geofeed", "geofeed url", "geofeed-url":
if parsed, err := url.Parse(value); err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https") {
result.GeofeedURLs = append(result.GeofeedURLs, parsed.String())
}
case "rir", "registry", "source", "whois server":
if name := rirNameFromText(value); name != "" {
result.RIR = RIRInfo{Name: name, Source: "whois", Status: ReportAvailable}
}
}
}
if err := scanner.Err(); err != nil {
return WHOISRecord{}, err
}
result.Prefixes = uniqueSortedStrings(result.Prefixes)
result.GeofeedURLs = uniqueSortedStrings(result.GeofeedURLs)
if result.RIR.Status != ReportAvailable {
if name := rirNameFromText(server); name != "" {
result.RIR = RIRInfo{Name: name, Source: "whois", Status: ReportAvailable}
}
}
return result, nil
}
func normalizeWHOISPrefix(value string) (string, bool) {
value = strings.TrimSpace(value)
_, network, err := net.ParseCIDR(value)
if err != nil {
return "", false
}
return network.String(), true
}
func parseWHOISDate(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
for _, layout := range []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
"02-Jan-2006",
"20060102",
} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func rirNameFromText(value string) string {
tokens := strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
})
registries := map[string]string{
"arin": "ARIN",
"ripe": "RIPE NCC",
"apnic": "APNIC",
"lacnic": "LACNIC",
"afrinic": "AFRINIC",
}
for _, token := range tokens {
if name := registries[token]; name != "" {
return name
}
}
return ""
}
func sourceStatusForError(source string, status ReportStatus, err error) IPBGPSourceStatus {
item := IPBGPSourceStatus{Source: source, Status: status}
if err != nil {
item.Error = err.Error()
}
return item
}
func reportStatusForError(err error) ReportStatus {
if err == nil {
return ReportAvailable
}
if errors.Is(err, ErrRDAPRateLimited) || errors.Is(err, ErrOriginASNRateLimited) {
return ReportRateLimited
}
if errors.Is(err, context.DeadlineExceeded) || netErrTimeout(err) {
return ReportTimeout
}
return ReportError
}
func reportStatusFromRelationship(status RelationshipStatus) ReportStatus {
switch status {
case RelationshipAvailable:
return ReportAvailable
case RelationshipPartial:
return ReportPartial
case RelationshipRateLimited:
return ReportRateLimited
case RelationshipTimeout:
return ReportTimeout
case RelationshipMissingFields:
return ReportMissingFields
case RelationshipUnsupported:
return ReportUnsupported
default:
return ReportError
}
}
func aggregateReportStatus(sources []IPBGPSourceStatus) ReportStatus {
if len(sources) == 0 {
return ReportUnsupported
}
hasAvailable := false
var first ReportStatus
for _, source := range sources {
if source.Status == ReportAvailable {
hasAvailable = true
continue
}
if first == "" {
first = source.Status
}
}
if first == "" {
return ReportAvailable
}
if hasAvailable {
return ReportPartial
}
for _, source := range sources {
if source.Status != first {
return ReportPartial
}
}
return first
}