Files
Gary HansenandClaude Fable 5 e8f56a1ef4 feat(receiver): storage layer and webhook payload compat
Dual-dialect store (SQLite via modernc.org, MySQL via go-sql-driver,
both pure Go) with order-tolerant start/complete upserts, filtered and
paginated listing, and aggregate queries (per-day, top domains, query
types, statuses, duration percentiles, top clients). Sender gains
optional EXPLOREDNS_WEBHOOK_TOKEN bearer auth; a round-trip test pins
receiver structs byte-compatible with the sender payloads.

Note: go directive moves to 1.25.0, required by modernc.org/sqlite.
CI reads the version from go.mod so GOTOOLCHAIN=local stays satisfied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:43:50 +10:00

144 lines
4.0 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
)
// Webhook event names, sent both in the JSON body and in the
// X-ExploreDNS-Event request header.
const (
webhookEventStart = "start"
webhookEventComplete = "complete"
)
// webhookStartEvent is posted when a traversal job is accepted.
type webhookStartEvent struct {
Event string `json:"event"`
ID string `json:"id"`
Domain string `json:"domain"`
QueryType string `json:"query_type"`
AllRoots bool `json:"all_roots"`
ClientIP string `json:"client_ip"`
StartedAt time.Time `json:"started_at"`
}
// webhookCompleteEvent is posted when a traversal job reaches a terminal
// state. Summary reuses the API Summary shape.
type webhookCompleteEvent struct {
Event string `json:"event"`
ID string `json:"id"`
Domain string `json:"domain"`
QueryType string `json:"query_type"`
ClientIP string `json:"client_ip"`
StartedAt time.Time `json:"started_at"`
DoneAt time.Time `json:"done_at"`
DurationMS int64 `json:"duration_ms"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
ResultCount int `json:"result_count"`
Summary *Summary `json:"summary"`
}
// webhookReporter posts usage events to a configured URL. Sends are
// fire-and-forget: each runs in its own goroutine with a timeout and a
// single retry, and failures are logged but never surface to callers.
type webhookReporter struct {
url string
token string
client *http.Client
timeout time.Duration
retryDelay time.Duration
}
// newWebhookReporter returns a reporter for url, or nil when url is empty
// (webhook reporting disabled). A nil reporter is safe to call. A non-empty
// token is sent as an Authorization bearer token on every delivery.
func newWebhookReporter(url, token string) *webhookReporter {
if url == "" {
return nil
}
return &webhookReporter{
url: url,
token: token,
client: &http.Client{},
timeout: 5 * time.Second,
retryDelay: 2 * time.Second,
}
}
// send marshals payload and posts it asynchronously with one retry on
// failure. Errors are logged and never affect the caller.
func (wr *webhookReporter) send(event string, payload any) {
if wr == nil {
return
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("webhook: marshal %s event: %v", event, err)
return
}
go func() {
err := wr.post(event, body)
if err == nil {
return
}
log.Printf("webhook: %s event failed, retrying in %s: %v", event, wr.retryDelay, err)
time.Sleep(wr.retryDelay)
if err := wr.post(event, body); err != nil {
log.Printf("webhook: %s event failed after retry: %v", event, err)
}
}()
}
// post performs one synchronous webhook delivery attempt.
func (wr *webhookReporter) post(event string, body []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), wr.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wr.url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ExploreDNS-Event", event)
if wr.token != "" {
req.Header.Set("Authorization", "Bearer "+wr.token)
}
resp, err := wr.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}
// clientIP resolves the requesting client's IP: the Fly-Client-IP header if
// present, else the first entry of X-Forwarded-For, else the host part of
// RemoteAddr.
func clientIP(r *http.Request) string {
if ip := strings.TrimSpace(r.Header.Get("Fly-Client-IP")); ip != "" {
return ip
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
return first
}
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}