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
|
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 {
|
func New(cfg Config) *Client {
|
||||||
if cfg.RequestTimeout <= 0 {
|
if cfg.RequestTimeout <= 0 {
|
||||||
cfg.RequestTimeout = defaultRequestTimeout
|
cfg.RequestTimeout = defaultRequestTimeout
|
||||||
|
|
@ -122,7 +131,7 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.
|
||||||
case r.StatusCode >= 400:
|
case r.StatusCode >= 400:
|
||||||
body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit))
|
body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit))
|
||||||
drainAndClose(r)
|
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
|
resp = r
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue