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") } requestBody, contentType := toMultipartCsv(csvData) req, err := c.newRequest(http.MethodPost, endpoint, nil, requestBody) if err != nil { return nil, err } req.Header.Add("Content-Type", contentType) result := BulkReportResult{} err = c.do(ctx, req, &result) if err != nil { return nil, err } return &result, nil } 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 }