From 428610b663f9e2d6389ddaab36f7517e979b5bf5 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:35:26 -0400 Subject: [PATCH] feat(monitor/collectors/cfradar): radar client (outages + bgp hijacks) with bearer auth --- .../internal/collectors/cfradar/client.go | 126 ++++++++++++++++++ .../collectors/cfradar/client_test.go | 93 +++++++++++++ .../collectors/cfradar/testdata/hijacks.json | 29 ++++ .../collectors/cfradar/testdata/outages.json | 27 ++++ 4 files changed, 275 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go new file mode 100644 index 00000000..deb73273 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go new file mode 100644 index 00000000..797520bd --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go @@ -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) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json new file mode 100644 index 00000000..7835afe0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json @@ -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"] + } + ] + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json new file mode 100644 index 00000000..8b03d2ad --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json @@ -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" + } + ] + } +}