Files
go-otel/internal/abuseipdb/abuseipdb.go
T
mats 0ed2935f04
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 36s
alot more changes
2026-09-21 18:59:13 +02:00

193 lines
5.6 KiB
Go

// Package abuseipdb proxies the AbuseIPDB API for authenticated operator actions.
// The API key is deliberately kept on the server; browsers only receive the
// selected IP's response data.
package abuseipdb
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const defaultBaseURL = "https://api.abuseipdb.com/api/v2"
type Config struct {
Key string
BaseURL string
}
func ConfigFromEnv() Config {
base := strings.TrimRight(os.Getenv("ABUSE_API_URL"), "/")
if base == "" {
base = defaultBaseURL
}
return Config{Key: strings.TrimSpace(os.Getenv("ABUSE_KEY")), BaseURL: base}
}
type Handler struct {
cfg Config
client *http.Client
}
func NewHandler(cfg Config) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Second}}
}
type Report struct {
ReportedAt string `json:"reportedAt,omitempty"`
Comment string `json:"comment,omitempty"`
Categories []int `json:"categories,omitempty"`
ReporterCountryCode string `json:"reporterCountryCode,omitempty"`
}
type CheckData struct {
IPAddress string `json:"ipAddress,omitempty"`
IsPublic bool `json:"isPublic"`
IPVersion int `json:"ipVersion,omitempty"`
IsWhitelisted bool `json:"isWhitelisted"`
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
CountryCode string `json:"countryCode,omitempty"`
UsageType string `json:"usageType,omitempty"`
ISP string `json:"isp,omitempty"`
Domain string `json:"domain,omitempty"`
TotalReports int `json:"totalReports"`
NumDistinctUsers int `json:"numDistinctUsers"`
LastReportedAt string `json:"lastReportedAt,omitempty"`
Reports []Report `json:"reports,omitempty"`
}
type checkResponse struct {
Data CheckData `json:"data"`
}
type reportResponse struct {
Data struct {
IPAddress string `json:"ipAddress,omitempty"`
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
} `json:"data"`
}
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
ip, ok := h.publicIP(w, r)
if !ok {
return
}
query := url.Values{"ipAddress": {ip.String()}, "maxAgeInDays": {"90"}}
var out checkResponse
if err := h.request(r.Context(), http.MethodGet, "/check?"+query.Encode(), nil, &out); err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, out.Data)
}
type reportRequest struct {
Categories []int `json:"categories"`
Comment string `json:"comment"`
}
func (h *Handler) Report(w http.ResponseWriter, r *http.Request) {
ip, ok := h.publicIP(w, r)
if !ok {
return
}
var req reportRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil {
http.Error(w, "invalid report request", http.StatusBadRequest)
return
}
if len(req.Categories) == 0 {
http.Error(w, "at least one category is required", http.StatusBadRequest)
return
}
if len(req.Comment) > 1000 {
http.Error(w, "comment is too long", http.StatusBadRequest)
return
}
form := url.Values{"ip": {ip.String()}, "comment": {strings.TrimSpace(req.Comment)}}
categories := make([]string, len(req.Categories))
for i, category := range req.Categories {
if category < 1 || category > 30 {
http.Error(w, "category must be between 1 and 30", http.StatusBadRequest)
return
}
categories[i] = strconv.Itoa(category)
}
form.Set("categories", strings.Join(categories, ","))
var out reportResponse
if err := h.request(r.Context(), http.MethodPost, "/report", strings.NewReader(form.Encode()), &out); err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, out.Data)
}
func (h *Handler) publicIP(w http.ResponseWriter, r *http.Request) (netip.Addr, bool) {
if h.cfg.Key == "" {
http.Error(w, "AbuseIPDB is not configured (set ABUSE_KEY)", http.StatusNotImplemented)
return netip.Addr{}, false
}
ip, err := netip.ParseAddr(strings.TrimSpace(r.PathValue("ip")))
if err != nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() {
http.Error(w, "only public unicast IPs can be checked or reported", http.StatusBadRequest)
return netip.Addr{}, false
}
return ip, true
}
func (h *Handler) request(ctx context.Context, method, path string, body io.Reader, dst any) error {
req, err := http.NewRequestWithContext(ctx, method, h.cfg.BaseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Key", h.cfg.Key)
if body != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := h.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var apiErr struct {
Errors []struct {
Detail string `json:"detail"`
} `json:"errors"`
}
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&apiErr)
if len(apiErr.Errors) > 0 && apiErr.Errors[0].Detail != "" {
return fmt.Errorf("AbuseIPDB: %s", apiErr.Errors[0].Detail)
}
return fmt.Errorf("AbuseIPDB returned HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(dst); err != nil {
return fmt.Errorf("invalid AbuseIPDB response: %w", err)
}
return nil
}
func (h *Handler) writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "private, no-store")
_ = json.NewEncoder(w).Encode(value)
}
func (h *Handler) writeError(w http.ResponseWriter, err error) {
status := http.StatusBadGateway
if errors.Is(err, context.DeadlineExceeded) {
status = http.StatusGatewayTimeout
}
http.Error(w, err.Error(), status)
}