commit 1009e032e45c0d297ef877d25c71597d4eba4566 Author: Seraphim Strub Date: Wed Jul 31 10:50:30 2024 +0000 feature: implement most AbuseIPDB endpoints * implement all but report-bulk endpoint * basic tests done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..739002d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go-abuseipdb + +go 1.22 diff --git a/pkg/abuseipdb/client.go b/pkg/abuseipdb/client.go new file mode 100644 index 0000000..ef44a7a --- /dev/null +++ b/pkg/abuseipdb/client.go @@ -0,0 +1,484 @@ +package abuseipdb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "go-abuseipdb/pkg/abuseipdb/rate" + "html" + "io" + "net" + "net/http" + "net/url" + "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 +} + +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 +} + +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) (*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(), nil) + 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"` +} + +func (c *Client) Check(ctx context.Context, ip net.IP, opts *CheckOptions) (*CheckResult, error) { + var endpoint = "check" + 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, endpoint, parameters) + if err != nil { + return nil, err + } + + var result *CheckResult + err = c.Do(ctx, req, result) + if err != nil { + return nil, err + } + return result, nil +} + +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) + if err != nil { + return nil, err + } + + var 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) + if err != nil { + return nil, err + } + + var 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) + 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() + var 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) (*ReportsResult, 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 _, category := range opts.Categories { + categories = append(categories, 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.MethodGet, endpoint, parameters) + if err != nil { + return nil, err + } + var result *ReportsResult + 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) + if err != nil { + return nil, err + } + + var 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 []string `csv:"Categories"` + ReportDate time.Time `csv:"ReportDate"` + Comment string `csv:"Comment"` +} + +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 *BulkReportData) (*BulkReportResult, error) { + //var endpoint = "bulk-report" + + return nil, fmt.Errorf("not implemented") +} + +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.MethodPost, endpoint, parameters) + if err != nil { + return nil, err + } + var result *ClearAddressData + err = c.Do(ctx, req, &result) + if err != nil { + return nil, err + } + return result, nil +} diff --git a/pkg/abuseipdb/rate/ratelimit.go b/pkg/abuseipdb/rate/ratelimit.go new file mode 100644 index 0000000..4d585f1 --- /dev/null +++ b/pkg/abuseipdb/rate/ratelimit.go @@ -0,0 +1,51 @@ +package rate + +import ( + "net/http" + "strconv" + "time" +) + +type Category int + +const ( + CheckCategory Category = iota + ReportsCategory + BlacklistCategory + ReportCategory + CheckBlockCategory + BulkReportCategory + ClearAddressCategory +) + +type Limit struct { + RetryAfter time.Time `header:"Retry-After"` + XRateLimitLimit int `header:"X-RateLimit-Limit"` + XRateLimitRemaining int `header:"X-RateLimit-Remaining"` + XRateLimitReset time.Time `header:"X-RateLimit-Reset"` +} + +type Limits map[Category]*Limit + +func (l *Limits) Update(category Category, header http.Header) error { + limit := &Limit{} + for key, value := range header { + iValue, err := strconv.Atoi(value[0]) + if err != nil { + return err + } + switch key { + case "X-RateLimit-Limit": + limit.XRateLimitLimit = iValue + case "X-RateLimit-Remaining": + limit.XRateLimitRemaining = iValue + case "X-RateLimit-Reset": + limit.XRateLimitReset = time.Unix(int64(iValue), 0) + case "Retry-After": + limit.RetryAfter = time.Now().Add(time.Duration(iValue) * time.Second) + } + } + + (*l)[category] = limit + return nil +}