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:
2026-03-20 13:39:57 +02:00
commit a70f3262e0
37 changed files with 15629 additions and 0 deletions

View 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)
}

View 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"`
}

View 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()
}
}