From dd4fdcdaeac39f39827b35c28f9064ce6829c480 Mon Sep 17 00:00:00 2001 From: spiritlhl <103393591+spiritLHLS@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:41:07 +0800 Subject: [PATCH] feat: add RDAP WHOIS and network relationship diagnostics --- .github/workflows/ci.yaml | 2 +- back/generate.go | 74 ++++- back/generate_test.go | 57 ++++ bgptools/origin.go | 126 +++++++ bgptools/origin_test.go | 58 ++++ bgptools/rdap.go | 208 ++++++++++++ bgptools/rdap_test.go | 48 +++ bgptools/relationships.go | 480 +++++++++++++++++++++++++++ bgptools/relationships_test.go | 182 ++++++++++ bgptools/report.go | 584 +++++++++++++++++++++++++++++++++ bgptools/report_test.go | 252 ++++++++++++++ bgptools/whois_bootstrap.go | 125 +++++++ model/model.go | 2 +- 13 files changed, 2180 insertions(+), 18 deletions(-) create mode 100644 back/generate_test.go create mode 100644 bgptools/origin.go create mode 100644 bgptools/origin_test.go create mode 100644 bgptools/rdap.go create mode 100644 bgptools/rdap_test.go create mode 100644 bgptools/relationships.go create mode 100644 bgptools/relationships_test.go create mode 100644 bgptools/report.go create mode 100644 bgptools/report_test.go create mode 100644 bgptools/whois_bootstrap.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c941e37..5ad02cc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: run: | git config --global user.name 'github-actions' git config --global user.email 'github-actions@github.com' - TAG="v0.0.10-$(date +'%Y%m%d%H%M%S')" + TAG="v0.0.11-$(date +'%Y%m%d%H%M%S')" git tag $TAG git push origin $TAG echo "TAG=$TAG" >> $GITHUB_ENV diff --git a/back/generate.go b/back/generate.go index 9383b97..0d6b6ed 100644 --- a/back/generate.go +++ b/back/generate.go @@ -1,30 +1,72 @@ package backtrace import ( + "errors" + "fmt" "net" ) -// GeneratePrefixList 生成指定前缀的IP地址列表 +const defaultPrefixListLimit = 1 << 16 + +var ( + ErrInvalidPrefix = errors.New("invalid IPv4 prefix") + ErrIPv6PrefixUnsupported = errors.New("IPv6 prefix generation is unsupported") + ErrPrefixListTooLarge = errors.New("prefix list exceeds limit") +) + +// GeneratePrefixList generates the legacy list of /24 address prefixes. The +// old API cannot return an error, so invalid, IPv6, and over-sized inputs +// return nil. Callers that need to distinguish these cases should use +// GeneratePrefixListWithLimit. func GeneratePrefixList(prefix string) []string { - // 解析CIDR表示法的IP地址 - _, ipNet, err := net.ParseCIDR(prefix) + result, err := GeneratePrefixListWithLimit(prefix, defaultPrefixListLimit) if err != nil { return nil } - // 获取IP地址的32位整数表示 - ip := ipNet.IP.To4() - start := binaryIPToInt(ip) - maskSize, _ := ipNet.Mask.Size() - end := start | (1<<(32-maskSize) - 1) - // 生成IP地址列表 - var prefixList []string - for i := start; i <= end; i++ { - if (i-start)%256 == 0 { - tempText := intToBinaryIP(i).String() - prefixList = append(prefixList, tempText[:len(tempText)-2]) - } + return result +} + +// GeneratePrefixListWithLimit generates one entry for every /24-sized block +// covered by an IPv4 CIDR. Prefixes narrower than /24 are rejected once the +// number of blocks exceeds maxEntries; this keeps /0 and similarly broad +// input from allocating or iterating unbounded data. A prefix longer than +// /24 still produces the containing block, preserving the legacy behaviour. +func GeneratePrefixListWithLimit(prefix string, maxEntries int) ([]string, error) { + if maxEntries <= 0 { + maxEntries = defaultPrefixListLimit } - return prefixList + _, ipNet, err := net.ParseCIDR(prefix) + if err != nil { + return nil, fmt.Errorf("%w: %q", ErrInvalidPrefix, prefix) + } + maskSize, bits := ipNet.Mask.Size() + if bits == 128 { + return nil, fmt.Errorf("%w: %q", ErrIPv6PrefixUnsupported, prefix) + } + if bits != 32 || maskSize < 0 || maskSize > 32 || ipNet.IP.To4() == nil { + return nil, fmt.Errorf("%w: %q", ErrInvalidPrefix, prefix) + } + + // A /24 block is the output unit used by the original implementation. A + // /25-/32 prefix therefore has one containing block, while a /0 would need + // 2^24 entries and is rejected before allocation. + blocks := uint64(1) + if maskSize < 24 { + blocks = uint64(1) << uint(24-maskSize) + } + if blocks > uint64(maxEntries) { + return nil, fmt.Errorf("%w: %d entries (limit %d)", ErrPrefixListTooLarge, blocks, maxEntries) + } + + start := binaryIPToInt(ipNet.IP.To4()) &^ uint32(255) + result := make([]string, 0, int(blocks)) + for index := uint64(0); index < blocks; index++ { + // The arithmetic is bounded by 2^24 blocks, so this multiplication and + // addition cannot overflow uint32 for a valid IPv4 network. + value := start + uint32(index*256) + result = append(result, fmt.Sprintf("%d.%d.%d", byte(value>>24), byte(value>>16), byte(value>>8))) + } + return result, nil } // 将IP地址转换为32位整数 diff --git a/back/generate_test.go b/back/generate_test.go new file mode 100644 index 0000000..7bcaaea --- /dev/null +++ b/back/generate_test.go @@ -0,0 +1,57 @@ +package backtrace + +import ( + "errors" + "reflect" + "testing" +) + +func TestGeneratePrefixListCompatibility(t *testing.T) { + got := GeneratePrefixList("192.0.2.0/23") + want := []string{"192.0.2", "192.0.3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("GeneratePrefixList() = %v, want %v", got, want) + } + + got = GeneratePrefixList("192.0.2.128/25") + want = []string{"192.0.2"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("GeneratePrefixList(/25) = %v, want %v", got, want) + } +} + +func TestGeneratePrefixListRejectsUnsafeInputs(t *testing.T) { + tests := []struct { + name string + prefix string + want error + }{ + {name: "empty", prefix: "", want: ErrInvalidPrefix}, + {name: "invalid", prefix: "not-a-prefix", want: ErrInvalidPrefix}, + {name: "IPv6", prefix: "2001:db8::/32", want: ErrIPv6PrefixUnsupported}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := GeneratePrefixList(test.prefix); got != nil { + t.Fatalf("legacy API returned %v, want nil", got) + } + if _, err := GeneratePrefixListWithLimit(test.prefix, 16); !errors.Is(err, test.want) { + t.Fatalf("GeneratePrefixListWithLimit() error = %v, want %v", err, test.want) + } + }) + } +} + +func TestGeneratePrefixListLimitsBroadPrefixesBeforeAllocation(t *testing.T) { + if _, err := GeneratePrefixListWithLimit("0.0.0.0/0", 1024); !errors.Is(err, ErrPrefixListTooLarge) { + t.Fatalf("GeneratePrefixListWithLimit(/0) error = %v, want %v", err, ErrPrefixListTooLarge) + } + + got, err := GeneratePrefixListWithLimit("10.0.0.0/16", 256) + if err != nil { + t.Fatal(err) + } + if len(got) != 256 || got[0] != "10.0.0" || got[255] != "10.0.255" { + t.Fatalf("unexpected bounded result: len=%d first=%q last=%q", len(got), got[0], got[len(got)-1]) + } +} diff --git a/bgptools/origin.go b/bgptools/origin.go new file mode 100644 index 0000000..2bdbcb9 --- /dev/null +++ b/bgptools/origin.go @@ -0,0 +1,126 @@ +package bgptools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" +) + +const defaultOriginASNURL = "https://stat.ripe.net/data/network-info/data.json" + +var ( + ErrOriginASNMissing = errors.New("origin ASN response has no usable ASN") + ErrOriginASNRateLimited = errors.New("origin ASN resolver rate limited") +) + +type OriginASNConfig struct { + Client *http.Client + BaseURL string + Timeout time.Duration + MaxResponseSize int64 +} + +func ResolveOriginASN(ctx context.Context, ip string) (string, error) { + return ResolveOriginASNWithConfig(ctx, ip, OriginASNConfig{}) +} + +func ResolveOriginASNWithConfig(parent context.Context, ip string, config OriginASNConfig) (string, error) { + parsed := net.ParseIP(strings.TrimSpace(ip)) + if parsed == nil { + return "", ErrInvalidIPAddress + } + if parent == nil { + parent = context.Background() + } + if config.Timeout <= 0 { + config.Timeout = 8 * time.Second + } + if config.MaxResponseSize <= 0 { + config.MaxResponseSize = 1 << 20 + } + if config.Client == nil { + config.Client = &http.Client{} + } + if strings.TrimSpace(config.BaseURL) == "" { + config.BaseURL = defaultOriginASNURL + } + requestURL, err := addQuery(config.BaseURL, "resource", parsed.String()) + if err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(parent, config.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "oneclickvirt-backtrace-origin-asn/1") + response, err := config.Client.Do(req) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode == http.StatusTooManyRequests { + return "", ErrOriginASNRateLimited + } + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("origin ASN HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, config.MaxResponseSize+1)) + if err != nil { + return "", err + } + if int64(len(body)) > config.MaxResponseSize { + return "", fmt.Errorf("origin ASN response exceeds %d bytes", config.MaxResponseSize) + } + var payload struct { + Status string `json:"status"` + Data *struct { + ASNs []json.RawMessage `json:"asns"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", err + } + if status := strings.TrimSpace(payload.Status); status != "" && !strings.EqualFold(status, "ok") { + return "", fmt.Errorf("origin ASN response status %q", status) + } + if payload.Data == nil || len(payload.Data.ASNs) == 0 { + return "", ErrOriginASNMissing + } + for _, raw := range payload.Data.ASNs { + value, err := originASNValue(raw) + if err != nil { + continue + } + if normalized, err := normalizeASN(value); err == nil { + return normalized, nil + } + } + return "", ErrOriginASNMissing +} + +func originASNValue(raw json.RawMessage) (string, error) { + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return text, nil + } + var number json.Number + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + if err := decoder.Decode(&number); err != nil { + return "", err + } + if _, err := strconv.ParseUint(number.String(), 10, 32); err != nil { + return "", err + } + return number.String(), nil +} diff --git a/bgptools/origin_test.go b/bgptools/origin_test.go new file mode 100644 index 0000000..2b4ed6b --- /dev/null +++ b/bgptools/origin_test.go @@ -0,0 +1,58 @@ +package bgptools + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestResolveOriginASNOfflineFixture(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("resource"); got != "192.0.2.1" { + t.Errorf("resource = %q", got) + } + _, _ = w.Write([]byte(`{"status":"ok","data":{"asns":["AS64500",64501],"prefix":"192.0.2.0/24"}}`)) + })) + defer server.Close() + + asn, err := ResolveOriginASNWithConfig(context.Background(), "192.0.2.1", OriginASNConfig{ + Client: server.Client(), BaseURL: server.URL, Timeout: time.Second, + }) + if err != nil || asn != "64500" { + t.Fatalf("ResolveOriginASNWithConfig() = %q, %v", asn, err) + } +} + +func TestResolveOriginASNRejectsMissingAndDriftedData(t *testing.T) { + for _, payload := range []string{ + `{"status":"ok","data":{"asns":[]}}`, + `{"status":"ok","data":{"asns":"64500"}}`, + `{"status":"ok","data":{"asns":["not-an-asn"]}}`, + `{"status":"error","data":{"asns":[64500]}}`, + } { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(payload)) + })) + _, err := ResolveOriginASNWithConfig(context.Background(), "2001:db8::1", OriginASNConfig{ + Client: server.Client(), BaseURL: server.URL, Timeout: time.Second, + }) + server.Close() + if err == nil { + t.Fatalf("payload %s unexpectedly succeeded", payload) + } + } +} + +func TestResolveOriginASNReportsRateLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + _, err := ResolveOriginASNWithConfig(context.Background(), "192.0.2.1", OriginASNConfig{Client: server.Client(), BaseURL: server.URL}) + if !errors.Is(err, ErrOriginASNRateLimited) { + t.Fatalf("rate limit error = %v", err) + } +} diff --git a/bgptools/rdap.go b/bgptools/rdap.go new file mode 100644 index 0000000..832d13d --- /dev/null +++ b/bgptools/rdap.go @@ -0,0 +1,208 @@ +package bgptools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "sort" + "strings" + "time" +) + +var ( + ErrInvalidIPAddress = errors.New("invalid IP address") + ErrRDAPRateLimited = errors.New("rdap rate limited") + ErrRDAPResponseTooLarge = errors.New("rdap response too large") +) + +type RDAPEntity struct { + Handle string `json:"handle,omitempty"` + Roles []string `json:"roles,omitempty"` +} + +type RDAPRecord struct { + IP string `json:"ip"` + Handle string `json:"handle,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Country string `json:"country,omitempty"` + StartAddress string `json:"start_address,omitempty"` + EndAddress string `json:"end_address,omitempty"` + ParentHandle string `json:"parent_handle,omitempty"` + Prefixes []string `json:"prefixes,omitempty"` + RegistrationDate *time.Time `json:"registration_date,omitempty"` + LastChangedDate *time.Time `json:"last_changed_date,omitempty"` + GeofeedURLs []string `json:"geofeed_urls,omitempty"` + Entities []RDAPEntity `json:"entities,omitempty"` + Port43 string `json:"port43,omitempty"` + Status []string `json:"status,omitempty"` + Source string `json:"source"` +} + +func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL string) (RDAPRecord, error) { + parsed := net.ParseIP(strings.TrimSpace(ip)) + if parsed == nil { + return RDAPRecord{}, ErrInvalidIPAddress + } + if ctx == nil { + ctx = context.Background() + } + if client == nil { + client = &http.Client{Timeout: 8 * time.Second} + } + if strings.TrimSpace(baseURL) == "" { + baseURL = "https://rdap.org/ip/" + } + requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return RDAPRecord{}, err + } + req.Header.Set("Accept", "application/rdap+json, application/json") + req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1") + response, err := client.Do(req) + if err != nil { + return RDAPRecord{}, err + } + defer response.Body.Close() + if response.StatusCode == http.StatusTooManyRequests { + return RDAPRecord{}, ErrRDAPRateLimited + } + if response.StatusCode != http.StatusOK { + return RDAPRecord{}, fmt.Errorf("rdap HTTP %d", response.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1)) + if err != nil { + return RDAPRecord{}, err + } + if len(data) > 4<<20 { + return RDAPRecord{}, ErrRDAPResponseTooLarge + } + source := requestURL + if response.Request != nil && response.Request.URL != nil { + source = response.Request.URL.String() + } + return parseRDAPRecord(parsed.String(), source, data) +} + +func parseRDAPRecord(ip, source string, data []byte) (RDAPRecord, error) { + var payload struct { + Handle string `json:"handle"` + Name string `json:"name"` + Type string `json:"type"` + Country string `json:"country"` + StartAddress string `json:"startAddress"` + EndAddress string `json:"endAddress"` + ParentHandle string `json:"parentHandle"` + Port43 string `json:"port43"` + Status []string `json:"status"` + Events []struct { + Action string `json:"eventAction"` + Date time.Time `json:"eventDate"` + } `json:"events"` + Links []struct { + Rel string `json:"rel"` + Href string `json:"href"` + Type string `json:"type"` + } `json:"links"` + Entities []struct { + Handle string `json:"handle"` + Roles []string `json:"roles"` + } `json:"entities"` + CIDRs []struct { + V4Prefix string `json:"v4prefix"` + V6Prefix string `json:"v6prefix"` + Length *int `json:"length"` + } `json:"cidr0_cidrs"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return RDAPRecord{}, err + } + record := RDAPRecord{ + IP: ip, Handle: payload.Handle, Name: payload.Name, Type: payload.Type, + Country: payload.Country, StartAddress: payload.StartAddress, EndAddress: payload.EndAddress, + ParentHandle: payload.ParentHandle, Port43: payload.Port43, Status: payload.Status, Source: source, + } + for _, event := range payload.Events { + switch strings.ToLower(event.Action) { + case "registration": + if !event.Date.IsZero() && (record.RegistrationDate == nil || event.Date.Before(*record.RegistrationDate)) { + value := event.Date + record.RegistrationDate = &value + } + case "last changed", "last update of rdap database": + if !event.Date.IsZero() && (record.LastChangedDate == nil || event.Date.After(*record.LastChangedDate)) { + value := event.Date + record.LastChangedDate = &value + } + } + } + for _, link := range payload.Links { + if strings.EqualFold(link.Rel, "geofeed") || strings.Contains(strings.ToLower(link.Type), "geofeed") { + if href := strings.TrimSpace(link.Href); href != "" { + record.GeofeedURLs = append(record.GeofeedURLs, href) + } + } + } + for _, entity := range payload.Entities { + record.Entities = append(record.Entities, RDAPEntity{Handle: entity.Handle, Roles: entity.Roles}) + } + for _, cidr := range payload.CIDRs { + prefix := cidr.V4Prefix + if prefix == "" { + prefix = cidr.V6Prefix + } + if cidr.Length == nil { + continue + } + if normalized, ok := normalizeRDAPPrefix(ip, prefix, *cidr.Length); ok { + record.Prefixes = append(record.Prefixes, normalized) + } + } + record.Prefixes = uniqueSortedStrings(record.Prefixes) + record.GeofeedURLs = uniqueSortedStrings(record.GeofeedURLs) + return record, nil +} + +func normalizeRDAPPrefix(targetIP, prefix string, length int) (string, bool) { + target := net.ParseIP(strings.TrimSpace(targetIP)) + parsed := net.ParseIP(strings.TrimSpace(prefix)) + if target == nil || parsed == nil || (target.To4() == nil) != (parsed.To4() == nil) { + return "", false + } + bits := 128 + if parsed.To4() != nil { + bits = 32 + } + if length < 0 || length > bits { + return "", false + } + _, network, err := net.ParseCIDR(fmt.Sprintf("%s/%d", parsed.String(), length)) + if err != nil { + return "", false + } + if !network.Contains(target) { + return "", false + } + return network.String(), true +} + +func uniqueSortedStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + seen[value] = struct{}{} + } + } + result := make([]string, 0, len(seen)) + for value := range seen { + result = append(result, value) + } + sort.Strings(result) + return result +} diff --git a/bgptools/rdap_test.go b/bgptools/rdap_test.go new file mode 100644 index 0000000..aee6c70 --- /dev/null +++ b/bgptools/rdap_test.go @@ -0,0 +1,48 @@ +package bgptools + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestQueryRDAPParsesRegistrationAndGeofeed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rdap+json") + _, _ = w.Write([]byte(`{"handle":"NET-TEST","name":"TEST-NET","country":"US","startAddress":"192.0.2.0","endAddress":"192.0.2.255","cidr0_cidrs":[{"v4prefix":"192.0.2.0","length":24}],"events":[{"eventAction":"registration","eventDate":"2020-01-02T03:04:05Z"}],"links":[{"rel":"geofeed","href":"https://example.test/geofeed.csv"}],"entities":[{"handle":"EXAMPLE","roles":["registrant"]}]}`)) + })) + defer server.Close() + record, err := QueryRDAP(context.Background(), "192.0.2.1", server.Client(), server.URL) + if err != nil { + t.Fatal(err) + } + if record.Handle != "NET-TEST" || record.RegistrationDate == nil || len(record.GeofeedURLs) != 1 || len(record.Entities) != 1 || len(record.Prefixes) != 1 || record.Prefixes[0] != "192.0.2.0/24" { + t.Fatalf("unexpected record: %+v", record) + } +} + +func TestQueryRDAPRejectsInvalidIP(t *testing.T) { + if _, err := QueryRDAP(context.Background(), "invalid", nil, ""); err == nil { + t.Fatal("expected invalid IP error") + } +} + +func TestQueryRDAPCanonicalizesAndFiltersPrefixes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := strings.TrimPrefix(r.URL.Path, "/"); got != "2001:db8::1" { + t.Errorf("RDAP path IP = %q", got) + } + _, _ = w.Write([]byte(`{"port43":"whois.ripe.net","cidr0_cidrs":[{"v6prefix":"2001:db8::99","length":32},{"v6prefix":"2001:db8::"},{"v6prefix":"2001:db9::","length":32},{"v6prefix":"not-an-ip","length":32},{"v6prefix":"2001:db8::","length":129},{"v4prefix":"192.0.2.0","length":24}],"events":[{"eventAction":"registration"}]}`)) + })) + defer server.Close() + + record, err := QueryRDAP(nil, "2001:db8::1", server.Client(), server.URL) + if err != nil { + t.Fatal(err) + } + if len(record.Prefixes) != 1 || record.Prefixes[0] != "2001:db8::/32" { + t.Fatalf("unexpected filtered prefixes: %+v", record.Prefixes) + } +} diff --git a/bgptools/relationships.go b/bgptools/relationships.go new file mode 100644 index 0000000..9f96612 --- /dev/null +++ b/bgptools/relationships.go @@ -0,0 +1,480 @@ +package bgptools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// RelationshipKind is the relationship of a neighbouring network to the +// target ASN. RIPEstat does not always publish a direction, in which case a +// neighbour is reported as peer with Inferred=true instead of pretending that +// the direction is known. +type RelationshipKind string + +const ( + RelationshipUpstream RelationshipKind = "upstream" + RelationshipPeer RelationshipKind = "peer" + RelationshipIXP RelationshipKind = "ixp" +) + +// RelationshipStatus is deliberately small and stable so callers can render +// a partial report without parsing provider-specific error strings. +type RelationshipStatus string + +const ( + RelationshipAvailable RelationshipStatus = "available" + RelationshipPartial RelationshipStatus = "partial" + RelationshipRateLimited RelationshipStatus = "rate_limited" + RelationshipTimeout RelationshipStatus = "timeout" + RelationshipMissingFields RelationshipStatus = "missing_fields" + RelationshipError RelationshipStatus = "error" + RelationshipUnsupported RelationshipStatus = "unsupported" +) + +var errRelationshipMissingFields = errors.New("relationship response missing required fields") + +// ASNRelationship is a normalized relationship entry. ASN is empty for an +// IXP that has no ASN in PeeringDB; IXPID and Name remain usable identifiers. +type ASNRelationship struct { + ASN string `json:"asn,omitempty"` + Name string `json:"name,omitempty"` + Kind RelationshipKind `json:"kind"` + Source string `json:"source"` + Status RelationshipStatus `json:"status"` + Power float64 `json:"power,omitempty"` + IXPID string `json:"ixp_id,omitempty"` + Inferred bool `json:"inferred,omitempty"` + RateLimited bool `json:"rate_limited,omitempty"` + Timeout bool `json:"timeout,omitempty"` +} + +// RelationshipSourceStatus records the result of each upstream API. A +// report may be partial when one API is unavailable and the other succeeds. +type RelationshipSourceStatus struct { + Source string `json:"source"` + Status RelationshipStatus `json:"status"` + RateLimited bool `json:"rate_limited"` + Timeout bool `json:"timeout"` + Error string `json:"error,omitempty"` +} + +// ASNRelationshipReport contains normalized upstream, peer and IXP entries. +// SourceStatuses allows callers to distinguish a clean empty result from a +// provider timeout or rate limit. +type ASNRelationshipReport struct { + TargetASN string `json:"target_asn"` + Status RelationshipStatus `json:"status"` + RateLimited bool `json:"rate_limited"` + Timeout bool `json:"timeout"` + Upstreams []ASNRelationship `json:"upstreams,omitempty"` + Peers []ASNRelationship `json:"peers,omitempty"` + IXPs []ASNRelationship `json:"ixps,omitempty"` + SourceStatuses []RelationshipSourceStatus `json:"sources,omitempty"` +} + +// RelationshipConfig controls the public API endpoints. URLs are explicit +// Go configuration so callers can use local fixtures without environment +// variables. Empty URLs select the documented public endpoints. +type RelationshipConfig struct { + Client *http.Client + RIPEstatURL string + PeeringDBURL string + Timeout time.Duration + MaxResponseSize int64 +} + +const ( + defaultRIPEstatURL = "https://stat.ripe.net/data/asn-neighbours/data.json" + defaultPeeringDBURL = "https://www.peeringdb.com/api/netixlan" +) + +// QueryASNRelationships fetches relationship data from RIPEstat and IXP +// membership data from PeeringDB. It returns a report even when one source +// fails; only an invalid ASN or caller cancellation is returned as an error. +func QueryASNRelationships(ctx context.Context, asn string, cfg RelationshipConfig) (*ASNRelationshipReport, error) { + normalized, err := normalizeASN(asn) + if err != nil { + return nil, err + } + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, err + } + if cfg.Timeout <= 0 { + cfg.Timeout = 8 * time.Second + } + if cfg.MaxResponseSize <= 0 { + cfg.MaxResponseSize = 4 << 20 + } + if cfg.Client == nil { + cfg.Client = &http.Client{} + } + if strings.TrimSpace(cfg.RIPEstatURL) == "" { + cfg.RIPEstatURL = defaultRIPEstatURL + } + if strings.TrimSpace(cfg.PeeringDBURL) == "" { + cfg.PeeringDBURL = defaultPeeringDBURL + } + + report := &ASNRelationshipReport{TargetASN: normalized} + ripeURL, err := addQuery(cfg.RIPEstatURL, "resource", normalized) + if err != nil { + return nil, err + } + ripeRaw, ripeStatus, ripeErr := fetchRelationshipJSON(ctx, cfg, "ripestat", ripeURL) + report.SourceStatuses = append(report.SourceStatuses, sourceStatus("ripestat", ripeStatus, ripeErr)) + if ripeErr == nil { + entries, parseErr := parseRIPEstatRelationships(ripeRaw, normalized) + if parseErr != nil { + ripeStatus = statusForParseError(parseErr) + report.SourceStatuses[len(report.SourceStatuses)-1] = sourceStatus("ripestat", ripeStatus, parseErr) + } else { + for _, entry := range entries { + if entry.Kind == RelationshipUpstream { + report.Upstreams = append(report.Upstreams, entry) + } else { + report.Peers = append(report.Peers, entry) + } + } + } + } + + peeringURL, err := addQuery(cfg.PeeringDBURL, "asn", normalized) + if err != nil { + return nil, err + } + peeringRaw, peeringStatus, peeringErr := fetchRelationshipJSON(ctx, cfg, "peeringdb", peeringURL) + report.SourceStatuses = append(report.SourceStatuses, sourceStatus("peeringdb", peeringStatus, peeringErr)) + if peeringErr == nil { + entries, parseErr := parsePeeringDBIXPs(peeringRaw) + if parseErr != nil { + peeringStatus = statusForParseError(parseErr) + report.SourceStatuses[len(report.SourceStatuses)-1] = sourceStatus("peeringdb", peeringStatus, parseErr) + } else { + report.IXPs = append(report.IXPs, entries...) + } + } + + sortRelationships(report) + report.Status = aggregateRelationshipStatus(report.SourceStatuses) + for _, source := range report.SourceStatuses { + report.RateLimited = report.RateLimited || source.RateLimited + report.Timeout = report.Timeout || source.Timeout + } + if err := ctx.Err(); err != nil { + return report, err + } + return report, nil +} + +// QueryASNRelationshipReport is a descriptive alias for QueryASNRelationships. +func QueryASNRelationshipReport(ctx context.Context, asn string, cfg RelationshipConfig) (*ASNRelationshipReport, error) { + return QueryASNRelationships(ctx, asn, cfg) +} + +func normalizeASN(value string) (string, error) { + value = strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(value)), "AS") + if value == "" { + return "", errors.New("ASN cannot be empty") + } + n, err := strconv.ParseUint(value, 10, 32) + if err != nil || n == 0 { + return "", fmt.Errorf("invalid ASN %q", value) + } + return strconv.FormatUint(n, 10), nil +} + +func addQuery(rawURL, key, value string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + query := u.Query() + query.Set(key, value) + u.RawQuery = query.Encode() + return u.String(), nil +} + +func fetchRelationshipJSON(parent context.Context, cfg RelationshipConfig, source, requestURL string) ([]byte, RelationshipStatus, error) { + ctx, cancel := context.WithTimeout(parent, cfg.Timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, RelationshipError, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "oneclickvirt-backtrace-relationships/1") + resp, err := cfg.Client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || (netErrTimeout(err)) { + return nil, RelationshipTimeout, err + } + return nil, RelationshipError, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, RelationshipRateLimited, fmt.Errorf("%s HTTP %d", source, resp.StatusCode) + } + if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout { + return nil, RelationshipTimeout, fmt.Errorf("%s HTTP %d", source, resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return nil, RelationshipError, fmt.Errorf("%s HTTP %d", source, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, cfg.MaxResponseSize+1)) + if err != nil { + return nil, RelationshipError, err + } + if int64(len(body)) > cfg.MaxResponseSize { + return nil, RelationshipError, fmt.Errorf("%s response exceeds %d bytes", source, cfg.MaxResponseSize) + } + return body, RelationshipAvailable, nil +} + +func netErrTimeout(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +func sourceStatus(source string, status RelationshipStatus, err error) RelationshipSourceStatus { + item := RelationshipSourceStatus{ + Source: source, Status: status, + RateLimited: status == RelationshipRateLimited, + Timeout: status == RelationshipTimeout, + } + if err != nil { + item.Error = err.Error() + } + return item +} + +func statusForParseError(err error) RelationshipStatus { + if errors.Is(err, errRelationshipMissingFields) { + return RelationshipMissingFields + } + return RelationshipError +} + +func aggregateRelationshipStatus(sources []RelationshipSourceStatus) RelationshipStatus { + available := 0 + for _, source := range sources { + if source.Status == RelationshipAvailable { + available++ + } + } + if available == len(sources) { + return RelationshipAvailable + } + if available > 0 { + return RelationshipPartial + } + for _, source := range sources { + if source.Status == RelationshipRateLimited { + return RelationshipRateLimited + } + } + for _, source := range sources { + if source.Status == RelationshipTimeout { + return RelationshipTimeout + } + } + for _, source := range sources { + if source.Status == RelationshipMissingFields { + return RelationshipMissingFields + } + } + return RelationshipError +} + +type ripeNeighbourResponse struct { + Data *struct { + Neighbours json.RawMessage `json:"neighbours"` + } `json:"data"` +} + +type ripeNeighbour struct { + ASN json.RawMessage `json:"asn"` + Name string `json:"name"` + Power float64 `json:"power"` + Type string `json:"type"` + Relationship string `json:"relationship"` + Direction string `json:"direction"` +} + +func parseRIPEstatRelationships(payload []byte, targetASN string) ([]ASNRelationship, error) { + var response ripeNeighbourResponse + if err := json.Unmarshal(payload, &response); err != nil { + return nil, err + } + if response.Data == nil || len(response.Data.Neighbours) == 0 || string(response.Data.Neighbours) == "null" { + return nil, errRelationshipMissingFields + } + var neighbours []ripeNeighbour + if err := json.Unmarshal(response.Data.Neighbours, &neighbours); err != nil { + return nil, errRelationshipMissingFields + } + var result []ASNRelationship + for _, neighbour := range neighbours { + asn, err := parseJSONASN(neighbour.ASN) + if err != nil || asn == targetASN { + continue + } + kind, inferred := relationshipKind(neighbour.Relationship, neighbour.Type, neighbour.Direction) + result = append(result, ASNRelationship{ + ASN: asn, Name: strings.TrimSpace(neighbour.Name), Kind: kind, + Source: "ripestat", Status: RelationshipAvailable, + Power: neighbour.Power, Inferred: inferred, + }) + } + return dedupeRelationships(result), nil +} + +func relationshipKind(values ...string) (RelationshipKind, bool) { + for _, value := range values { + switch strings.ToLower(strings.TrimSpace(value)) { + case "upstream", "provider", "transit": + return RelationshipUpstream, false + case "peer", "peering", "customer", "downstream": + return RelationshipPeer, false + case "ixp", "internet exchange", "exchange": + return RelationshipIXP, false + } + } + return RelationshipPeer, true +} + +func parseJSONASN(raw json.RawMessage) (string, error) { + if len(raw) == 0 || string(raw) == "null" { + return "", errors.New("missing ASN") + } + var value string + if raw[0] == '"' { + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + } else { + var number json.Number + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + if err := decoder.Decode(&number); err != nil { + return "", err + } + value = number.String() + } + return normalizeASN(value) +} + +func dedupeRelationships(values []ASNRelationship) []ASNRelationship { + seen := make(map[string]ASNRelationship, len(values)) + for _, value := range values { + key := string(value.Kind) + ":" + value.ASN + ":" + value.IXPID + if previous, ok := seen[key]; !ok || previous.Name == "" && value.Name != "" { + seen[key] = value + } + } + result := make([]ASNRelationship, 0, len(seen)) + for _, value := range seen { + result = append(result, value) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Kind != result[j].Kind { + return result[i].Kind < result[j].Kind + } + if result[i].ASN != result[j].ASN { + return result[i].ASN < result[j].ASN + } + return result[i].IXPID < result[j].IXPID + }) + return result +} + +type peeringDBResponse struct { + Data json.RawMessage `json:"data"` +} + +func parsePeeringDBIXPs(payload []byte) ([]ASNRelationship, error) { + var response peeringDBResponse + if err := json.Unmarshal(payload, &response); err != nil { + return nil, err + } + if len(response.Data) == 0 || string(response.Data) == "null" { + return nil, errRelationshipMissingFields + } + var records []map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &records); err != nil { + return nil, errRelationshipMissingFields + } + result := make([]ASNRelationship, 0, len(records)) + for _, record := range records { + id := firstString(record, "ix_id", "ixlan_id", "id") + name := firstString(record, "name", "ix_name") + if nested, ok := record["ixlan"]; ok { + var object map[string]json.RawMessage + if json.Unmarshal(nested, &object) == nil { + if id == "" { + id = firstString(object, "ix_id", "id") + } + if name == "" { + name = firstString(object, "name", "name_long") + } + } + } + if nested, ok := record["ix"]; ok { + var object map[string]json.RawMessage + if json.Unmarshal(nested, &object) == nil { + if id == "" { + id = firstString(object, "id", "ix_id") + } + if name == "" { + name = firstString(object, "name", "name_long") + } + } + } + if id == "" && name == "" { + continue + } + result = append(result, ASNRelationship{ + Name: name, Kind: RelationshipIXP, Source: "peeringdb", + Status: RelationshipAvailable, IXPID: id, + }) + } + return dedupeRelationships(result), nil +} + +func firstString(values map[string]json.RawMessage, keys ...string) string { + for _, key := range keys { + raw, ok := values[key] + if !ok { + continue + } + var value string + if json.Unmarshal(raw, &value) == nil && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + if number, err := parseJSONASN(raw); err == nil { + return number + } + } + return "" +} + +func sortRelationships(report *ASNRelationshipReport) { + report.Upstreams = dedupeRelationships(report.Upstreams) + report.Peers = dedupeRelationships(report.Peers) + report.IXPs = dedupeRelationships(report.IXPs) + sort.Slice(report.SourceStatuses, func(i, j int) bool { + return report.SourceStatuses[i].Source < report.SourceStatuses[j].Source + }) +} diff --git a/bgptools/relationships_test.go b/bgptools/relationships_test.go new file mode 100644 index 0000000..c7a426f --- /dev/null +++ b/bgptools/relationships_test.go @@ -0,0 +1,182 @@ +package bgptools + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestQueryASNRelationshipsFixture(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/ripe": + if got := r.URL.Query().Get("resource"); got != "64500" { + t.Errorf("resource query = %q", got) + } + _, _ = w.Write([]byte(`{"data":{"neighbours":[{"asn":64501,"name":"Transit Example","relationship":"upstream","power":12.5},{"asn":"64502","name":"Peer Example","type":"peer","power":3.25},{"asn":64500,"name":"target"}]}}`)) + case "/peering": + if got := r.URL.Query().Get("asn"); got != "64500" { + t.Errorf("asn query = %q", got) + } + _, _ = w.Write([]byte(`{"data":[{"id":101,"ix_id":7,"name":"Example IX"},{"ixlan":{"id":202,"name":"Nested IX"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + report, err := QueryASNRelationships(context.Background(), "AS64500", RelationshipConfig{ + Client: server.Client(), + RIPEstatURL: server.URL + "/ripe", + PeeringDBURL: server.URL + "/peering", + Timeout: time.Second, + }) + if err != nil { + t.Fatal(err) + } + if report.TargetASN != "64500" || report.Status != RelationshipAvailable { + t.Fatalf("unexpected report status: %+v", report) + } + if len(report.Upstreams) != 1 || report.Upstreams[0].ASN != "64501" || report.Upstreams[0].Source != "ripestat" { + t.Fatalf("unexpected upstreams: %+v", report.Upstreams) + } + if len(report.Peers) != 1 || report.Peers[0].ASN != "64502" { + t.Fatalf("unexpected peers: %+v", report.Peers) + } + if len(report.IXPs) != 2 || report.IXPs[0].Kind != RelationshipIXP { + t.Fatalf("unexpected IXPs: %+v", report.IXPs) + } + for _, source := range report.SourceStatuses { + if source.Status != RelationshipAvailable || source.RateLimited || source.Timeout { + t.Fatalf("unexpected source status: %+v", source) + } + } +} + +func TestQueryASNRelationshipsRateLimitAndTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/limited": + w.WriteHeader(http.StatusTooManyRequests) + case "/slow": + <-r.Context().Done() + default: + _, _ = w.Write([]byte(`{"data":[]}`)) + } + })) + defer server.Close() + + limited, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{ + Client: server.Client(), + RIPEstatURL: server.URL + "/limited", + PeeringDBURL: server.URL + "/peering", + Timeout: time.Second, + }) + if err != nil { + t.Fatal(err) + } + if limited.Status != RelationshipPartial || !limited.RateLimited { + t.Fatalf("expected partial rate-limited report: %+v", limited) + } + if status := sourceStatusByName(limited, "ripestat"); status.Status != RelationshipRateLimited || !status.RateLimited || status.Timeout { + t.Fatalf("unexpected rate-limit source: %+v", status) + } + + timed, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{ + Client: server.Client(), + RIPEstatURL: server.URL + "/slow", + PeeringDBURL: server.URL + "/slow", + Timeout: 20 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if timed.Status != RelationshipTimeout || !timed.Timeout { + t.Fatalf("expected timeout report: %+v", timed) + } + for _, source := range timed.SourceStatuses { + if source.Status != RelationshipTimeout || !source.Timeout || source.RateLimited { + t.Fatalf("unexpected timeout source: %+v", source) + } + } +} + +func TestQueryASNRelationshipsFieldDrift(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/ripe" { + _, _ = w.Write([]byte(`{"data":{"neighbors":[]}}`)) + return + } + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer server.Close() + + report, err := QueryASNRelationships(context.Background(), "64500", RelationshipConfig{ + Client: server.Client(), + RIPEstatURL: server.URL + "/ripe", + PeeringDBURL: server.URL + "/peering", + Timeout: time.Second, + }) + if err != nil { + t.Fatal(err) + } + if report.Status != RelationshipMissingFields { + t.Fatalf("expected missing_fields report: %+v", report) + } + for _, source := range report.SourceStatuses { + if source.Status != RelationshipMissingFields { + t.Fatalf("expected missing_fields source, got %+v", source) + } + } +} + +func TestQueryASNRelationshipsContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + started := time.Now() + _, err := QueryASNRelationships(ctx, "64500", RelationshipConfig{ + Client: server.Client(), + RIPEstatURL: server.URL, + PeeringDBURL: server.URL, + Timeout: time.Second, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + if time.Since(started) > 200*time.Millisecond { + t.Fatalf("cancellation was not immediate: %v", time.Since(started)) + } +} + +func TestNormalizeASN(t *testing.T) { + for _, input := range []string{"AS64500", " as64500 ", "64500"} { + if got, err := normalizeASN(input); err != nil || got != "64500" { + t.Fatalf("normalizeASN(%q) = %q, %v", input, got, err) + } + } + for _, input := range []string{"", "AS0", "AS4294967296", "AS-not-an-asn"} { + if _, err := normalizeASN(input); err == nil { + t.Fatalf("normalizeASN(%q) unexpectedly succeeded", input) + } + } +} + +func sourceStatusByName(report *ASNRelationshipReport, name string) RelationshipSourceStatus { + for _, source := range report.SourceStatuses { + if strings.EqualFold(source.Source, name) { + return source + } + } + return RelationshipSourceStatus{Source: name, Status: RelationshipError} +} diff --git a/bgptools/report.go b/bgptools/report.go new file mode 100644 index 0000000..947f739 --- /dev/null +++ b/bgptools/report.go @@ -0,0 +1,584 @@ +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 +} diff --git a/bgptools/report_test.go b/bgptools/report_test.go new file mode 100644 index 0000000..dbe97fc --- /dev/null +++ b/bgptools/report_test.go @@ -0,0 +1,252 @@ +package bgptools + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestQueryIPBGPReportOfflineFixtures(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/rdap/"): + if got := strings.TrimPrefix(r.URL.Path, "/rdap/"); got != "192.0.2.1" { + t.Errorf("RDAP IP = %q", got) + } + w.Header().Set("Content-Type", "application/rdap+json") + _, _ = fmt.Fprintf(w, `{"handle":"NET-ARIN-TEST","port43":"whois.arin.net","cidr0_cidrs":[{"v4prefix":"192.0.2.42","length":24}],"events":[{"eventAction":"registration","eventDate":"2020-01-02T03:04:05Z"}],"links":[{"rel":"geofeed","href":%q}]}`, server.URL+"/geofeed.csv") + case r.URL.Path == "/geofeed.csv": + _, _ = io.WriteString(w, "192.0.2.0/24,US,US-CA,Example City,\n") + case r.URL.Path == "/ripe": + if got := r.URL.Query().Get("resource"); got != "64500" { + t.Errorf("resource query = %q", got) + } + _, _ = io.WriteString(w, `{"data":{"neighbours":[{"asn":64501,"name":"Transit","relationship":"upstream"},{"asn":64502,"name":"Peer","relationship":"peer"}]}}`) + case r.URL.Path == "/peering": + _, _ = io.WriteString(w, `{"data":[{"ix_id":7,"name":"Example IX"}]}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + report, err := QueryIPBGPReport(context.Background(), "192.0.2.1", IPBGPReportConfig{ + Timeout: time.Second, + RDAPClient: server.Client(), + RDAPBaseURL: server.URL + "/rdap", + FetchGeofeed: true, + GeofeedClient: server.Client(), + ResolveASN: func(_ context.Context, ip string) (string, error) { + if ip != "192.0.2.1" { + t.Fatalf("resolver IP = %q", ip) + } + return "AS64500", nil + }, + Relationships: RelationshipConfig{ + Client: server.Client(), + RIPEstatURL: server.URL + "/ripe", + PeeringDBURL: server.URL + "/peering", + Timeout: time.Second, + }, + }) + if err != nil { + t.Fatal(err) + } + if report.Status != ReportAvailable || report.IP != "192.0.2.1" || report.ASN != "64500" { + t.Fatalf("unexpected report identity/status: %+v", report) + } + if len(report.Prefixes) != 1 || report.Prefixes[0] != "192.0.2.0/24" || report.PrefixSource != "rdap" { + t.Fatalf("unexpected prefixes: %+v source=%q", report.Prefixes, report.PrefixSource) + } + if report.RIR.Name != "ARIN" || report.RIR.Status != ReportAvailable || report.RegistrationDate == nil { + t.Fatalf("unexpected registry fields: RIR=%+v registration=%v", report.RIR, report.RegistrationDate) + } + if len(report.Geofeeds) != 1 || report.Geofeeds[0].Status != ReportAvailable || report.Geofeeds[0].Bytes == 0 { + t.Fatalf("unexpected geofeed result: %+v", report.Geofeeds) + } + if report.Relationships == nil || len(report.Relationships.Upstreams) != 1 || len(report.Relationships.Peers) != 1 || len(report.Relationships.IXPs) != 1 { + t.Fatalf("unexpected relationships: %+v", report.Relationships) + } +} + +func TestQueryIPBGPReportWHOISFallbackTCPFixture(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + tcpErr := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + tcpErr <- err + return + } + defer conn.Close() + query, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + tcpErr <- err + return + } + if strings.TrimSpace(query) != "203.0.113.9" { + tcpErr <- fmt.Errorf("WHOIS query = %q", query) + return + } + _, err = io.WriteString(conn, "CIDR: 203.0.113.99/24\r\nRegDate: 2021-02-03\r\nSource: ARIN\r\nGeofeed: https://example.test/geofeed.csv\r\n") + tcpErr <- err + }() + + rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer rdapServer.Close() + + report, err := QueryIPBGPReport(context.Background(), "203.0.113.9", IPBGPReportConfig{ + Timeout: time.Second, + RDAPClient: rdapServer.Client(), + RDAPBaseURL: rdapServer.URL, + EnableWHOISFallback: true, + WHOISServer: listener.Addr().String(), + WHOISTimeout: 250 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if err := <-tcpErr; err != nil { + t.Fatal(err) + } + if report.Status != ReportPartial || report.WHOIS == nil || report.WHOIS.Status != ReportAvailable { + t.Fatalf("unexpected fallback status: %+v", report) + } + if report.PrefixSource != "whois" || len(report.Prefixes) != 1 || report.Prefixes[0] != "203.0.113.0/24" { + t.Fatalf("unexpected WHOIS prefixes: %+v", report.Prefixes) + } + if report.RIR.Name != "ARIN" || report.RIR.Source != "whois" || report.RegistrationDate == nil { + t.Fatalf("unexpected WHOIS metadata: RIR=%+v registration=%v", report.RIR, report.RegistrationDate) + } + if len(report.Geofeeds) != 1 || report.Geofeeds[0].Status != ReportUnsupported { + t.Fatalf("disabled geofeed fetch was not explicit: %+v", report.Geofeeds) + } +} + +func TestQueryIPBGPReportWHOISTimeoutIsBounded(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = bufio.NewReader(conn).ReadString('\n') + _, _ = io.Copy(io.Discard, conn) + }() + + rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer rdapServer.Close() + + started := time.Now() + report, err := QueryIPBGPReport(context.Background(), "198.51.100.1", IPBGPReportConfig{ + Timeout: time.Second, + RDAPClient: rdapServer.Client(), + RDAPBaseURL: rdapServer.URL, + EnableWHOISFallback: true, + WHOISServer: listener.Addr().String(), + WHOISTimeout: 30 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if elapsed := time.Since(started); elapsed > 500*time.Millisecond { + t.Fatalf("WHOIS timeout took %v", elapsed) + } + if source := unifiedSourceByName(report, "whois"); source.Status != ReportTimeout { + t.Fatalf("WHOIS source = %+v, want timeout", source) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("TCP fixture did not observe connection cleanup") + } +} + +func TestQueryIPBGPReportResolvesWHOISFromIANABootstrap(t *testing.T) { + bootstrap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"services":[[["203.0.113.0/24"],["https://rdap.arin.net/registry/"]]]}`) + })) + defer bootstrap.Close() + rdapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer rdapServer.Close() + + dialed := make(chan string, 1) + report, err := QueryIPBGPReport(context.Background(), "203.0.113.9", IPBGPReportConfig{ + Timeout: time.Second, + RDAPClient: rdapServer.Client(), + RDAPBaseURL: rdapServer.URL, + EnableWHOISFallback: true, + WHOISBootstrapClient: bootstrap.Client(), + WHOISBootstrapURL: bootstrap.URL, + WHOISTimeout: 250 * time.Millisecond, + WHOISDialContext: func(_ context.Context, _, address string) (net.Conn, error) { + dialed <- address + client, server := net.Pipe() + go func() { + defer server.Close() + _, _ = bufio.NewReader(server).ReadString('\n') + _, _ = io.WriteString(server, "CIDR: 203.0.113.0/24\r\nSource: ARIN\r\n") + }() + return client, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if got := <-dialed; got != "whois.arin.net:43" { + t.Fatalf("WHOIS address = %q", got) + } + if report.WHOIS == nil || report.WHOIS.Status != ReportAvailable || unifiedSourceByName(report, "whois_bootstrap").Status != ReportAvailable { + t.Fatalf("bootstrap WHOIS evidence missing: %+v", report) + } +} + +func TestQueryIPBGPReportRejectsInvalidInput(t *testing.T) { + if _, err := QueryIPBGPReport(context.Background(), "", IPBGPReportConfig{}); !errors.Is(err, ErrInvalidIPAddress) { + t.Fatalf("error = %v, want %v", err, ErrInvalidIPAddress) + } +} + +func TestRIRInferenceRequiresTokenBoundary(t *testing.T) { + if got := rirNameFromText("https://example.test/stripe-network"); got != "" { + t.Fatalf("false RIR inference = %q", got) + } + if got := rirNameFromText("whois.ripe.net"); got != "RIPE NCC" { + t.Fatalf("RIR inference = %q, want RIPE NCC", got) + } +} + +func unifiedSourceByName(report *IPBGPReport, name string) IPBGPSourceStatus { + for _, source := range report.Sources { + if source.Source == name { + return source + } + } + return IPBGPSourceStatus{Source: name, Status: ReportError, Error: "not found"} +} diff --git a/bgptools/whois_bootstrap.go b/bgptools/whois_bootstrap.go new file mode 100644 index 0000000..84cab91 --- /dev/null +++ b/bgptools/whois_bootstrap.go @@ -0,0 +1,125 @@ +package bgptools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" +) + +const ( + defaultIPv4BootstrapURL = "https://data.iana.org/rdap/ipv4.json" + defaultIPv6BootstrapURL = "https://data.iana.org/rdap/ipv6.json" + defaultWHOISBootstrapSize = 4 << 20 +) + +var errWHOISBootstrapMissing = errors.New("IANA bootstrap has no WHOIS service for IP") + +// resolveWHOISServer uses the IANA RDAP bootstrap only to identify the +// responsible RIR. The actual fallback remains a bounded port-43 query. +func resolveWHOISServer(ctx context.Context, ip string, cfg IPBGPReportConfig) (string, error) { + parsed := net.ParseIP(strings.TrimSpace(ip)) + if parsed == nil { + return "", ErrInvalidIPAddress + } + bootstrapURL := strings.TrimSpace(cfg.WHOISBootstrapURL) + if bootstrapURL == "" { + bootstrapURL = defaultIPv6BootstrapURL + if parsed.To4() != nil { + bootstrapURL = defaultIPv4BootstrapURL + } + } + client := cfg.WHOISBootstrapClient + if client == nil { + client = cfg.RDAPClient + } + if client == nil { + client = &http.Client{} + } + maximum := cfg.MaxWHOISBootstrapBytes + if maximum <= 0 { + maximum = defaultWHOISBootstrapSize + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bootstrapURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "oneclickvirt-backtrace-whois-bootstrap/1") + response, err := client.Do(req) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("IANA bootstrap HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maximum+1)) + if err != nil { + return "", err + } + if int64(len(body)) > maximum { + return "", fmt.Errorf("IANA bootstrap response exceeds %d bytes", maximum) + } + var payload struct { + Services [][][]string `json:"services"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", err + } + + bestPrefix := -1 + bestServer := "" + for _, service := range payload.Services { + if len(service) < 2 { + continue + } + server := whoisServerForRDAPURLs(service[1]) + if server == "" { + continue + } + for _, prefix := range service[0] { + _, network, err := net.ParseCIDR(strings.TrimSpace(prefix)) + if err != nil || !network.Contains(parsed) { + continue + } + ones, _ := network.Mask.Size() + if ones > bestPrefix { + bestPrefix = ones + bestServer = server + } + } + } + if bestServer == "" { + return "", errWHOISBootstrapMissing + } + return bestServer, nil +} + +func whoisServerForRDAPURLs(values []string) string { + for _, value := range values { + parsed, err := url.Parse(strings.TrimSpace(value)) + if err != nil { + continue + } + host := strings.ToLower(parsed.Hostname()) + switch { + case strings.Contains(host, "arin.net"): + return "whois.arin.net" + case strings.Contains(host, "apnic.net"): + return "whois.apnic.net" + case strings.Contains(host, "ripe.net"): + return "whois.ripe.net" + case strings.Contains(host, "afrinic.net"): + return "whois.afrinic.net" + case strings.Contains(host, "lacnic.net"): + return "whois.lacnic.net" + } + } + return "" +} diff --git a/model/model.go b/model/model.go index 51038c0..6b6d323 100644 --- a/model/model.go +++ b/model/model.go @@ -2,7 +2,7 @@ package model import "time" -const BackTraceVersion = "v0.0.10" +const BackTraceVersion = "v0.0.11" var EnableLoger = false