feat(api): rate limiting, webhook telemetry, server fingerprints, region
- 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
94e41fe5b5
commit
d8ef805a6a
@@ -0,0 +1,137 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user