diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go new file mode 100644 index 00000000..a8d30cc2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go @@ -0,0 +1,88 @@ +// ©AngelaMos | 2026 +// client.go + +package greynoise + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + + +const ( + defaultGNBaseURL = "https://api.greynoise.io" + pathCommunityLookup = "/v3/community/" + greynoiseAPIHeader = "key" + defaultGNRate = 2 * time.Second + defaultGNBudget = 3 + defaultGNBreakerWin = 5 * time.Minute + defaultGNRequestTO = 10 * time.Second +) + +var ErrUnknownIP = errors.New("greynoise: unknown ip") + +type ClientConfig struct { + BaseURL string + APIKey string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultGNBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "greynoise", + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + APIKeyHeader: greynoiseAPIHeader, + Rate: rate.Every(defaultGNRate), + Burst: 1, + ConsecutiveFailureBudget: defaultGNBudget, + BreakerTimeout: defaultGNBreakerWin, + RequestTimeout: defaultGNRequestTO, + }), + } +} + +type Verdict struct { + IP string `json:"ip"` + Noise bool `json:"noise"` + Riot bool `json:"riot"` + Classification string `json:"classification"` + Name string `json:"name"` + Link string `json:"link"` + LastSeen string `json:"last_seen"` + Message string `json:"message"` +} + +func (c *Client) Lookup(ctx context.Context, ip string) (Verdict, error) { + resp, err := c.hx.Get(ctx, pathCommunityLookup+url.PathEscape(ip), nil) + if err != nil { + var se *httpx.StatusError + if errors.As(err, &se) && se.Code == http.StatusNotFound { + return Verdict{}, ErrUnknownIP + } + return Verdict{}, fmt.Errorf("greynoise lookup %s: %w", ip, err) + } + defer resp.Body.Close() + + var v Verdict + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return Verdict{}, fmt.Errorf("greynoise decode: %w", err) + } + return v, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go new file mode 100644 index 00000000..289fb446 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// client_test.go + +package greynoise_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/greynoise" +) + +func TestClient_LookupDecodesVerdict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v3/community/8.8.8.8", r.URL.Path) + require.Equal(t, "test-key", r.Header.Get("key")) + body, err := os.ReadFile("testdata/ip_lookup.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}) + v, err := c.Lookup(context.Background(), "8.8.8.8") + require.NoError(t, err) + require.Equal(t, "8.8.8.8", v.IP) + require.True(t, v.Riot) + require.Equal(t, "benign", v.Classification) +} + +func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "k"}) + _, err := c.Lookup(context.Background(), "10.0.0.1") + require.ErrorIs(t, err, greynoise.ErrUnknownIP) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json new file mode 100644 index 00000000..ac414915 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json @@ -0,0 +1,10 @@ +{ + "ip": "8.8.8.8", + "noise": false, + "riot": true, + "classification": "benign", + "name": "Google Public DNS", + "link": "https://viz.greynoise.io/ip/8.8.8.8", + "last_seen": "2026-05-02", + "message": "Success" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go index e214aba6..ba1bf59a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go @@ -52,6 +52,15 @@ type Client struct { hc *http.Client } +type StatusError struct { + Code int + Body string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("client error %d: %s", e.Code, e.Body) +} + func New(cfg Config) *Client { if cfg.RequestTimeout <= 0 { cfg.RequestTimeout = defaultRequestTimeout @@ -122,7 +131,7 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http. case r.StatusCode >= 400: body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit)) drainAndClose(r) - return backoff.Permanent(fmt.Errorf("client error %d: %s", r.StatusCode, body)) + return backoff.Permanent(&StatusError{Code: r.StatusCode, Body: string(body)}) } resp = r return nil