feat(monitor/collectors/cfradar): radar client (outages + bgp hijacks) with bearer auth

This commit is contained in:
CarterPerez-dev 2026-05-01 22:35:26 -04:00
parent ada39ef6d9
commit 428610b663
4 changed files with 275 additions and 0 deletions

View File

@ -0,0 +1,126 @@
// ©AngelaMos | 2026
// client.go
package cfradar
import (
"context"
"fmt"
"net/url"
"strconv"
"time"
"golang.org/x/time/rate"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
)
const (
defaultCFBaseURL = "https://api.cloudflare.com"
defaultCFRate = 250 * time.Millisecond
defaultCFBurst = 5
defaultCFBudget = 5
defaultCFBreaker = 60 * time.Second
pathRadarOutages = "/client/v4/radar/annotations/outages"
pathRadarHijacks = "/client/v4/radar/bgp/hijacks/events"
defaultDateRange = "1d"
)
type ClientConfig struct {
BaseURL string
BearerToken string
}
type Client struct {
hx *httpx.Client
}
func NewClient(cfg ClientConfig) *Client {
if cfg.BaseURL == "" {
cfg.BaseURL = defaultCFBaseURL
}
return &Client{
hx: httpx.New(httpx.Config{
Name: "cfradar",
BaseURL: cfg.BaseURL,
BearerToken: cfg.BearerToken,
Rate: rate.Every(defaultCFRate),
Burst: defaultCFBurst,
ConsecutiveFailureBudget: defaultCFBudget,
BreakerTimeout: defaultCFBreaker,
}),
}
}
type OutageEnvelope struct {
Success bool `json:"success"`
Result OutageResultBody `json:"result"`
Errors []ErrorEntry `json:"errors"`
}
type OutageResultBody struct {
Annotations []OutageAnnotation `json:"annotations"`
}
type OutageAnnotation struct {
ID string `json:"id"`
StartDate time.Time `json:"startDate"`
EndDate *time.Time `json:"endDate"`
Locations []string `json:"locations"`
ASNs []int32 `json:"asns"`
Reason string `json:"reason"`
OutageType string `json:"outageType"`
}
type HijackEnvelope struct {
Success bool `json:"success"`
Result HijackBody `json:"result"`
Errors []ErrorEntry `json:"errors"`
}
type HijackBody struct {
Events []HijackEvent `json:"events"`
}
type HijackEvent struct {
ID int64 `json:"id"`
DetectedAt time.Time `json:"detectedAt"`
StartedAt time.Time `json:"startedAt"`
DurationSec int32 `json:"duration"`
Confidence int16 `json:"confidenceScore"`
HijackerASN int32 `json:"hijackerAsn"`
VictimASNs []int32 `json:"victimAsns"`
Prefixes []string `json:"prefixes"`
}
type ErrorEntry struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (c *Client) FetchOutages(ctx context.Context) (OutageResultBody, error) {
q := url.Values{"dateRange": []string{defaultDateRange}}
var env OutageEnvelope
if err := c.hx.GetJSON(ctx, pathRadarOutages, q, &env); err != nil {
return OutageResultBody{}, fmt.Errorf("fetch outages: %w", err)
}
if !env.Success {
return OutageResultBody{}, fmt.Errorf("radar outages: success=false errors=%v", env.Errors)
}
return env.Result, nil
}
func (c *Client) FetchHijacks(ctx context.Context, minConfidence int) (HijackBody, error) {
q := url.Values{
"dateRange": []string{defaultDateRange},
"minConfidence": []string{strconv.Itoa(minConfidence)},
}
var env HijackEnvelope
if err := c.hx.GetJSON(ctx, pathRadarHijacks, q, &env); err != nil {
return HijackBody{}, fmt.Errorf("fetch hijacks: %w", err)
}
if !env.Success {
return HijackBody{}, fmt.Errorf("radar hijacks: success=false errors=%v", env.Errors)
}
return env.Result, nil
}

View File

@ -0,0 +1,93 @@
// ©AngelaMos | 2026
// client_test.go
package cfradar_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar"
)
func newRadarServer(t *testing.T, authHits *atomic.Int32) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/client/v4/radar/annotations/outages", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "Bearer test-token" {
authHits.Add(1)
}
body, err := os.ReadFile("testdata/outages.json")
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
mux.HandleFunc("/client/v4/radar/bgp/hijacks/events", func(w http.ResponseWriter, _ *http.Request) {
body, err := os.ReadFile("testdata/hijacks.json")
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
return httptest.NewServer(mux)
}
func TestClient_FetchOutagesSendsBearerAndDecodes(t *testing.T) {
var hits atomic.Int32
srv := newRadarServer(t, &hits)
defer srv.Close()
c := cfradar.NewClient(cfradar.ClientConfig{
BaseURL: srv.URL,
BearerToken: "test-token",
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body, err := c.FetchOutages(ctx)
require.NoError(t, err)
require.Len(t, body.Annotations, 2)
require.Equal(t, "outage-2026-04-30-DE-DTAG", body.Annotations[0].ID)
require.Equal(t, []string{"DE"}, body.Annotations[0].Locations)
require.Equal(t, []int32{3320}, body.Annotations[0].ASNs)
require.EqualValues(t, 1, hits.Load())
}
func TestClient_FetchHijacksDecodes(t *testing.T) {
var hits atomic.Int32
srv := newRadarServer(t, &hits)
defer srv.Close()
c := cfradar.NewClient(cfradar.ClientConfig{
BaseURL: srv.URL,
BearerToken: "test-token",
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body, err := c.FetchHijacks(ctx, 7)
require.NoError(t, err)
require.Len(t, body.Events, 2)
require.EqualValues(t, 7700001, body.Events[0].ID)
require.EqualValues(t, 9, body.Events[0].Confidence)
require.Equal(t, []string{"203.0.113.0/24"}, body.Events[0].Prefixes)
}
func TestClient_FetchOutagesFailsOnSuccessFalse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"success":false,"errors":[{"message":"unauthorized"}]}`))
}))
defer srv.Close()
c := cfradar.NewClient(cfradar.ClientConfig{BaseURL: srv.URL, BearerToken: "x"})
_, err := c.FetchOutages(context.Background())
require.Error(t, err)
}

View File

@ -0,0 +1,29 @@
{
"success": true,
"errors": [],
"messages": [],
"result": {
"events": [
{
"id": 7700001,
"detectedAt": "2026-05-01T17:23:14Z",
"startedAt": "2026-05-01T17:21:50Z",
"duration": 600,
"confidenceScore": 9,
"hijackerAsn": 64500,
"victimAsns": [15169, 13335],
"prefixes": ["203.0.113.0/24"]
},
{
"id": 7700002,
"detectedAt": "2026-05-01T18:02:09Z",
"startedAt": "2026-05-01T18:00:00Z",
"duration": 180,
"confidenceScore": 7,
"hijackerAsn": 64512,
"victimAsns": [3356],
"prefixes": ["198.51.100.0/24", "198.51.101.0/24"]
}
]
}
}

View File

@ -0,0 +1,27 @@
{
"success": true,
"errors": [],
"messages": [],
"result": {
"annotations": [
{
"id": "outage-2026-04-30-DE-DTAG",
"startDate": "2026-04-30T14:00:00Z",
"endDate": "2026-04-30T15:30:00Z",
"locations": ["DE"],
"asns": [3320],
"reason": "fiber cut, regional infrastructure",
"outageType": "REGIONAL"
},
{
"id": "outage-2026-05-01-IR-MCI",
"startDate": "2026-05-01T09:15:00Z",
"endDate": null,
"locations": ["IR"],
"asns": [197207],
"reason": "government-directed shutdown",
"outageType": "NATION_WIDE"
}
]
}
}