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
@@ -29,6 +29,7 @@ func newTestServer(t *testing.T) *api.Server {
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
t.Setenv("FLY_REGION", "") // ensure region is absent regardless of host env
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
@@ -48,6 +49,55 @@ func TestHealth(t *testing.T) {
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("want status=ok, got %q", body["status"])
|
||||
}
|
||||
if body["version"] != "dev" {
|
||||
t.Fatalf("want version=dev, got %q", body["version"])
|
||||
}
|
||||
if region, ok := body["region"]; ok {
|
||||
t.Fatalf("region should be omitted outside Fly, got %q", region)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportsFlyRegion(t *testing.T) {
|
||||
t.Setenv("FLY_REGION", "syd")
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
resp, err := http.Get("http://" + srv.Addr() + "/api/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["region"] != "syd" {
|
||||
t.Fatalf("want region=syd, got %q", body["region"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportsStampedVersion(t *testing.T) {
|
||||
srv := api.NewServer("127.0.0.1:0")
|
||||
srv.SetVersion("v1.2.3")
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("start server: %v", err)
|
||||
}
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
resp, err := http.Get("http://" + srv.Addr() + "/api/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["version"] != "v1.2.3" {
|
||||
t.Fatalf("want version=v1.2.3, got %q", body["version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSDisabledByDefault(t *testing.T) {
|
||||
@@ -205,6 +255,95 @@ func TestGetTraversal_Found(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServers_NotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
resp, err := http.Get("http://" + srv.Addr() + "/api/traverse/does-not-exist/servers")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetServers_AvailableAfterCompletion drives a full job through the API:
|
||||
// the servers endpoint answers 202 pending while the traversal/fingerprinting
|
||||
// is in flight and the fingerprinted list once everything finished.
|
||||
func TestGetServers_AvailableAfterCompletion(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
startBody := bytes.NewBufferString(`{"domain":"example.com","type":"A"}`)
|
||||
startResp, err := http.Post("http://"+srv.Addr()+"/api/traverse", "application/json", startBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer startResp.Body.Close()
|
||||
|
||||
var start struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(startResp.Body).Decode(&start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for {
|
||||
resp, err := http.Get("http://" + srv.Addr() + "/api/traverse/" + start.ID + "/servers")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusAccepted:
|
||||
var pending struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pending); err != nil {
|
||||
t.Fatalf("pending body: %v (%s)", err, body)
|
||||
}
|
||||
if pending.Status != "pending" {
|
||||
t.Fatalf("want status=pending, got %q", pending.Status)
|
||||
}
|
||||
case http.StatusOK:
|
||||
var done struct {
|
||||
Status string `json:"status"`
|
||||
Servers []struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Version string `json:"version"`
|
||||
} `json:"servers"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &done); err != nil {
|
||||
t.Fatalf("servers body: %v (%s)", err, body)
|
||||
}
|
||||
if done.Status != "complete" {
|
||||
t.Fatalf("want status=complete, got %q", done.Status)
|
||||
}
|
||||
if done.Servers == nil {
|
||||
t.Fatalf("servers key missing or null: %s", body)
|
||||
}
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unexpected status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("timed out waiting for servers to become available")
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTraversal_NotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
@@ -322,6 +461,71 @@ func TestStaticSPA_TypeOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticSPA_DetailTree asserts the SPA ships the live detail tree with
|
||||
// its resolve-subtree toggle markup, plus the raw-log fallback feed so the
|
||||
// old flat progress view is still reachable for debugging.
|
||||
func TestStaticSPA_DetailTree(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
resp, err := http.Get("http://" + srv.Addr() + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := string(body)
|
||||
|
||||
for _, want := range []string{
|
||||
`id="detailTree"`, // detail-tree container
|
||||
`resolve-toggle`, // per-node show/hide resolve markup
|
||||
`show resolve`, // toggle wording mirrors dns.squish.net
|
||||
`id="progressFeed"`, // raw-log fallback feed still present
|
||||
`id="rawToggle"`, // toggle that reveals it
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("index.html missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticSPA_ServersSection asserts the SPA ships the server map/table
|
||||
// section: Leaflet lazy-loaded from unpkg, geojs.io client-side geolocation,
|
||||
// and the reference-style table headings.
|
||||
func TestStaticSPA_ServersSection(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
resp, err := http.Get("http://" + srv.Addr() + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := string(body)
|
||||
|
||||
for _, want := range []string{
|
||||
`unpkg.com/leaflet@1.9`, // map library CDN
|
||||
`get.geojs.io`, // client-side geolocation service
|
||||
`id="serversCard"`,
|
||||
`id="serverMap"`,
|
||||
`<th>Country</th><th>City</th><th>Servers</th><th>Software guess</th>`,
|
||||
`/servers`, // fetches the servers endpoint
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("index.html missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticSPA_FallbackToIndex(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Shutdown(5 * time.Second) //nolint:errcheck
|
||||
|
||||
Reference in New Issue
Block a user