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:
co-authored by
Claude Fable 5
parent
d71c7fbef2
commit
111b8bf48e
+76
-5
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user