feat(web): harden server for public exposure

- hard per-traversal deadline (EXPLOREDNS_JOB_TIMEOUT, default 5m) so
  every job reaches a terminal state; timed-out jobs report error with
  any partial results instead of masquerading as complete
- cap concurrent traversals (EXPLOREDNS_MAX_JOBS, default 8) returning
  429 when saturated
- CORS off by default (the embedded SPA is same-origin); opt in via
  EXPLOREDNS_CORS_ORIGIN

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gary Hansen
2026-07-07 21:50:29 +10:00
co-authored by Claude Fable 5
parent d71c7fbef2
commit 111b8bf48e
4 changed files with 178 additions and 9 deletions
+76 -5
View File
@@ -7,7 +7,9 @@ import (
"fmt"
"io/fs"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -27,6 +29,15 @@ const (
// jobTTL is how long completed jobs are retained in memory.
const jobTTL = time.Hour
// Public-exposure guards. Every traversal gets a hard deadline so jobs
// always reach a terminal state (the TTL cleanup only purges finished
// jobs), and the number of in-flight traversals is capped. Overridable
// via EXPLOREDNS_JOB_TIMEOUT (Go duration) and EXPLOREDNS_MAX_JOBS.
const (
defaultJobTimeout = 5 * time.Minute
defaultMaxRunningJobs = 8
)
// TraverseRequest is the JSON body for POST /api/traverse.
type TraverseRequest struct {
Domain string `json:"domain"`
@@ -183,6 +194,21 @@ func (s *store) set(j *TraversalJob) {
s.jobs[j.ID] = j
}
// runningCount reports how many jobs have not yet reached a terminal state.
func (s *store) runningCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
n := 0
for _, j := range s.jobs {
j.mu.RLock()
if j.DoneAt == nil {
n++
}
j.mu.RUnlock()
}
return n
}
// cleanup removes completed jobs older than jobTTL.
func (s *store) cleanup() {
cutoff := time.Now().Add(-jobTTL)
@@ -200,14 +226,18 @@ func (s *store) cleanup() {
// Handler wires together the HTTP routes and the job store.
type Handler struct {
st *store
mux *http.ServeMux
st *store
mux *http.ServeMux
jobTimeout time.Duration
maxRunning int
}
func newHandler(ctx context.Context) *Handler {
h := &Handler{
st: newStore(),
mux: http.NewServeMux(),
st: newStore(),
mux: http.NewServeMux(),
jobTimeout: envDuration("EXPLOREDNS_JOB_TIMEOUT", defaultJobTimeout),
maxRunning: envInt("EXPLOREDNS_MAX_JOBS", defaultMaxRunningJobs),
}
h.mux.HandleFunc("POST /api/traverse", h.startTraversal)
@@ -265,8 +295,13 @@ func (h *Handler) startTraversal(w http.ResponseWriter, r *http.Request) {
return
}
if h.st.runningCount() >= h.maxRunning {
writeError(w, http.StatusTooManyRequests, "too many concurrent traversals, try again shortly")
return
}
id := newUUID()
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), h.jobTimeout)
job := &TraversalJob{
ID: id,
@@ -445,6 +480,7 @@ func (h *Handler) runTraversal(ctx context.Context, job *TraversalJob, domain st
job.DoneAt = &now
timedOut := ctx.Err() == context.DeadlineExceeded
if err != nil && ctx.Err() == nil {
job.Status = statusError
job.Error = err.Error()
@@ -461,9 +497,44 @@ func (h *Handler) runTraversal(ctx context.Context, job *TraversalJob, domain st
job.Summary = toSummary(root.SummaryStats())
}
job.Results = items
if timedOut {
// Keep any partial results, but do not report a truncated
// traversal as a successful one.
job.Status = statusError
job.Error = fmt.Sprintf("traversal timed out after %s", h.jobTimeout)
return
}
job.Status = statusComplete
}
// envDuration reads a Go duration from the environment, falling back to
// def when unset or unparsable.
func envDuration(name string, def time.Duration) time.Duration {
v := os.Getenv(name)
if v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return def
}
return d
}
// envInt reads a positive integer from the environment, falling back to
// def when unset or unparsable.
func envInt(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return def
}
return n
}
// toSummary converts the engine's grouped stats to the API representation:
// answers sorted by RRset key (as SummaryStats returns them), remaining
// statuses sorted lexically like the CLI Summary Results section.