- 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>
320 lines
8.6 KiB
Go
320 lines
8.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestStartTraversalJobCap verifies that new traversals are rejected with
|
|
// 429 once maxRunning jobs are in flight. The store is pre-filled with
|
|
// running jobs so no real traversal is spawned.
|
|
func TestStartTraversalJobCap(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
h := newHandler(ctx)
|
|
h.maxRunning = 2
|
|
|
|
for i := 0; i < 2; i++ {
|
|
h.st.set(&TraversalJob{ID: strconv.Itoa(i), Status: statusRunning, StartedAt: time.Now()})
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/traverse",
|
|
strings.NewReader(`{"domain":"example.com"}`))
|
|
rec := httptest.NewRecorder()
|
|
h.mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != 429 {
|
|
t.Fatalf("want 429 with %d running jobs, got %d: %s", h.st.runningCount(), rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// A finished job frees a slot: runningCount must drop below the cap.
|
|
done := time.Now()
|
|
if j, ok := h.st.get("0"); ok {
|
|
j.mu.Lock()
|
|
j.Status = statusComplete
|
|
j.DoneAt = &done
|
|
j.mu.Unlock()
|
|
}
|
|
if got := h.st.runningCount(); got != 1 {
|
|
t.Fatalf("runningCount after completion = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestJobTimeoutReachesTerminalState verifies that a traversal launched
|
|
// with an already-expired deadline still drives the job to a terminal
|
|
// error state (the TTL cleanup only ever purges finished jobs).
|
|
func TestJobTimeoutReachesTerminalState(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
|
defer cancel()
|
|
<-ctx.Done() // deadline already exceeded
|
|
|
|
h := &Handler{st: newStore(), jobTimeout: time.Nanosecond}
|
|
job := &TraversalJob{ID: "t", Status: statusRunning, StartedAt: time.Now(), cancel: cancel}
|
|
h.st.set(job)
|
|
|
|
doneCh := make(chan struct{})
|
|
go func() {
|
|
h.runTraversal(ctx, job, "example.com", 1, false)
|
|
close(doneCh)
|
|
}()
|
|
select {
|
|
case <-doneCh:
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("runTraversal did not return with an expired context")
|
|
}
|
|
|
|
job.mu.RLock()
|
|
defer job.mu.RUnlock()
|
|
if job.DoneAt == nil {
|
|
t.Fatal("job never reached a terminal state")
|
|
}
|
|
if job.Status != statusError || !strings.Contains(job.Error, "timed out") {
|
|
t.Fatalf("want error status mentioning timeout, got status=%q error=%q", job.Status, job.Error)
|
|
}
|
|
}
|
|
|
|
// TestSubscribeSnapshot_NoDuplicates regresses the subscribe/snapshot race:
|
|
// subscribers arriving while events are being published must never see the
|
|
// same event twice (once from the snapshot replay and once from the channel).
|
|
// Events are numbered, so any duplicate breaks strict monotonicity. Slow
|
|
// subscribers may legitimately drop events (publishLocked is non-blocking),
|
|
// so gaps are not an error.
|
|
func TestSubscribeSnapshot_NoDuplicates(t *testing.T) {
|
|
job := &TraversalJob{Status: statusRunning}
|
|
|
|
const total = 2000
|
|
const subscribers = 8
|
|
|
|
var pub sync.WaitGroup
|
|
pub.Add(1)
|
|
go func() {
|
|
defer pub.Done()
|
|
for i := 0; i < total; i++ {
|
|
ev := ProgressEvent{RefID: strconv.Itoa(i)}
|
|
job.mu.Lock()
|
|
job.Progress = append(job.Progress, ev)
|
|
job.publishLocked(ev)
|
|
job.mu.Unlock()
|
|
}
|
|
job.mu.Lock()
|
|
job.Status = statusComplete
|
|
job.mu.Unlock()
|
|
job.closeSubscribers()
|
|
}()
|
|
|
|
var subs sync.WaitGroup
|
|
errs := make(chan string, subscribers)
|
|
for s := 0; s < subscribers; s++ {
|
|
subs.Add(1)
|
|
go func() {
|
|
defer subs.Done()
|
|
sub, past, done := job.subscribeSnapshot()
|
|
defer job.unsubscribe(sub)
|
|
|
|
last := -1
|
|
check := func(refid string) {
|
|
n, err := strconv.Atoi(refid)
|
|
if err != nil {
|
|
errs <- "bad refid " + refid
|
|
return
|
|
}
|
|
if n <= last {
|
|
errs <- "event " + refid + " out of order or duplicated after " + strconv.Itoa(last)
|
|
return
|
|
}
|
|
last = n
|
|
}
|
|
for _, ev := range past {
|
|
check(ev.RefID)
|
|
}
|
|
if !done {
|
|
for ev := range sub {
|
|
check(ev.RefID)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
pub.Wait()
|
|
subs.Wait()
|
|
close(errs)
|
|
for msg := range errs {
|
|
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")
|
|
}
|
|
}
|