- per-client-IP token bucket on POST /api/traverse (EXPLOREDNS_RATE_LIMIT,
default 30/1h; direct localhost exempt, proxied clients are not)
- optional usage webhooks (EXPLOREDNS_WEBHOOK_URL): start/complete JSON
events, fire-and-forget with 5s timeout + one retry so a dead receiver
never delays a job
- post-traversal version.bind fingerprinting exposed at
GET /api/traverse/{id}/servers (pending until ready) and announced via
an SSE "servers" event; never delays job completion
- /api/health reports the serving Fly region (FLY_REGION) for observing
anycast routing from a roaming client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
3.8 KiB
Go
138 lines
3.8 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
|
|
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
|
|
}
|