1
0
Fork 0
go-abuseipdb/pkg/abuseipdb/client.go

634 lines
16 KiB
Go
Raw Normal View History

package abuseipdb
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"github.com/allegro/bigcache/v3"
"html"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"reflect"
"sst.rievo.dev/go-abuseipdb/pkg/abuseipdb/rate"
"strconv"
"strings"
"time"
)
const (
Version = "v2.0"
defaultBaseURL = "https://api.abuseipdb.com/api/v2/"
defaultUserAgent = "go-abuseipdb/" + Version
)
type Client struct {
client *http.Client
BaseURL *url.URL
UserAgent string
APIKey string
// ratelimit
RateLimit *rate.Limit
cache *bigcache.BigCache
}
// NewClient pass nil to not use a custom *http.Client
func NewClient(client *http.Client) *Client {
if client == nil {
client = &http.Client{}
}
copiedClient := *client
c := &Client{client: &copiedClient}
c.initialize()
return c
}
func (c *Client) AddApiKey(key string) {
c.APIKey = key
}
// AddCache with "ttl", suggestion 30min
func (c *Client) AddCache(eviction time.Duration) {
c.cache, _ = bigcache.New(context.Background(), bigcache.DefaultConfig(eviction))
}
func (c *Client) initialize() {
if c.client == nil {
c.client = &http.Client{}
}
if c.BaseURL == nil {
c.BaseURL, _ = url.Parse(defaultBaseURL)
}
if c.UserAgent == "" {
c.UserAgent = defaultUserAgent
}
}
func (c *Client) newRequest(method, urlStr string, parameter map[string]string, body io.Reader) (*http.Request, error) {
if !strings.HasSuffix(c.BaseURL.Path, "/") {
return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL)
}
u, err := c.BaseURL.Parse(urlStr)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.UserAgent)
req.Header.Set("Accept", "application/json")
req.Header.Set("Key", c.APIKey)
q := req.URL.Query()
for k, v := range parameter {
q.Add(k, v)
}
req.URL.RawQuery = q.Encode()
return req, nil
}
type errorResponse struct {
Errors []struct {
Detail string `json:"detail"`
Status int `json:"status"`
} `json:"errors"`
}
func handleError(r *http.Response) error {
if r.StatusCode == http.StatusTooManyRequests {
return fmt.Errorf("too many requests, see: Client.RateLimit")
}
if r.StatusCode >= 400 {
var errR *errorResponse
err := json.NewDecoder(r.Body).Decode(&errR)
if err != nil {
return fmt.Errorf("unable to decode error message %d: %w", r.StatusCode, err)
}
return fmt.Errorf("abuseipdb error: %s", errR.Errors[0].Detail)
}
return nil
}
func (c *Client) do(ctx context.Context, req *http.Request, v any) error {
resp, err := c.client.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer resp.Body.Close()
switch v := v.(type) {
case nil:
case io.Writer:
_, err = io.Copy(v, resp.Body)
default:
decErr := json.NewDecoder(resp.Body).Decode(v)
if decErr == io.EOF {
decErr = nil // ignore EOF errors caused by empty response body
}
if decErr != nil {
err = decErr
}
}
return err
}
type CheckOptions struct {
MaxAgeInDays int `json:"maxAgeInDays,omitempty"`
Verbose bool `json:"verbose,omitempty"`
}
type CheckResult struct {
Data struct {
IpAddress string `json:"ipAddress"`
IsPublic bool `json:"isPublic"`
IpVersion int `json:"ipVersion"`
IsWhitelisted bool `json:"isWhitelisted"`
AbuseConfidenceScore int `json:"abuseConfidenceScore"` // 100 spam, 0 ham
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName,omitempty"`
UsageType string `json:"usageType"`
Isp string `json:"isp"`
Domain string `json:"domain"`
Hostnames []string `json:"hostnames"`
IsTor bool `json:"isTor"`
TotalReports int `json:"totalReports"`
NumDistinctUsers int `json:"numDistinctUsers"`
LastReportedAt time.Time `json:"lastReportedAt"`
Reports []CheckResultReport `json:"reports,omitempty"`
} `json:"data"`
}
type CheckResultReport struct {
ReportedAt time.Time `json:"reportedAt"`
Comment string `json:"comment"`
Categories []int `json:"categories"`
ReporterId int `json:"reporterId"`
ReporterCountryCode string `json:"reporterCountryCode"`
ReporterCountryName string `json:"reporterCountryName"`
}
var checkEndpoint = "check"
func (c *Client) Check(ctx context.Context, ip net.IP, opts *CheckOptions) (*CheckResult, error) {
ipAddress := html.EscapeString(ip.String())
parameters := map[string]string{
"ipAddress": ipAddress,
}
if opts != nil {
if opts.MaxAgeInDays > 0 && opts.MaxAgeInDays <= 365 {
parameters["maxAgeInDays"] = strconv.Itoa(opts.MaxAgeInDays)
}
if opts.Verbose {
parameters["verbose"] = ""
}
}
req, err := c.newRequest(http.MethodGet, checkEndpoint, parameters, nil)
if err != nil {
return nil, err
}
result := CheckResult{}
err = c.do(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// CheckCached uses the client cache and the ip as a key
func (c *Client) CheckCached(ctx context.Context, ip net.IP, opts *CheckOptions) (*CheckResult, error) {
key := fmt.Sprintf("%s:%s", checkEndpoint, ip.String())
if r, err := c.cache.Get(key); err == nil {
result := CheckResult{}
if jsonErr := json.NewDecoder(bytes.NewReader(r)).Decode(&result); jsonErr == nil {
fmt.Println("return cached")
return &result, nil
}
}
if result, err := c.Check(ctx, ip, opts); err == nil {
resultEncoded := bytes.Buffer{}
if jsonErr := json.NewEncoder(&resultEncoded).Encode(result); jsonErr != nil {
return nil, jsonErr
}
if re := resultEncoded.Bytes(); re != nil {
c.cache.Set(key, re)
}
return result, nil
} else {
return nil, err
}
}
type ReportsOptions struct {
MaxAgeInDays int `json:"maxAgeInDays,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"perPage,omitempty"`
}
type ReportsResult struct {
Data struct {
Total int `json:"total"`
Page int `json:"page"`
Count int `json:"count"`
PerPage int `json:"perPage"`
LastPage int `json:"lastPage"`
NextPageUrl string `json:"nextPageUrl"`
PreviousPageUrl string `json:"previousPageUrl"`
Results []ReportResultReport `json:"results,omitempty"`
} `json:"data"`
}
type ReportResultReport struct {
ReportedAt time.Time `json:"reportedAt"`
Comment string `json:"comment"`
Categories []int `json:"categories"`
ReporterId int `json:"reporterId"`
ReporterCountryCode string `json:"reporterCountryCode"`
ReporterCountryName string `json:"reporterCountryName"`
}
func (c *Client) Reports(ctx context.Context, ip net.IP, opts *ReportsOptions) (*ReportsResult, error) {
var endpoint = "reports"
ipAddress := html.EscapeString(ip.String())
parameters := map[string]string{
"ipAddress": ipAddress,
}
if opts != nil {
if opts.MaxAgeInDays > 0 && opts.MaxAgeInDays <= 365 {
parameters["maxAgeInDays"] = strconv.Itoa(opts.MaxAgeInDays)
}
if opts.Page > 0 {
parameters["page"] = strconv.Itoa(opts.Page)
}
if opts.PerPage > 0 && opts.PerPage <= 100 {
parameters["perPage"] = strconv.Itoa(opts.PerPage)
}
}
req, err := c.newRequest(http.MethodGet, endpoint, parameters, nil)
if err != nil {
return nil, err
}
result := ReportsResult{}
err = c.do(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
type BlacklistOptions struct {
ConfidenceMinimum int `json:"confidenceMinimum,omitempty"`
Limit int `json:"limit,omitempty"`
OnlyCountries []string `json:"onlyCountries,omitempty"`
ExceptCountries []string `json:"exceptCountries,omitempty"`
IpVersion int `json:"ipVersion,omitempty"`
}
type BlacklistResult struct {
Meta struct {
GeneratedAt time.Time `json:"generatedAt"`
} `json:"meta"`
Data []struct {
IpAddress string `json:"ipAddress"`
AbuseConfidenceScore int `json:"abuseConfidenceScore,omitempty"`
LastReportedAt time.Time `json:"lastReportedAt"`
} `json:"data"`
}
func handleBlacklistOptions(opts *BlacklistOptions) map[string]string {
parameters := map[string]string{}
if opts != nil {
if opts.ConfidenceMinimum > 25 && opts.ConfidenceMinimum <= 100 {
parameters["confidenceMinimum"] = strconv.Itoa(opts.ConfidenceMinimum)
}
if opts.Limit > 0 && opts.Limit <= 500_000 {
parameters["limit"] = strconv.Itoa(opts.Limit)
}
if opts.OnlyCountries != nil && len(opts.OnlyCountries) > 0 {
parameters["onlyCountries"] = strings.Join(opts.OnlyCountries, ",")
}
if opts.ExceptCountries != nil && len(opts.ExceptCountries) > 0 {
parameters["exceptCountries"] = strings.Join(opts.ExceptCountries, ",")
}
if opts.IpVersion == 4 || opts.IpVersion == 6 {
parameters["ipVersion"] = strconv.Itoa(opts.IpVersion)
}
}
return parameters
}
func (c *Client) Blacklist(ctx context.Context, opts *BlacklistOptions) (*BlacklistResult, error) {
var endpoint = "blacklist"
parameters := handleBlacklistOptions(opts)
req, err := c.newRequest(http.MethodGet, endpoint, parameters, nil)
if err != nil {
return nil, err
}
result := BlacklistResult{}
err = c.do(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) BlacklistPlain(ctx context.Context, opts *BlacklistOptions) (io.Reader, error) {
var endpoint = "blacklist"
parameters := handleBlacklistOptions(opts)
req, err := c.newRequest(http.MethodGet, endpoint, parameters, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/plain")
resp, err := c.client.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
defer resp.Body.Close()
result := bytes.Buffer{}
_, err = io.Copy(&result, resp.Body)
if err != nil {
return nil, err
}
return &result, nil
}
type ReportOptions struct {
Categories []int `json:"categories"`
Comment string `json:"comment,omitempty"`
Time *time.Time `json:"timestamp,omitempty"`
}
type ReportResult struct {
Data struct {
IpAddress string `json:"ipAddress"`
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
} `json:"data"`
}
func (c *Client) Report(ctx context.Context, ip net.IP, opts *ReportOptions) (*ReportResult, error) {
var endpoint = "report"
ipAddress := html.EscapeString(ip.String())
parameters := map[string]string{
"ip": ipAddress,
}
if opts != nil {
if opts.Categories != nil {
categories := make([]string, len(opts.Categories))
for i, category := range opts.Categories {
categories[i] = strconv.Itoa(category)
}
parameters["categories"] = strings.Join(categories, ",")
}
if opts.Comment != "" {
parameters["comment"] = opts.Comment
}
if opts.Time != nil {
parameters["timestamp"] = opts.Time.Format(time.RFC3339)
}
}
req, err := c.newRequest(http.MethodPost, endpoint, parameters, nil)
if err != nil {
return nil, err
}
result := ReportResult{}
err = c.do(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
type CheckBlockOptions struct {
MaxAgeInDays int `json:"maxAgeInDays,omitempty"`
}
type CheckBlockResult struct {
Data struct {
NetworkAddress string `json:"networkAddress"`
Netmask string `json:"netmask"`
MinAddress string `json:"minAddress"`
MaxAddress string `json:"maxAddress"`
NumPossibleHosts int `json:"numPossibleHosts"`
AddressSpaceDesc string `json:"addressSpaceDesc"`
ReportedAddress []struct {
IpAddress string `json:"ipAddress"`
NumReports int `json:"numReports"`
MostRecentReport time.Time `json:"mostRecentReport"`
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
CountryCode string `json:"countryCode"`
} `json:"reportedAddress,omitempty"`
} `json:"data"`
}
func (c *Client) CheckBlock(ctx context.Context, ipnNet net.IPNet, opts *CheckBlockOptions) (*CheckBlockResult, error) {
var endpoint = "check-block"
network := html.EscapeString(ipnNet.String())
parameters := map[string]string{
"network": network,
}
// TODO check network
if opts != nil {
if opts.MaxAgeInDays > 0 && opts.MaxAgeInDays <= 365 {
parameters["maxAgeInDays"] = strconv.Itoa(opts.MaxAgeInDays)
}
}
req, err := c.newRequest(http.MethodGet, endpoint, parameters, nil)
if err != nil {
return nil, err
}
result := CheckBlockResult{}
err = c.do(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
type BulkReportData struct {
IpAddress string `csv:"IP"`
Categories []int `csv:"Categories"`
ReportDate time.Time `csv:"ReportDate"`
Comment string `csv:"Comment"`
}
type BulkReportDatas []BulkReportData
func (b *BulkReportDatas) getHeaders() []string {
if b == nil || len(*b) == 0 {
return nil
}
val := reflect.ValueOf((*b)[0])
var headers []string
for i := 0; i < val.Type().NumField(); i++ {
t := val.Type().Field(i)
fieldName := t.Name
jsonTag := t.Tag.Get("csv")
parts := strings.Split(jsonTag, ",")
name := parts[0]
if name == "" {
name = fieldName
}
headers = append(headers, name)
}
return headers
}
func (b *BulkReportDatas) toSlice() [][]string {
var slice [][]string
if b == nil || len(*b) == 0 {
return nil
}
for _, data := range *b {
categories := make([]string, len(data.Categories))
for i, category := range data.Categories {
categories[i] = strconv.Itoa(category)
}
slice = append(slice, []string{
data.IpAddress,
strings.Join(categories, ","),
data.ReportDate.Format(time.RFC3339),
data.Comment,
})
}
return slice
}
func (b *BulkReportDatas) csv() io.Reader {
result := &bytes.Buffer{}
writer := csv.NewWriter(result)
err := writer.WriteAll(append([][]string{b.getHeaders()}, b.toSlice()...))
if err != nil {
return nil
}
return result
}
func (b *BulkReportDatas) validate() error {
if b == nil || len(*b) == 0 {
return nil
}
for i, data := range *b {
if len(data.Categories) == 0 {
return fmt.Errorf("no categories found in BulkReportDatas entry %d", i)
}
if data.ReportDate.IsZero() {
return fmt.Errorf("no report date found in BulkReportDatas entry %d", i)
}
if len(data.Comment) > 1_024 {
return fmt.Errorf("comment is too long (%d bytes) for entry %d", len(data.Comment), i)
}
}
return nil
}
func toMultipartCsv(data io.Reader) (io.Reader, string) {
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
formFile, err := writer.CreateFormFile("csv", "data.csv")
if err != nil {
return nil, ""
}
_, err = io.Copy(formFile, data)
if err != nil {
return nil, ""
}
writer.Close()
return &requestBody, writer.FormDataContentType()
}
type BulkReportResult struct {
Data struct {
SavedReports int `json:"savedReports"`
InvalidReports []struct {
Error string `json:"error"`
Input string `json:"input"`
RowNumber int `json:"rowNumber"`
} `json:"invalidReports,omitempty"`
} `json:"data"`
}
func (c *Client) BulkReport(ctx context.Context, data *BulkReportDatas) (*BulkReportResult, error) {
var endpoint = "bulk-report"
if data == nil || len(*data) == 0 {
return nil, errors.New("bulk report: no data")
}
if err := data.validate(); err != nil {
return nil, err
}
csvData := data.csv()
if csvData == nil {
return nil, errors.New("bulk report: no csv data")
}
2024-08-01 14:36:51 +00:00
requestBody, contentType := toMultipartCsv(csvData)
req, err := c.newRequest(http.MethodPost, endpoint, nil, requestBody)
if err != nil {
return nil, err
}
2024-08-01 14:36:51 +00:00
req.Header.Add("Content-Type", contentType)
result := BulkReportResult{}
err = c.do(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
2024-08-01 14:36:51 +00:00
}
type ClearAddressData struct {
Data struct {
NumReportsDeleted int `json:"numReportsDeleted"`
} `json:"data"`
}
func (c *Client) ClearAddress(ctx context.Context, ip net.IP) (*ClearAddressData, error) {
var endpoint = "clear-address"
ipAddress := html.EscapeString(ip.String())
parameters := map[string]string{
"ipAddress": ipAddress,
}
req, err := c.newRequest(http.MethodDelete, endpoint, parameters, nil)
if err != nil {
return nil, err
}
result := ClearAddressData{}
err = c.do(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}