mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-07-23 03:20:10 +08:00
feat: add RDAP WHOIS and network relationship diagnostics
This commit is contained in:
parent
42997dab69
commit
dd4fdcdaea
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q", ErrInvalidPrefix, prefix)
|
||||
}
|
||||
return prefixList
|
||||
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位整数
|
||||
|
||||
57
back/generate_test.go
Normal file
57
back/generate_test.go
Normal file
@ -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])
|
||||
}
|
||||
}
|
||||
126
bgptools/origin.go
Normal file
126
bgptools/origin.go
Normal file
@ -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
|
||||
}
|
||||
58
bgptools/origin_test.go
Normal file
58
bgptools/origin_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
208
bgptools/rdap.go
Normal file
208
bgptools/rdap.go
Normal file
@ -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
|
||||
}
|
||||
48
bgptools/rdap_test.go
Normal file
48
bgptools/rdap_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
480
bgptools/relationships.go
Normal file
480
bgptools/relationships.go
Normal file
@ -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
|
||||
})
|
||||
}
|
||||
182
bgptools/relationships_test.go
Normal file
182
bgptools/relationships_test.go
Normal file
@ -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}
|
||||
}
|
||||
584
bgptools/report.go
Normal file
584
bgptools/report.go
Normal file
@ -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
|
||||
}
|
||||
252
bgptools/report_test.go
Normal file
252
bgptools/report_test.go
Normal file
@ -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"}
|
||||
}
|
||||
125
bgptools/whois_bootstrap.go
Normal file
125
bgptools/whois_bootstrap.go
Normal file
@ -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 ""
|
||||
}
|
||||
@ -2,7 +2,7 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
const BackTraceVersion = "v0.0.10"
|
||||
const BackTraceVersion = "v0.0.11"
|
||||
|
||||
var EnableLoger = false
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user