feat(monitor/enrich/greynoise): on-demand IP lookup with 404→ErrUnknownIP via httpx.StatusError

This commit is contained in:
CarterPerez-dev 2026-05-01 22:49:48 -04:00
parent 894ecc8fe5
commit 6cc3e681de
4 changed files with 154 additions and 1 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -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"
}

View File

@ -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