feat(monitor/enrich/greynoise): on-demand IP lookup with 404→ErrUnknownIP via httpx.StatusError
This commit is contained in:
parent
894ecc8fe5
commit
6cc3e681de
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue