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:
Gary Hansen
2026-07-07 23:31:34 +10:00
co-authored by Claude Fable 5
parent 94e41fe5b5
commit d8ef805a6a
7 changed files with 1271 additions and 7 deletions
+170
View File
@@ -2,6 +2,8 @@ package api
import (
"context"
"encoding/json"
"net"
"net/http/httptest"
"strconv"
"strings"
@@ -147,3 +149,171 @@ func TestSubscribeSnapshot_NoDuplicates(t *testing.T) {
t.Error(msg)
}
}
// fakeVersionQuerier returns canned version strings without touching the
// network.
type fakeVersionQuerier struct {
mu sync.Mutex
versions map[string]string
queried []string
}
func (f *fakeVersionQuerier) Query(_ context.Context, ip net.IP) string {
f.mu.Lock()
defer f.mu.Unlock()
f.queried = append(f.queried, ip.String())
return f.versions[ip.String()]
}
// TestGetServers_PendingWhileRunning verifies the 202 pending shape while a
// job has not finished fingerprinting (running or just-completed).
func TestGetServers_PendingWhileRunning(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
h := newHandler(ctx)
now := time.Now()
jobs := []*TraversalJob{
{ID: "running", Status: statusRunning, StartedAt: now},
{ID: "fingerprinting", Status: statusComplete, StartedAt: now, DoneAt: &now},
}
for _, j := range jobs {
h.st.set(j)
}
for _, id := range []string{"running", "fingerprinting"} {
req := httptest.NewRequest("GET", "/api/traverse/"+id+"/servers", nil)
rec := httptest.NewRecorder()
h.mux.ServeHTTP(rec, req)
if rec.Code != 202 {
t.Fatalf("%s: want 202, got %d: %s", id, rec.Code, rec.Body.String())
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("%s: decode: %v", id, err)
}
if body["status"] != "pending" {
t.Fatalf("%s: want status=pending, got %q", id, body["status"])
}
}
}
// TestFingerprintServers_StoresServersAndPublishes drives the fingerprint
// step with a fake querier: (server, ip) pairs become sorted job.Servers with
// versions, pseudo/invalid entries are skipped, a {"stage":"servers"} event
// is published, and the endpoint flips from pending to the final list.
func TestFingerprintServers_StoresServersAndPublishes(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
h := newHandler(ctx)
fake := &fakeVersionQuerier{versions: map[string]string{
"192.0.2.1": "TestDNS 1.0",
"192.0.2.2": "",
}}
h.fp = fake
now := time.Now()
job := &TraversalJob{ID: "j", Status: statusComplete, StartedAt: now, DoneAt: &now}
h.st.set(job)
sub, _, _ := job.subscribeSnapshot()
defer job.unsubscribe(sub)
h.fingerprintServers(job, map[string][]string{
"b.example.net": {"192.0.2.2"},
"a.example.net": {"192.0.2.1", "key:pseudo:entry", "not-an-ip"},
})
want := []ServerInfo{
{Name: "a.example.net", IP: "192.0.2.1", Version: "TestDNS 1.0"},
{Name: "b.example.net", IP: "192.0.2.2", Version: ""},
}
job.mu.RLock()
got := append([]ServerInfo(nil), job.Servers...)
done := job.serversDone
job.mu.RUnlock()
if !done {
t.Fatal("serversDone not set")
}
if len(got) != len(want) {
t.Fatalf("servers = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("servers[%d] = %+v, want %+v", i, got[i], want[i])
}
}
select {
case ev := <-sub:
if ev.Stage != "servers" {
t.Fatalf("published stage = %q, want servers", ev.Stage)
}
default:
t.Fatal("no servers event published")
}
// Endpoint now serves the final list.
req := httptest.NewRequest("GET", "/api/traverse/j/servers", nil)
rec := httptest.NewRecorder()
h.mux.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
var body struct {
Status string `json:"status"`
Servers []ServerInfo `json:"servers"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Status != "complete" || len(body.Servers) != 2 {
t.Fatalf("body = %+v", body)
}
// Snapshot includes servers too.
req = httptest.NewRequest("GET", "/api/traverse/j", nil)
rec = httptest.NewRecorder()
h.mux.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("snapshot: want 200, got %d", rec.Code)
}
var snap struct {
Servers []ServerInfo `json:"servers"`
}
if err := json.NewDecoder(rec.Body).Decode(&snap); err != nil {
t.Fatal(err)
}
if len(snap.Servers) != 2 {
t.Fatalf("snapshot servers = %+v, want 2 entries", snap.Servers)
}
}
// TestFingerprintServers_EmptySeen still terminates the pending state and
// publishes the servers event for traversals that recorded no servers.
func TestFingerprintServers_EmptySeen(t *testing.T) {
h := &Handler{st: newStore()}
job := &TraversalJob{ID: "e", Status: statusError}
h.st.set(job)
sub, _, _ := job.subscribeSnapshot()
defer job.unsubscribe(sub)
h.fingerprintServers(job, nil)
job.mu.RLock()
defer job.mu.RUnlock()
if !job.serversDone {
t.Fatal("serversDone not set")
}
if len(job.Servers) != 0 {
t.Fatalf("servers = %+v, want empty", job.Servers)
}
select {
case ev := <-sub:
if ev.Stage != "servers" {
t.Fatalf("published stage = %q, want servers", ev.Stage)
}
default:
t.Fatal("no servers event published")
}
}