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 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. func newWebhookReporter(url string) *webhookReporter { if url == "" { return nil } return &webhookReporter{ url: url, 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) 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 }