feat(monitor/collectors/cfradar): radar client (outages + bgp hijacks) with bearer auth
This commit is contained in:
parent
ada39ef6d9
commit
428610b663
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue