// 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) }