Initial commit: DNS Test - DNS health checking tool
Go backend (miekg/dns) + Nuxt 3 frontend (Tailwind CSS v4). 8 check categories, 52 checks total: - Overview: @ record, WWW, MX with ASN/provider lookup - Domain Registration: expiry, registrar (RDAP + whois fallback) - Parent Delegation: NS records, glue, consistency - Nameservers: 17 checks (reachability, auth, recursion, TCP/UDP, AXFR, etc.) - SOA: serial consistency, timing values - Mail (MX): 11 checks (CNAME, PTR, public IPs, consistency) - Mail Auth: SPF, DKIM, DMARC - WWW: A record, CNAME Features: - SSE streaming (results appear as each category completes) - SQLite history (modernc.org/sqlite) - Rate limiting, CORS, request logging - Dark mode, responsive design
This commit is contained in:
220
backend/internal/api/handler.go
Normal file
220
backend/internal/api/handler.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/intodns/backend/internal/checker"
|
||||
"github.com/intodns/backend/internal/store"
|
||||
)
|
||||
|
||||
var validHostname = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
func cleanDomain(raw string) string {
|
||||
domain := raw
|
||||
domain = strings.TrimPrefix(domain, "http://")
|
||||
domain = strings.TrimPrefix(domain, "https://")
|
||||
domain = strings.TrimRight(domain, "/")
|
||||
if idx := strings.Index(domain, "/"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
if idx := strings.LastIndex(domain, ":"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(domain))
|
||||
}
|
||||
|
||||
// CheckHandler returns the full report as JSON (non-streaming).
|
||||
func CheckHandler(ch *checker.Checker, st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
domain := cleanDomain(r.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "missing 'domain' query parameter"})
|
||||
return
|
||||
}
|
||||
if !validHostname.MatchString(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid domain name"})
|
||||
return
|
||||
}
|
||||
|
||||
report := ch.Check(domain)
|
||||
|
||||
if st != nil {
|
||||
if err := st.SaveReport(report, extractIP(r), r.UserAgent()); err != nil {
|
||||
log.Printf("failed to save report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckStreamHandler streams results via Server-Sent Events as each category completes.
|
||||
func CheckStreamHandler(ch *checker.Checker, st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
domain := cleanDomain(r.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "missing 'domain' query parameter"})
|
||||
return
|
||||
}
|
||||
if !validHostname.MatchString(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid domain name"})
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
// Fallback to non-streaming.
|
||||
report := ch.Check(domain)
|
||||
if st != nil {
|
||||
if err := st.SaveReport(report, extractIP(r), r.UserAgent()); err != nil {
|
||||
log.Printf("failed to save report: %v", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
start := time.Now()
|
||||
catCh := ch.CheckStream(domain)
|
||||
var categories []checker.Category
|
||||
for event := range catCh {
|
||||
categories = append(categories, event.Category)
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Build and save the final report.
|
||||
if st != nil {
|
||||
var summary checker.Summary
|
||||
for _, cat := range categories {
|
||||
for _, check := range cat.Checks {
|
||||
switch check.Status {
|
||||
case checker.StatusPass:
|
||||
summary.Pass++
|
||||
case checker.StatusWarn:
|
||||
summary.Warn++
|
||||
case checker.StatusFail:
|
||||
summary.Fail++
|
||||
case checker.StatusInfo:
|
||||
summary.Info++
|
||||
}
|
||||
}
|
||||
}
|
||||
report := &checker.Report{
|
||||
Domain: domain,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
Summary: summary,
|
||||
Categories: categories,
|
||||
}
|
||||
if err := st.SaveReport(report, extractIP(r), r.UserAgent()); err != nil {
|
||||
log.Printf("failed to save streamed report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "data: {\"done\":true}\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryHandler returns recent check history as JSON.
|
||||
// Query params: domain (optional), limit (default 20, max 100).
|
||||
func HistoryHandler(st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
domain := r.URL.Query().Get("domain")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
|
||||
limit := 20
|
||||
if limitStr != "" {
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
var entries []store.HistoryEntry
|
||||
var err error
|
||||
|
||||
if domain != "" {
|
||||
entries, err = st.GetHistory(domain, limit)
|
||||
} else {
|
||||
entries, err = st.GetRecent(limit)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("history query error: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to query history"})
|
||||
return
|
||||
}
|
||||
|
||||
if entries == nil {
|
||||
entries = []store.HistoryEntry{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
}
|
||||
|
||||
// ReportHandler returns the full saved report JSON for a specific history entry.
|
||||
// Query param: id (required).
|
||||
func ReportHandler(st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "missing 'id' query parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid 'id' parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := st.GetReport(id)
|
||||
if err != nil {
|
||||
log.Printf("report query error: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to query report"})
|
||||
return
|
||||
}
|
||||
if entry == nil {
|
||||
writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "report not found"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// HealthHandler returns an http.HandlerFunc for the health endpoint.
|
||||
func HealthHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
11
backend/internal/api/response.go
Normal file
11
backend/internal/api/response.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package api
|
||||
|
||||
// ErrorResponse is returned on invalid requests.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// HealthResponse is returned by the health endpoint.
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
158
backend/internal/api/router.go
Normal file
158
backend/internal/api/router.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/intodns/backend/internal/checker"
|
||||
"github.com/intodns/backend/internal/store"
|
||||
)
|
||||
|
||||
// NewRouter builds the HTTP mux with all routes and middleware.
|
||||
func NewRouter(ch *checker.Checker, st *store.Store) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/dnstest/api/check/stream", CheckStreamHandler(ch, st))
|
||||
mux.HandleFunc("/dnstest/api/check", CheckHandler(ch, st))
|
||||
mux.HandleFunc("/dnstest/api/health", HealthHandler())
|
||||
mux.HandleFunc("/dnstest/api/history", HistoryHandler(st))
|
||||
mux.HandleFunc("/dnstest/api/report", ReportHandler(st))
|
||||
|
||||
// Stack middleware: logging -> CORS -> rate limit -> mux.
|
||||
var handler http.Handler = mux
|
||||
handler = rateLimitMiddleware(handler)
|
||||
handler = corsMiddleware(handler)
|
||||
handler = loggingMiddleware(handler)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// --- CORS middleware ---
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Rate limiting middleware (10 req/min per IP) ---
|
||||
|
||||
type ipEntry struct {
|
||||
count int
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
rateMu sync.Mutex
|
||||
rateMap = make(map[string]*ipEntry)
|
||||
rateOnce sync.Once
|
||||
)
|
||||
|
||||
func startRateCleanup() {
|
||||
rateOnce.Do(func() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Minute)
|
||||
rateMu.Lock()
|
||||
now := time.Now()
|
||||
for ip, entry := range rateMap {
|
||||
if now.Sub(entry.windowStart) > 2*time.Minute {
|
||||
delete(rateMap, ip)
|
||||
}
|
||||
}
|
||||
rateMu.Unlock()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func rateLimitMiddleware(next http.Handler) http.Handler {
|
||||
startRateCleanup()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := extractIP(r)
|
||||
|
||||
rateMu.Lock()
|
||||
entry, ok := rateMap[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !ok || now.Sub(entry.windowStart) > time.Minute {
|
||||
// New window.
|
||||
rateMap[ip] = &ipEntry{count: 1, windowStart: now}
|
||||
rateMu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
entry.count++
|
||||
if entry.count > 10 {
|
||||
rateMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", "60")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error":"rate limit exceeded, try again in 1 minute"}`))
|
||||
return
|
||||
}
|
||||
rateMu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func extractIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For first.
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP in the chain.
|
||||
for i := 0; i < len(xff); i++ {
|
||||
if xff[i] == ',' {
|
||||
return xff[:i]
|
||||
}
|
||||
}
|
||||
return xff
|
||||
}
|
||||
// Fall back to RemoteAddr (strip port).
|
||||
addr := r.RemoteAddr
|
||||
for i := len(addr) - 1; i >= 0; i-- {
|
||||
if addr[i] == ':' {
|
||||
return addr[:i]
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// --- Logging middleware ---
|
||||
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(lw, r)
|
||||
log.Printf("%s %s %d %s %s",
|
||||
r.Method, r.URL.Path, lw.statusCode,
|
||||
time.Since(start).Round(time.Millisecond), extractIP(r))
|
||||
})
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (lw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lw.statusCode = code
|
||||
lw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (lw *loggingResponseWriter) Flush() {
|
||||
if f, ok := lw.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user