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

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Backend
backend/dnstest-server
*.exe
*.db
# Frontend
frontend/node_modules/
frontend/.nuxt/
frontend/.output/
frontend/dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

16
backend/go.mod Normal file
View File

@@ -0,0 +1,16 @@
module github.com/intodns/backend
go 1.22
require (
github.com/miekg/dns v1.1.62
modernc.org/sqlite v1.34.5
)
require (
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/tools v0.22.0 // indirect
)

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

View File

@@ -0,0 +1,145 @@
package checker
import (
"sync"
"time"
"github.com/intodns/backend/internal/resolver"
)
// Checker orchestrates all DNS health checks for a domain.
type Checker struct {
resolver *resolver.Resolver
}
// NewChecker creates a Checker with a default Resolver.
func NewChecker() *Checker {
return &Checker{
resolver: resolver.NewResolver(),
}
}
// StreamEvent is sent for each completed category during streaming.
type StreamEvent struct {
Domain string `json:"domain,omitempty"`
Category Category `json:"category"`
Done bool `json:"done,omitempty"`
}
// Check runs all category checks in parallel and returns a Report.
func (c *Checker) Check(domain string) *Report {
start := time.Now()
type catResult struct {
index int
cat Category
}
var wg sync.WaitGroup
results := make(chan catResult, 8)
categories := c.categoryFuncs()
for _, cf := range categories {
wg.Add(1)
go func(idx int, fn func(string, *resolver.Resolver) Category) {
defer wg.Done()
cat := fn(domain, c.resolver)
results <- catResult{index: idx, cat: cat}
}(cf.index, cf.fn)
}
go func() {
wg.Wait()
close(results)
}()
ordered := make([]Category, len(categories))
for cr := range results {
ordered[cr.index] = cr.cat
}
var summary Summary
for _, cat := range ordered {
for _, check := range cat.Checks {
switch check.Status {
case StatusPass:
summary.Pass++
case StatusWarn:
summary.Warn++
case StatusFail:
summary.Fail++
case StatusInfo:
summary.Info++
}
}
}
return &Report{
Domain: domain,
Timestamp: time.Now().UTC().Format(time.RFC3339),
DurationMs: time.Since(start).Milliseconds(),
Summary: summary,
Categories: ordered,
}
}
// CheckStream runs all categories in parallel and sends each category as it completes.
func (c *Checker) CheckStream(domain string) <-chan StreamEvent {
out := make(chan StreamEvent, 6)
go func() {
defer close(out)
type catResult struct {
index int
cat Category
}
var wg sync.WaitGroup
results := make(chan catResult, 8)
categories := c.categoryFuncs()
for _, cf := range categories {
wg.Add(1)
go func(idx int, fn func(string, *resolver.Resolver) Category) {
defer wg.Done()
cat := fn(domain, c.resolver)
results <- catResult{index: idx, cat: cat}
}(cf.index, cf.fn)
}
go func() {
wg.Wait()
close(results)
}()
for cr := range results {
out <- StreamEvent{
Domain: domain,
Category: cr.cat,
}
}
}()
return out
}
type catFunc struct {
index int
fn func(string, *resolver.Resolver) Category
}
func (c *Checker) categoryFuncs() []catFunc {
return []catFunc{
{0, checkOverview},
{1, checkDomainWhois},
{2, checkParent},
{3, checkNS},
{4, checkSOA},
{5, checkMX},
{6, checkMail},
{7, checkWWW},
}
}

View File

@@ -0,0 +1,329 @@
package checker
import (
"fmt"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkMail runs SPF, DKIM, and DMARC checks.
func checkMail(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "mail-auth", Title: "Mail Authentication (SPF/DKIM/DMARC)"}
domain = dns.Fqdn(domain)
// 1. SPF
cat.Checks = append(cat.Checks, checkSPF(domain, r))
// 2. SPF syntax
cat.Checks = append(cat.Checks, checkSPFSyntax(domain, r))
// 3. DMARC
cat.Checks = append(cat.Checks, checkDMARC(domain, r))
// 4. DMARC policy
cat.Checks = append(cat.Checks, checkDMARCPolicy(domain, r))
// 5. DKIM (common selectors)
cat.Checks = append(cat.Checks, checkDKIM(domain, r))
return cat
}
func getTXTRecords(name string, r *resolver.Resolver) []string {
resp, err := r.Query(name, "8.8.8.8", dns.TypeTXT)
if err != nil || resp == nil {
return nil
}
var records []string
for _, rr := range resp.Answer {
if txt, ok := rr.(*dns.TXT); ok {
records = append(records, strings.Join(txt.Txt, ""))
}
}
return records
}
func findSPF(records []string) string {
for _, r := range records {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(r)), "v=spf1") {
return r
}
}
return ""
}
func checkSPF(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "spf-present", Title: "SPF Record"}
defer func() { res.DurationMs = measureDuration(start) }()
txtRecords := getTXTRecords(domain, r)
spf := findSPF(txtRecords)
if spf == "" {
res.Status = StatusFail
res.Message = "No SPF record found"
if len(txtRecords) > 0 {
res.Details = append(res.Details, fmt.Sprintf("%d TXT records found, but none is SPF", len(txtRecords)))
}
return res
}
res.Status = StatusPass
res.Message = "SPF record found"
res.Details = append(res.Details, spf)
return res
}
func checkSPFSyntax(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "spf-syntax", Title: "SPF Syntax"}
defer func() { res.DurationMs = measureDuration(start) }()
txtRecords := getTXTRecords(domain, r)
spf := findSPF(txtRecords)
if spf == "" {
res.Status = StatusInfo
res.Message = "No SPF record to validate"
return res
}
// Count SPF records — there should be exactly one.
spfCount := 0
for _, rec := range txtRecords {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=spf1") {
spfCount++
}
}
if spfCount > 1 {
res.Status = StatusFail
res.Message = fmt.Sprintf("Multiple SPF records found (%d) — only one is allowed per RFC 7208", spfCount)
return res
}
// Basic syntax checks.
var warnings []string
lower := strings.ToLower(spf)
if !strings.HasPrefix(lower, "v=spf1 ") && lower != "v=spf1" {
warnings = append(warnings, "SPF version tag should be followed by a space")
}
// Check for common mechanisms.
mechanisms := []string{"all", "include:", "a", "mx", "ip4:", "ip6:", "redirect=", "exists:"}
hasMechanism := false
for _, m := range mechanisms {
if strings.Contains(lower, m) {
hasMechanism = true
break
}
}
if !hasMechanism {
warnings = append(warnings, "SPF record has no recognizable mechanisms")
}
// Check for +all (too permissive).
if strings.Contains(lower, "+all") {
warnings = append(warnings, "SPF uses +all which allows anyone to send mail (too permissive)")
}
// Check for ?all (neutral — not recommended).
if strings.Contains(lower, "?all") {
warnings = append(warnings, "SPF uses ?all (neutral) — consider using ~all or -all")
}
// Count DNS lookups (include, a, mx, ptr, exists, redirect — max 10).
lookups := 0
parts := strings.Fields(lower)
for _, p := range parts {
p = strings.TrimLeft(p, "+-~?")
if strings.HasPrefix(p, "include:") || strings.HasPrefix(p, "redirect=") ||
p == "a" || strings.HasPrefix(p, "a:") ||
p == "mx" || strings.HasPrefix(p, "mx:") ||
strings.HasPrefix(p, "ptr") || strings.HasPrefix(p, "exists:") {
lookups++
}
}
if lookups > 10 {
warnings = append(warnings, fmt.Sprintf("SPF record has %d DNS lookups (max 10 allowed per RFC 7208)", lookups))
}
res.Details = append(res.Details, spf)
res.Details = append(res.Details, fmt.Sprintf("DNS lookup mechanisms: %d/10", lookups))
if len(warnings) > 0 {
res.Status = StatusWarn
res.Message = "SPF record has potential issues"
res.Details = append(res.Details, warnings...)
} else {
res.Status = StatusPass
res.Message = "SPF record syntax looks valid"
}
return res
}
func checkDMARC(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "dmarc-present", Title: "DMARC Record"}
defer func() { res.DurationMs = measureDuration(start) }()
dmarcName := "_dmarc." + domain
txtRecords := getTXTRecords(dmarcName, r)
var dmarcRecord string
for _, rec := range txtRecords {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
dmarcRecord = rec
break
}
}
if dmarcRecord == "" {
res.Status = StatusFail
res.Message = fmt.Sprintf("No DMARC record found at %s", dmarcName)
return res
}
res.Status = StatusPass
res.Message = "DMARC record found"
res.Details = append(res.Details, dmarcRecord)
return res
}
func checkDMARCPolicy(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "dmarc-policy", Title: "DMARC Policy"}
defer func() { res.DurationMs = measureDuration(start) }()
dmarcName := "_dmarc." + domain
txtRecords := getTXTRecords(dmarcName, r)
var dmarcRecord string
for _, rec := range txtRecords {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
dmarcRecord = rec
break
}
}
if dmarcRecord == "" {
res.Status = StatusInfo
res.Message = "No DMARC record to evaluate"
return res
}
// Parse key=value tags.
tags := parseDMARCTags(dmarcRecord)
res.Details = append(res.Details, dmarcRecord)
policy := strings.ToLower(tags["p"])
switch policy {
case "reject":
res.Status = StatusPass
res.Message = "DMARC policy is 'reject' (strongest protection)"
case "quarantine":
res.Status = StatusPass
res.Message = "DMARC policy is 'quarantine' (good protection)"
case "none":
res.Status = StatusWarn
res.Message = "DMARC policy is 'none' (monitoring only, no enforcement)"
default:
res.Status = StatusWarn
res.Message = fmt.Sprintf("DMARC policy tag missing or unrecognized: '%s'", policy)
}
// Check subdomain policy.
if sp, ok := tags["sp"]; ok {
res.Details = append(res.Details, fmt.Sprintf("Subdomain policy (sp): %s", sp))
}
// Check reporting.
if rua, ok := tags["rua"]; ok {
res.Details = append(res.Details, fmt.Sprintf("Aggregate reports (rua): %s", rua))
} else {
res.Details = append(res.Details, "No aggregate report address (rua) — consider adding one")
}
if ruf, ok := tags["ruf"]; ok {
res.Details = append(res.Details, fmt.Sprintf("Forensic reports (ruf): %s", ruf))
}
// Check percentage.
if pct, ok := tags["pct"]; ok && pct != "100" {
res.Details = append(res.Details, fmt.Sprintf("pct=%s — not all messages are subject to policy", pct))
}
return res
}
func parseDMARCTags(record string) map[string]string {
tags := make(map[string]string)
// Remove v=DMARC1 prefix.
parts := strings.Split(record, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if eq := strings.IndexByte(part, '='); eq > 0 {
key := strings.TrimSpace(part[:eq])
val := strings.TrimSpace(part[eq+1:])
tags[strings.ToLower(key)] = val
}
}
return tags
}
func checkDKIM(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "dkim-present", Title: "DKIM Records"}
defer func() { res.DurationMs = measureDuration(start) }()
// DKIM selectors are not discoverable — check common ones.
commonSelectors := []string{
"default",
"google",
"selector1", // Microsoft 365
"selector2", // Microsoft 365
"k1", // Mailchimp
"s1",
"s2",
"dkim",
"mail",
"smtp",
"mandrill", // Mailchimp Transactional
"cm", // Campaign Monitor
"sig1",
"amazonses", // Amazon SES
}
var found []string
for _, sel := range commonSelectors {
dkimName := sel + "._domainkey." + domain
txtRecords := getTXTRecords(dkimName, r)
for _, rec := range txtRecords {
lower := strings.ToLower(rec)
if strings.Contains(lower, "v=dkim1") || strings.Contains(lower, "p=") {
found = append(found, fmt.Sprintf("%s: %s", sel, truncate(rec, 80)))
}
}
}
if len(found) > 0 {
res.Status = StatusPass
res.Message = fmt.Sprintf("DKIM record(s) found for %d selector(s)", len(found))
res.Details = found
} else {
res.Status = StatusInfo
res.Message = "No DKIM records found for common selectors (may use custom selector)"
res.Details = append(res.Details, "Checked selectors: "+strings.Join(commonSelectors, ", "))
}
return res
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View File

@@ -0,0 +1,449 @@
package checker
import (
"fmt"
"net"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkMX runs the 11 MX checks.
func checkMX(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "mx", Title: "Mail (MX)"}
domain = dns.Fqdn(domain)
// Get MX records.
resp, err := r.Query(domain, "8.8.8.8", dns.TypeMX)
if err != nil || resp == nil {
cat.Checks = append(cat.Checks, CheckResult{
ID: "mx-present", Title: "MX Records Present",
Status: StatusInfo, Message: fmt.Sprintf("Failed to query MX records: %v", err),
})
return cat
}
var mxRecords []*dns.MX
for _, rr := range resp.Answer {
if mx, ok := rr.(*dns.MX); ok {
mxRecords = append(mxRecords, mx)
}
}
// 1. mx-present
cat.Checks = append(cat.Checks, checkMXPresent(mxRecords))
if len(mxRecords) == 0 {
return cat
}
// 2. mx-reachable
cat.Checks = append(cat.Checks, checkMXReachable(mxRecords, r))
// 3. mx-no-cname
cat.Checks = append(cat.Checks, checkMXNoCNAME(mxRecords, r))
// 4. mx-no-ip
cat.Checks = append(cat.Checks, checkMXNoIP(mxRecords))
// 5. mx-priority
cat.Checks = append(cat.Checks, checkMXPriority(mxRecords))
// 6. mx-reverse-dns
cat.Checks = append(cat.Checks, checkMXReverseDNS(mxRecords, r))
// 7. mx-public-ip
cat.Checks = append(cat.Checks, checkMXPublicIP(mxRecords, r))
// 8. mx-consistent
cat.Checks = append(cat.Checks, checkMXConsistent(domain, r))
// 9. mx-a-records
cat.Checks = append(cat.Checks, checkMXARecords(mxRecords, r))
// 10. mx-aaaa-records
cat.Checks = append(cat.Checks, checkMXAAAARecords(mxRecords, r))
// 11. mx-localhost
cat.Checks = append(cat.Checks, checkMXLocalhost(mxRecords, r))
return cat
}
func checkMXPresent(mxRecords []*dns.MX) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-present", Title: "MX Records Present"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(mxRecords) == 0 {
res.Status = StatusInfo
res.Message = "No MX records found (domain may not handle email)"
return res
}
res.Status = StatusPass
res.Message = fmt.Sprintf("%d MX records found", len(mxRecords))
for _, mx := range mxRecords {
res.Details = append(res.Details, fmt.Sprintf("Priority %d: %s", mx.Preference, mx.Mx))
}
return res
}
func checkMXReachable(mxRecords []*dns.MX, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-reachable", Title: "MX Reachability"}
defer func() { res.DurationMs = measureDuration(start) }()
allResolvable := true
for _, mx := range mxRecords {
ips := resolveNS(mx.Mx, r) // reuse the name resolution helper
if len(ips) == 0 {
allResolvable = false
res.Details = append(res.Details, fmt.Sprintf("%s: no IP addresses found", mx.Mx))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s: %s", mx.Mx, strings.Join(ips, ", ")))
}
}
if allResolvable {
res.Status = StatusPass
res.Message = "All MX hosts resolve to IP addresses"
} else {
res.Status = StatusFail
res.Message = "Some MX hosts do not resolve"
}
return res
}
func checkMXNoCNAME(mxRecords []*dns.MX, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-no-cname", Title: "MX No CNAME"}
defer func() { res.DurationMs = measureDuration(start) }()
hasCNAME := false
for _, mx := range mxRecords {
resp, err := r.Query(mx.Mx, "8.8.8.8", dns.TypeCNAME)
if err != nil {
continue
}
for _, rr := range resp.Answer {
if cname, ok := rr.(*dns.CNAME); ok {
hasCNAME = true
res.Details = append(res.Details, fmt.Sprintf("%s is a CNAME to %s (RFC 2181 violation)", mx.Mx, cname.Target))
}
}
}
if hasCNAME {
res.Status = StatusFail
res.Message = "Some MX records point to CNAMEs (violates RFC 2181)"
} else {
res.Status = StatusPass
res.Message = "No MX records point to CNAMEs"
}
return res
}
func checkMXNoIP(mxRecords []*dns.MX) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-no-ip", Title: "MX No IP Literal"}
defer func() { res.DurationMs = measureDuration(start) }()
hasIPLiteral := false
for _, mx := range mxRecords {
name := strings.TrimSuffix(mx.Mx, ".")
if net.ParseIP(name) != nil {
hasIPLiteral = true
res.Details = append(res.Details, fmt.Sprintf("%s is an IP literal", name))
}
}
if hasIPLiteral {
res.Status = StatusFail
res.Message = "MX records contain IP literals (must be hostnames)"
} else {
res.Status = StatusPass
res.Message = "No MX records contain IP literals"
}
return res
}
func checkMXPriority(mxRecords []*dns.MX) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-priority", Title: "MX Priority Diversity"}
defer func() { res.DurationMs = measureDuration(start) }()
priorities := make(map[uint16]bool)
for _, mx := range mxRecords {
priorities[mx.Preference] = true
res.Details = append(res.Details, fmt.Sprintf("Priority %d: %s", mx.Preference, mx.Mx))
}
if len(mxRecords) == 1 {
res.Status = StatusInfo
res.Message = "Only one MX record; no priority diversity needed"
} else if len(priorities) >= 2 {
res.Status = StatusPass
res.Message = fmt.Sprintf("MX records use %d different priority levels for redundancy", len(priorities))
} else {
res.Status = StatusInfo
res.Message = "All MX records share the same priority (round-robin)"
}
return res
}
func checkMXReverseDNS(mxRecords []*dns.MX, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-reverse-dns", Title: "MX Reverse DNS (PTR)"}
defer func() { res.DurationMs = measureDuration(start) }()
allHavePTR := true
checked := 0
for _, mx := range mxRecords {
ips := resolveNS(mx.Mx, r)
for _, ip := range ips {
checked++
ptrName := reverseDNS(ip)
if ptrName == "" {
continue
}
resp, err := r.Query(ptrName, "8.8.8.8", dns.TypePTR)
if err != nil {
allHavePTR = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): PTR lookup failed", mx.Mx, ip))
continue
}
found := false
for _, rr := range resp.Answer {
if ptr, ok := rr.(*dns.PTR); ok {
found = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): PTR -> %s", mx.Mx, ip, ptr.Ptr))
}
}
if !found {
allHavePTR = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): no PTR record", mx.Mx, ip))
}
}
}
if checked == 0 {
res.Status = StatusWarn
res.Message = "No MX IPs to check for reverse DNS"
} else if allHavePTR {
res.Status = StatusPass
res.Message = "All MX IPs have reverse DNS (PTR) records"
} else {
res.Status = StatusWarn
res.Message = "Some MX IPs lack reverse DNS (PTR) records"
}
return res
}
func checkMXPublicIP(mxRecords []*dns.MX, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-public-ip", Title: "MX Public IPs"}
defer func() { res.DurationMs = measureDuration(start) }()
allPublic := true
checked := 0
for _, mx := range mxRecords {
ips := resolveNS(mx.Mx, r)
for _, ipStr := range ips {
checked++
ip := net.ParseIP(ipStr)
if isPublicIP(ip) {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): public", mx.Mx, ipStr))
} else {
allPublic = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): PRIVATE/RESERVED", mx.Mx, ipStr))
}
}
}
if checked == 0 {
res.Status = StatusWarn
res.Message = "No MX IPs to check"
} else if allPublic {
res.Status = StatusPass
res.Message = "All MX IPs are publicly routable"
} else {
res.Status = StatusFail
res.Message = "Some MX IPs are not publicly routable"
}
return res
}
func checkMXConsistent(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-consistent", Title: "MX Consistency"}
defer func() { res.DurationMs = measureDuration(start) }()
// Get NS list.
nsResp, err := r.Query(domain, "8.8.8.8", dns.TypeNS)
if err != nil || nsResp == nil {
res.Status = StatusWarn
res.Message = "Could not retrieve NS for consistency check"
return res
}
var nsNames []string
for _, rr := range nsResp.Answer {
if ns, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, ns.Ns)
}
}
if len(nsNames) < 2 {
res.Status = StatusInfo
res.Message = "Fewer than 2 NS; consistency check skipped"
return res
}
var mxSets []string
allSame := true
var referenceSet string
for _, ns := range nsNames {
ips := resolveNS(ns, r)
for _, ip := range ips {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeMX)
if err != nil {
continue
}
var mxNames []string
for _, rr := range resp.Answer {
if mx, ok := rr.(*dns.MX); ok {
mxNames = append(mxNames, fmt.Sprintf("%d:%s", mx.Preference, strings.ToLower(mx.Mx)))
}
}
sorted := sortedStrings(mxNames)
setStr := strings.Join(sorted, ",")
mxSets = append(mxSets, setStr)
res.Details = append(res.Details, fmt.Sprintf("%s: %s", ns, strings.Join(sorted, " ")))
if referenceSet == "" {
referenceSet = setStr
} else if setStr != referenceSet {
allSame = false
}
break
}
}
if allSame {
res.Status = StatusPass
res.Message = "All nameservers return the same MX set"
} else {
res.Status = StatusFail
res.Message = "Nameservers return different MX sets"
}
return res
}
func checkMXARecords(mxRecords []*dns.MX, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-a-records", Title: "MX A Records"}
defer func() { res.DurationMs = measureDuration(start) }()
allHaveA := true
for _, mx := range mxRecords {
resp, err := r.Query(mx.Mx, "8.8.8.8", dns.TypeA)
if err != nil {
allHaveA = false
res.Details = append(res.Details, fmt.Sprintf("%s: error resolving A record", mx.Mx))
continue
}
found := false
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
found = true
res.Details = append(res.Details, fmt.Sprintf("%s: %s", mx.Mx, a.A.String()))
}
}
if !found {
allHaveA = false
res.Details = append(res.Details, fmt.Sprintf("%s: no A record", mx.Mx))
}
}
if allHaveA {
res.Status = StatusPass
res.Message = "All MX hosts have A records"
} else {
res.Status = StatusWarn
res.Message = "Some MX hosts lack A records"
}
return res
}
func checkMXAAAARecords(mxRecords []*dns.MX, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-aaaa-records", Title: "MX AAAA Records"}
defer func() { res.DurationMs = measureDuration(start) }()
anyAAAA := false
for _, mx := range mxRecords {
resp, err := r.Query(mx.Mx, "8.8.8.8", dns.TypeAAAA)
if err != nil {
res.Details = append(res.Details, fmt.Sprintf("%s: error resolving", mx.Mx))
continue
}
found := false
for _, rr := range resp.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
found = true
anyAAAA = true
res.Details = append(res.Details, fmt.Sprintf("%s: %s", mx.Mx, aaaa.AAAA.String()))
}
}
if !found {
res.Details = append(res.Details, fmt.Sprintf("%s: no AAAA record", mx.Mx))
}
}
if anyAAAA {
res.Status = StatusInfo
res.Message = "Some MX hosts have AAAA records (IPv6 capable)"
} else {
res.Status = StatusInfo
res.Message = "No MX hosts have AAAA records"
}
return res
}
func checkMXLocalhost(mxRecords []*dns.MX, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "mx-localhost", Title: "MX Not Localhost"}
defer func() { res.DurationMs = measureDuration(start) }()
hasLocalhost := false
for _, mx := range mxRecords {
name := strings.ToLower(strings.TrimSuffix(mx.Mx, "."))
if name == "localhost" {
hasLocalhost = true
res.Details = append(res.Details, fmt.Sprintf("MX %s points to localhost", mx.Mx))
continue
}
// Also check if any resolved IP is loopback.
ips := resolveNS(mx.Mx, r)
for _, ipStr := range ips {
ip := net.ParseIP(ipStr)
if ip != nil && ip.IsLoopback() {
hasLocalhost = true
res.Details = append(res.Details, fmt.Sprintf("MX %s resolves to loopback %s", mx.Mx, ipStr))
}
}
}
if hasLocalhost {
res.Status = StatusFail
res.Message = "MX record points to localhost"
} else {
res.Status = StatusPass
res.Message = "No MX records point to localhost"
}
return res
}

View File

@@ -0,0 +1,713 @@
package checker
import (
"fmt"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkNS runs the 17 nameserver checks.
func checkNS(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "nameservers", Title: "Nameservers"}
domain = dns.Fqdn(domain)
// Resolve the NS set via a recursive query.
resp, err := r.Query(domain, "8.8.8.8", dns.TypeNS)
if err != nil || resp == nil {
cat.Checks = append(cat.Checks, CheckResult{
ID: "ns-count", Title: "NS Count",
Status: StatusFail, Message: fmt.Sprintf("Failed to query NS records: %v", err),
})
return cat
}
var nsNames []string
for _, rr := range resp.Answer {
if ns, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, ns.Ns)
}
}
// Resolve all NS to IPs.
var nsInfos []nsEntry
var allIPs []string
for _, ns := range nsNames {
ips := resolveNS(ns, r)
nsInfos = append(nsInfos, nsEntry{Name: ns, IPs: ips})
allIPs = append(allIPs, ips...)
}
// 1. ns-count
cat.Checks = append(cat.Checks, checkNSCount(nsNames))
// 2. ns-reachable — also builds a filtered list of reachable NS
reachableCheck, reachableInfos := checkNSReachableFiltered(domain, nsInfos, r)
cat.Checks = append(cat.Checks, reachableCheck)
// Use reachable NS for checks that query NS directly to avoid timeouts.
// Checks that only need names/IPs (no direct queries) still use full nsInfos.
// 3. ns-auth
cat.Checks = append(cat.Checks, checkNSAuth(domain, reachableInfos, r))
// 4. ns-recursion
cat.Checks = append(cat.Checks, checkNSRecursion(domain, reachableInfos, r))
// 5. ns-identical
cat.Checks = append(cat.Checks, checkNSIdentical(domain, reachableInfos, r))
// 6. ns-ip-unique
cat.Checks = append(cat.Checks, checkNSIPUnique(nsInfos))
// 7. ns-subnet-diversity
cat.Checks = append(cat.Checks, checkNSSubnetDiversity(allIPs))
// 8. ns-as-diversity
cat.Checks = append(cat.Checks, checkNSASDiversity(allIPs))
// 9. ns-tcp
cat.Checks = append(cat.Checks, checkNSTCP(domain, reachableInfos, r))
// 10. ns-udp
cat.Checks = append(cat.Checks, checkNSUDP(domain, reachableInfos, r))
// 11. ns-no-cname
cat.Checks = append(cat.Checks, checkNSNoCNAME(nsNames, r))
// 12. ns-a-records
cat.Checks = append(cat.Checks, checkNSARecords(nsInfos, r))
// 13. ns-aaaa-records
cat.Checks = append(cat.Checks, checkNSAAAARecords(nsNames, r))
// 14. ns-version
cat.Checks = append(cat.Checks, checkNSVersion(reachableInfos, r))
// 15. ns-zone-transfer
cat.Checks = append(cat.Checks, checkNSZoneTransfer(domain, reachableInfos, r))
// 16. ns-lame
cat.Checks = append(cat.Checks, checkNSLame(domain, reachableInfos, r))
// 17. ns-response-size
cat.Checks = append(cat.Checks, checkNSResponseSize(domain, reachableInfos, r))
return cat
}
func checkNSCount(nsNames []string) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-count", Title: "NS Count"}
defer func() { res.DurationMs = measureDuration(start) }()
count := len(nsNames)
switch {
case count == 0:
res.Status = StatusFail
res.Message = "No NS records found"
case count == 1:
res.Status = StatusWarn
res.Message = "Only 1 nameserver; at least 2 recommended"
res.Details = nsNames
default:
res.Status = StatusPass
res.Message = fmt.Sprintf("%d nameservers found", count)
res.Details = nsNames
}
return res
}
type nsEntry struct {
Name string
IPs []string
}
// checkNSReachableFiltered checks reachability and returns both the check result
// and a filtered list of only reachable NS entries (with only reachable IPs).
func checkNSReachableFiltered(domain string, infos []nsEntry, r *resolver.Resolver) (CheckResult, []nsEntry) {
start := time.Now()
res := CheckResult{ID: "ns-reachable", Title: "NS Reachability"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res, nil
}
var reachable []nsEntry
allOK := true
for _, ns := range infos {
if len(ns.IPs) == 0 {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s: no IPs resolved", ns.Name))
continue
}
var reachableIPs []string
for _, ip := range ns.IPs {
_, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err == nil {
reachableIPs = append(reachableIPs, ip)
res.Details = append(res.Details, fmt.Sprintf("%s (%s): OK", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): unreachable", ns.Name, ip))
}
}
if len(reachableIPs) > 0 {
reachable = append(reachable, nsEntry{Name: ns.Name, IPs: reachableIPs})
} else {
allOK = false
}
}
if allOK {
res.Status = StatusPass
res.Message = "All nameservers respond to DNS queries"
} else {
var unreachableNames []string
for _, ns := range infos {
found := false
for _, r := range reachable {
if r.Name == ns.Name {
found = true
break
}
}
if !found {
unreachableNames = append(unreachableNames, ns.Name)
}
}
res.Status = StatusFail
res.Message = fmt.Sprintf("The following nameservers are not responding to DNS queries: %s. This means part of your DNS infrastructure is down. Check that these servers are running and accessible on port 53 (UDP/TCP).", strings.Join(unreachableNames, ", "))
}
return res, reachable
}
func checkNSAuth(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-auth", Title: "NS Authoritative"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allAuth := true
for _, ns := range infos {
for _, ip := range ns.IPs {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
allAuth = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): error %v", ns.Name, ip, err))
continue
}
if resp.Authoritative {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): authoritative (AA=1)", ns.Name, ip))
} else {
allAuth = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): NOT authoritative (AA=0)", ns.Name, ip))
}
break // only check first reachable IP per NS
}
}
if allAuth {
res.Status = StatusPass
res.Message = "All nameservers are authoritative"
} else {
res.Status = StatusFail
res.Message = "Some nameservers are not authoritative"
}
return res
}
func checkNSRecursion(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-recursion", Title: "NS Recursion"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
anyRecursive := false
for _, ns := range infos {
for _, ip := range ns.IPs {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
continue
}
if resp.RecursionAvailable {
anyRecursive = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): recursion available (RA=1)", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): no recursion (RA=0)", ns.Name, ip))
}
break
}
}
if anyRecursive {
res.Status = StatusWarn
res.Message = "Some nameservers offer recursion; this is a security risk"
} else {
res.Status = StatusPass
res.Message = "No nameservers offer recursion"
}
return res
}
func checkNSIdentical(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-identical", Title: "NS Identical Sets"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
var sets []string
allSame := true
var referenceSet string
for _, ns := range infos {
for _, ip := range ns.IPs {
names := nsNamesFromAuthNS(domain, ip, r)
sorted := sortedStrings(names)
setStr := strings.Join(sorted, ",")
sets = append(sets, setStr)
res.Details = append(res.Details, fmt.Sprintf("%s: %s", ns.Name, strings.Join(sorted, ", ")))
if referenceSet == "" {
referenceSet = setStr
} else if setStr != referenceSet {
allSame = false
}
break
}
}
if allSame {
res.Status = StatusPass
res.Message = "All nameservers return the same NS set"
} else {
res.Status = StatusFail
res.Message = "Nameservers return different NS sets"
}
return res
}
func checkNSIPUnique(infos []nsEntry) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-ip-unique", Title: "NS IP Uniqueness"}
defer func() { res.DurationMs = measureDuration(start) }()
seen := make(map[string]string) // ip -> ns name
duplicates := false
for _, ns := range infos {
for _, ip := range ns.IPs {
if prev, ok := seen[ip]; ok {
duplicates = true
res.Details = append(res.Details, fmt.Sprintf("Duplicate IP %s shared by %s and %s", ip, prev, ns.Name))
} else {
seen[ip] = ns.Name
}
}
}
if duplicates {
res.Status = StatusWarn
res.Message = "Some nameservers share IP addresses"
} else {
res.Status = StatusPass
res.Message = "All nameserver IPs are unique"
}
return res
}
func checkNSSubnetDiversity(allIPs []string) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-subnet-diversity", Title: "NS Subnet Diversity"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(allIPs) == 0 {
res.Status = StatusWarn
res.Message = "No NS IPs to check"
return res
}
subnets := make(map[string]bool)
for _, ip := range allIPs {
subnets[subnet24(ip)] = true
}
for s := range subnets {
res.Details = append(res.Details, s)
}
if len(subnets) >= 2 {
res.Status = StatusPass
res.Message = fmt.Sprintf("Nameservers span %d /24 subnets", len(subnets))
} else {
res.Status = StatusWarn
res.Message = "All nameservers are in the same /24 subnet"
}
return res
}
func checkNSASDiversity(allIPs []string) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-as-diversity", Title: "NS AS Diversity"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(allIPs) == 0 {
res.Status = StatusInfo
res.Message = "No NS IPs to check"
return res
}
networks := make(map[string]bool)
for _, ip := range allIPs {
networks[subnet16(ip)] = true
}
for n := range networks {
res.Details = append(res.Details, n)
}
if len(networks) >= 2 {
res.Status = StatusInfo
res.Message = fmt.Sprintf("Nameservers appear to span %d /16 networks (rough AS diversity proxy)", len(networks))
} else {
res.Status = StatusInfo
res.Message = "All nameservers appear to be in the same /16 network"
}
return res
}
func checkNSTCP(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-tcp", Title: "NS TCP Support"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allOK := true
for _, ns := range infos {
for _, ip := range ns.IPs {
_, err := r.QueryTCP(domain, ip, dns.TypeSOA)
if err != nil {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): TCP failed: %v", ns.Name, ip, err))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): TCP OK", ns.Name, ip))
}
break
}
}
if allOK {
res.Status = StatusPass
res.Message = "All nameservers respond over TCP"
} else {
res.Status = StatusFail
res.Message = "Some nameservers do not respond over TCP"
}
return res
}
func checkNSUDP(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-udp", Title: "NS UDP Support"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allOK := true
for _, ns := range infos {
for _, ip := range ns.IPs {
_, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): UDP failed: %v", ns.Name, ip, err))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): UDP OK", ns.Name, ip))
}
break
}
}
if allOK {
res.Status = StatusPass
res.Message = "All nameservers respond over UDP"
} else {
res.Status = StatusFail
res.Message = "Some nameservers do not respond over UDP"
}
return res
}
func checkNSNoCNAME(nsNames []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-no-cname", Title: "NS No CNAME"}
defer func() { res.DurationMs = measureDuration(start) }()
hasCNAME := false
for _, ns := range nsNames {
resp, err := r.Query(ns, "8.8.8.8", dns.TypeCNAME)
if err != nil {
continue
}
for _, rr := range resp.Answer {
if cname, ok := rr.(*dns.CNAME); ok {
hasCNAME = true
res.Details = append(res.Details, fmt.Sprintf("%s is a CNAME to %s", ns, cname.Target))
}
}
}
if hasCNAME {
res.Status = StatusFail
res.Message = "Some NS names resolve to CNAMEs (RFC violation)"
} else {
res.Status = StatusPass
res.Message = "No NS names resolve to CNAMEs"
}
return res
}
func checkNSARecords(infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-a-records", Title: "NS A Records"}
defer func() { res.DurationMs = measureDuration(start) }()
allHaveA := true
for _, ns := range infos {
resp, err := r.Query(ns.Name, "8.8.8.8", dns.TypeA)
if err != nil {
allHaveA = false
res.Details = append(res.Details, fmt.Sprintf("%s: query error %v", ns.Name, err))
continue
}
found := false
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
found = true
res.Details = append(res.Details, fmt.Sprintf("%s: %s", ns.Name, a.A.String()))
}
}
if !found {
allHaveA = false
res.Details = append(res.Details, fmt.Sprintf("%s: no A record", ns.Name))
}
}
if allHaveA {
res.Status = StatusPass
res.Message = "All nameservers have A records"
} else {
res.Status = StatusWarn
res.Message = "Some nameservers lack A records"
}
return res
}
func checkNSAAAARecords(nsNames []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-aaaa-records", Title: "NS AAAA Records"}
defer func() { res.DurationMs = measureDuration(start) }()
anyAAAA := false
for _, ns := range nsNames {
resp, err := r.Query(ns, "8.8.8.8", dns.TypeAAAA)
if err != nil {
res.Details = append(res.Details, fmt.Sprintf("%s: query error", ns))
continue
}
found := false
for _, rr := range resp.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
found = true
anyAAAA = true
res.Details = append(res.Details, fmt.Sprintf("%s: %s", ns, aaaa.AAAA.String()))
}
}
if !found {
res.Details = append(res.Details, fmt.Sprintf("%s: no AAAA record", ns))
}
}
if anyAAAA {
res.Status = StatusInfo
res.Message = "Some nameservers have AAAA records (IPv6)"
} else {
res.Status = StatusInfo
res.Message = "No nameservers have AAAA records"
}
return res
}
func checkNSVersion(infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-version", Title: "NS Version"}
defer func() { res.DurationMs = measureDuration(start) }()
for _, ns := range infos {
for _, ip := range ns.IPs {
ver, err := r.QueryVersionBind(ip)
if err != nil || ver == "" {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): not disclosed", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %s", ns.Name, ip, ver))
}
break
}
}
res.Status = StatusInfo
res.Message = "Version information (version.bind)"
return res
}
func checkNSZoneTransfer(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-zone-transfer", Title: "NS Zone Transfer (AXFR)"}
defer func() { res.DurationMs = measureDuration(start) }()
anyAllowed := false
for _, ns := range infos {
for _, ip := range ns.IPs {
allowed, _ := r.QueryAXFR(domain, ip)
if allowed {
anyAllowed = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): AXFR ALLOWED (dangerous!)", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): AXFR refused (good)", ns.Name, ip))
}
break
}
}
if anyAllowed {
res.Status = StatusFail
res.Message = "Zone transfer (AXFR) is allowed on some nameservers"
} else {
res.Status = StatusPass
res.Message = "Zone transfer (AXFR) is refused on all nameservers"
}
return res
}
func checkNSLame(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-lame", Title: "NS Lame Delegation"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
anyLame := false
for _, ns := range infos {
for _, ip := range ns.IPs {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
anyLame = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): lame (unreachable)", ns.Name, ip))
break
}
if !resp.Authoritative {
anyLame = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): lame (not authoritative)", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): OK (authoritative)", ns.Name, ip))
}
break
}
}
if anyLame {
res.Status = StatusFail
res.Message = "Lame delegation detected"
} else {
res.Status = StatusPass
res.Message = "No lame delegations"
}
return res
}
func checkNSResponseSize(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-response-size", Title: "NS Response Size"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allOK := true
for _, ns := range infos {
for _, ip := range ns.IPs {
// Try a normal query and check the response size.
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeNS)
if err != nil {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): error", ns.Name, ip))
break
}
packed, err := resp.Pack()
if err != nil {
break
}
size := len(packed)
if size <= 512 {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes (fits in 512)", ns.Name, ip, size))
} else {
// Check EDNS support.
ednsResp, ednsErr := r.QueryEDNS(domain, ip, dns.TypeNS, 4096)
if ednsErr != nil {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes, no EDNS support", ns.Name, ip, size))
} else {
opt := ednsResp.IsEdns0()
if opt != nil {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes, EDNS supported (bufsize %d)", ns.Name, ip, size, opt.UDPSize()))
} else {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes, EDNS not in response", ns.Name, ip, size))
}
}
}
break
}
}
if allOK {
res.Status = StatusPass
res.Message = "Responses fit in 512 bytes or EDNS is supported"
} else {
res.Status = StatusWarn
res.Message = "Some responses exceed 512 bytes without EDNS support"
}
return res
}

View File

@@ -0,0 +1,381 @@
package checker
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkOverview resolves @, WWW, MX and identifies hosting providers via ASN.
func checkOverview(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "overview", Title: "Overview"}
domain = dns.Fqdn(domain)
// 1. @ record (A/AAAA for the domain itself)
cat.Checks = append(cat.Checks, checkDomainRecord(domain, r))
// 2. WWW record
cat.Checks = append(cat.Checks, checkWWWRecord(domain, r))
// 3. MX record + mail provider
cat.Checks = append(cat.Checks, checkMailProvider(domain, r))
return cat
}
func checkDomainRecord(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "overview-domain", Title: "Domain (@)"}
defer func() { res.DurationMs = measureDuration(start) }()
ips := resolveAllIPs(domain, r)
if len(ips) == 0 {
res.Status = StatusInfo
res.Message = fmt.Sprintf("%s does not resolve to any IP address (no A/AAAA records)", strings.TrimSuffix(domain, "."))
return res
}
res.Status = StatusPass
var lines []string
domainClean := strings.TrimSuffix(domain, ".")
for _, ip := range ips {
asn := lookupASN(ip, r)
lines = append(lines, formatIPLine(ip, asn))
}
if len(ips) == 1 {
asn := lookupASN(ips[0], r)
res.Message = fmt.Sprintf("%s → %s", domainClean, formatIPSummary(ips[0], asn))
} else {
res.Message = fmt.Sprintf("%s resolves to %d IP addresses", domainClean, len(ips))
}
res.Details = lines
return res
}
func checkWWWRecord(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "overview-www", Title: "Website (WWW)"}
defer func() { res.DurationMs = measureDuration(start) }()
wwwName := "www." + domain
// Check for CNAME first
var cnameTarget string
cnameResp, err := r.Query(wwwName, "8.8.8.8", dns.TypeCNAME)
if err == nil && cnameResp != nil {
for _, rr := range cnameResp.Answer {
if cname, ok := rr.(*dns.CNAME); ok {
cnameTarget = cname.Target
break
}
}
}
ips := resolveAllIPs(wwwName, r)
if len(ips) == 0 {
res.Status = StatusInfo
res.Message = fmt.Sprintf("www.%s does not resolve (no web hosting detected)", strings.TrimSuffix(domain, "."))
return res
}
res.Status = StatusPass
var lines []string
if cnameTarget != "" {
lines = append(lines, fmt.Sprintf("www.%s is a CNAME → %s", strings.TrimSuffix(domain, "."), cnameTarget))
}
for _, ip := range ips {
asn := lookupASN(ip, r)
lines = append(lines, formatIPLine(ip, asn))
}
// Build summary message
asn := lookupASN(ips[0], r)
domainClean := strings.TrimSuffix(domain, ".")
if cnameTarget != "" {
res.Message = fmt.Sprintf("www.%s → %s → %s", domainClean, strings.TrimSuffix(cnameTarget, "."), formatIPSummary(ips[0], asn))
} else {
res.Message = fmt.Sprintf("www.%s → %s", domainClean, formatIPSummary(ips[0], asn))
}
res.Details = lines
return res
}
func checkMailProvider(domain string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "overview-mail", Title: "Mail (MX)"}
defer func() { res.DurationMs = measureDuration(start) }()
resp, err := r.Query(domain, "8.8.8.8", dns.TypeMX)
if err != nil || resp == nil {
res.Status = StatusInfo
res.Message = fmt.Sprintf("%s has no MX records (no email configured)", strings.TrimSuffix(domain, "."))
return res
}
var mxRecords []*dns.MX
for _, rr := range resp.Answer {
if mx, ok := rr.(*dns.MX); ok {
mxRecords = append(mxRecords, mx)
}
}
if len(mxRecords) == 0 {
res.Status = StatusInfo
res.Message = fmt.Sprintf("%s has no MX records (no email configured)", strings.TrimSuffix(domain, "."))
return res
}
res.Status = StatusPass
var lines []string
var primaryProvider string
for _, mx := range mxRecords {
mxHost := mx.Mx
ips := resolveAllIPs(mxHost, r)
if len(ips) > 0 {
asn := lookupASN(ips[0], r)
provider := identifyMailProvider(mxHost, asn.Org)
lines = append(lines, fmt.Sprintf("Priority %d: %s → %s", mx.Preference, mxHost, formatIPSummary(ips[0], asn)))
if primaryProvider == "" {
primaryProvider = provider
}
} else {
lines = append(lines, fmt.Sprintf("Priority %d: %s (does not resolve)", mx.Preference, mxHost))
}
}
domainClean := strings.TrimSuffix(domain, ".")
if primaryProvider != "" {
res.Message = fmt.Sprintf("Mail for %s is handled by %s (%s)", domainClean, strings.TrimSuffix(mxRecords[0].Mx, "."), primaryProvider)
} else {
res.Message = fmt.Sprintf("Mail for %s is handled by %s", domainClean, strings.TrimSuffix(mxRecords[0].Mx, "."))
}
res.Details = lines
return res
}
// resolveAllIPs returns both A and AAAA records, A first.
func resolveAllIPs(name string, r *resolver.Resolver) []string {
var ips []string
resp, err := r.Query(name, "8.8.8.8", dns.TypeA)
if err == nil && resp != nil {
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
}
resp, err = r.Query(name, "8.8.8.8", dns.TypeAAAA)
if err == nil && resp != nil {
for _, rr := range resp.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
ips = append(ips, aaaa.AAAA.String())
}
}
}
return ips
}
// asnInfo holds ASN lookup results.
type asnInfo struct {
ASN string
Country string
Org string
Netname string
}
// lookupASN queries Team Cymru's DNS-based ASN service.
// Query: reversed-ip.origin.asn.cymru.com TXT -> "ASN | prefix | CC | registry | date"
// Then: ASN.asn.cymru.com TXT -> "ASN | CC | registry | date | org"
func lookupASN(ipStr string, r *resolver.Resolver) asnInfo {
ip := net.ParseIP(ipStr)
if ip == nil {
return asnInfo{}
}
var queryName string
if v4 := ip.To4(); v4 != nil {
queryName = fmt.Sprintf("%d.%d.%d.%d.origin.asn.cymru.com.", v4[3], v4[2], v4[1], v4[0])
} else {
// IPv6 — nibble format
full := ip.To16()
var parts []string
for i := len(full) - 1; i >= 0; i-- {
b := full[i]
parts = append(parts, fmt.Sprintf("%x", b&0x0f))
parts = append(parts, fmt.Sprintf("%x", (b>>4)&0x0f))
}
queryName = strings.Join(parts, ".") + ".origin6.asn.cymru.com."
}
// Step 1: Get ASN number
resp, err := r.Query(queryName, "8.8.8.8", dns.TypeTXT)
if err != nil || resp == nil {
return asnInfo{}
}
var asnNum, country string
for _, rr := range resp.Answer {
if txt, ok := rr.(*dns.TXT); ok && len(txt.Txt) > 0 {
parts := strings.SplitN(txt.Txt[0], "|", 5)
if len(parts) >= 3 {
asnNum = strings.TrimSpace(parts[0])
country = strings.TrimSpace(parts[2])
}
}
}
if asnNum == "" {
return asnInfo{}
}
// Step 2: Get org name
asnQuery := fmt.Sprintf("AS%s.asn.cymru.com.", asnNum)
resp, err = r.Query(asnQuery, "8.8.8.8", dns.TypeTXT)
if err != nil || resp == nil {
return asnInfo{ASN: "AS" + asnNum, Country: country}
}
var org string
for _, rr := range resp.Answer {
if txt, ok := rr.(*dns.TXT); ok && len(txt.Txt) > 0 {
parts := strings.SplitN(txt.Txt[0], "|", 5)
if len(parts) >= 5 {
org = strings.TrimSpace(parts[4])
}
}
}
info := asnInfo{
ASN: "AS" + asnNum,
Country: country,
Org: org,
}
// Try to get netname from RDAP IP lookup
info.Netname = lookupIPNetname(ipStr)
return info
}
// lookupIPNetname fetches netname from RDAP IP endpoint.
func lookupIPNetname(ipStr string) string {
client := &http.Client{Timeout: 3 * time.Second}
url := fmt.Sprintf("https://rdap.db.ripe.net/ip/%s", ipStr)
resp, err := client.Get(url)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024))
if err != nil {
return ""
}
var result struct {
Name string `json:"name"`
Handle string `json:"handle"`
}
if err := json.Unmarshal(body, &result); err != nil {
return ""
}
return result.Name
}
// formatIPSummary builds a one-line summary: "IP (Org / Netname, ASN, CC)"
func formatIPSummary(ip string, asn asnInfo) string {
if asn.Org == "" {
return ip
}
parts := []string{asn.Org}
if asn.Netname != "" && asn.Netname != asn.Org {
parts = []string{asn.Org + " / " + asn.Netname}
}
if asn.ASN != "" {
parts = append(parts, asn.ASN)
}
if asn.Country != "" {
parts = append(parts, asn.Country)
}
return fmt.Sprintf("%s (%s)", ip, strings.Join(parts, ", "))
}
// formatIPLine builds a detail line for an IP.
func formatIPLine(ip string, asn asnInfo) string {
if asn.Org == "" {
return ip
}
var parts []string
parts = append(parts, fmt.Sprintf("IP: %s", ip))
parts = append(parts, fmt.Sprintf("Provider: %s", asn.Org))
if asn.Netname != "" {
parts = append(parts, fmt.Sprintf("Netname: %s", asn.Netname))
}
if asn.ASN != "" {
parts = append(parts, fmt.Sprintf("ASN: %s", asn.ASN))
}
if asn.Country != "" {
parts = append(parts, fmt.Sprintf("Country: %s", asn.Country))
}
return strings.Join(parts, " | ")
}
// identifyMailProvider tries to identify well-known mail providers from MX hostname or ASN org.
func identifyMailProvider(mxHost string, asnOrg string) string {
mx := strings.ToLower(mxHost)
org := strings.ToLower(asnOrg)
switch {
case strings.Contains(mx, "google") || strings.Contains(mx, "gmail") || strings.Contains(mx, "googlemail"):
return "Google Workspace"
case strings.Contains(mx, "outlook") || strings.Contains(mx, "microsoft") || strings.Contains(mx, "protection.outlook"):
return "Microsoft 365"
case strings.Contains(mx, "zoho"):
return "Zoho Mail"
case strings.Contains(mx, "protonmail") || strings.Contains(mx, "proton"):
return "Proton Mail"
case strings.Contains(mx, "yahoodns") || strings.Contains(mx, "yahoo"):
return "Yahoo Mail"
case strings.Contains(mx, "mailgun"):
return "Mailgun"
case strings.Contains(mx, "sendgrid"):
return "SendGrid"
case strings.Contains(mx, "postmarkapp"):
return "Postmark"
case strings.Contains(mx, "mxlogin") || strings.Contains(mx, "emailsrvr"):
return "Rackspace Email"
case strings.Contains(mx, "1and1") || strings.Contains(mx, "ionos"):
return "IONOS"
case strings.Contains(mx, "ovh"):
return "OVH"
case strings.Contains(mx, "gandi"):
return "Gandi"
case strings.Contains(mx, "fastmail"):
return "Fastmail"
case strings.Contains(mx, "migadu"):
return "Migadu"
case strings.Contains(mx, "yandex"):
return "Yandex Mail"
case strings.Contains(mx, "amazon") || strings.Contains(mx, "amazonaws"):
return "Amazon SES"
default:
// Fall back to ASN org name
if org != "" {
return asnOrg
}
return "Self-hosted"
}
}

View File

@@ -0,0 +1,313 @@
package checker
import (
"fmt"
"sort"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkParent runs the 5 parent-delegation checks.
func checkParent(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "parent", Title: "Parent Delegation"}
domain = dns.Fqdn(domain)
parts := dns.SplitDomainName(domain)
if len(parts) < 2 {
cat.Checks = append(cat.Checks, CheckResult{
ID: "parent-ns-records", Title: "Parent NS Records",
Status: StatusFail, Message: "Cannot determine parent zone",
})
return cat
}
parent := dns.Fqdn(strings.Join(parts[1:], "."))
// Find parent zone nameservers.
parentNSNames := findNSForZone(parent, r)
parentNSIPs := resolveNames(parentNSNames, r)
// Query a parent NS for delegation NS records.
var delegationNS []string
var glueA []string
var glueAAAA []string
var queryServer string
for _, pip := range parentNSIPs {
resp, err := r.QueryNoRecurse(domain, pip, dns.TypeNS)
if err != nil {
continue
}
for _, rr := range resp.Ns {
if ns, ok := rr.(*dns.NS); ok {
delegationNS = appendUniqLower(delegationNS, ns.Ns)
}
}
for _, rr := range resp.Answer {
if ns, ok := rr.(*dns.NS); ok {
delegationNS = appendUniqLower(delegationNS, ns.Ns)
}
}
// Collect glue records from additional section.
for _, rr := range resp.Extra {
switch v := rr.(type) {
case *dns.A:
glueA = append(glueA, fmt.Sprintf("%s -> %s", v.Hdr.Name, v.A.String()))
case *dns.AAAA:
glueAAAA = append(glueAAAA, fmt.Sprintf("%s -> %s", v.Hdr.Name, v.AAAA.String()))
}
}
if len(delegationNS) > 0 {
queryServer = pip
break
}
}
// 1. parent-ns-records
cat.Checks = append(cat.Checks, checkParentNSRecords(delegationNS, queryServer))
// 2. parent-ns-ips
cat.Checks = append(cat.Checks, checkParentNSIPs(delegationNS, r))
// 3. parent-glue
cat.Checks = append(cat.Checks, checkParentGlue(delegationNS, domain, glueA, glueAAAA, r))
// 4. parent-ns-count
cat.Checks = append(cat.Checks, checkParentNSCount(delegationNS))
// 5. parent-consistency
cat.Checks = append(cat.Checks, checkParentConsistency(domain, delegationNS, r))
return cat
}
func checkParentNSRecords(delegationNS []string, server string) CheckResult {
start := time.Now()
res := CheckResult{ID: "parent-ns-records", Title: "Parent NS Records"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(delegationNS) == 0 {
res.Status = StatusFail
res.Message = "Your domain does not have NS records at the parent zone. This means your domain cannot be resolved. You need to configure NS records at your domain registrar."
return res
}
res.Status = StatusPass
res.Message = fmt.Sprintf("Found %d nameservers at parent zone (queried %s): %s", len(delegationNS), server, strings.Join(delegationNS, ", "))
for _, ns := range delegationNS {
res.Details = append(res.Details, ns)
}
return res
}
func checkParentNSIPs(delegationNS []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "parent-ns-ips", Title: "Parent NS IP Addresses"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(delegationNS) == 0 {
res.Status = StatusFail
res.Message = "No parent NS to check"
return res
}
allResolved := true
var unresolvedNS []string
for _, ns := range delegationNS {
ips := resolveNS(ns, r)
if len(ips) == 0 {
allResolved = false
unresolvedNS = append(unresolvedNS, ns)
res.Details = append(res.Details, fmt.Sprintf("%s has no A/AAAA records — cannot be reached", ns))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s resolves to %s", ns, strings.Join(ips, ", ")))
}
}
if allResolved {
res.Status = StatusPass
res.Message = "All nameservers listed at parent have resolvable IP addresses"
} else {
res.Status = StatusFail
res.Message = fmt.Sprintf("The following nameservers cannot be resolved to IP addresses: %s. Make sure these hostnames have A or AAAA records configured.", strings.Join(unresolvedNS, ", "))
}
return res
}
func checkParentGlue(delegationNS []string, domain string, glueA, glueAAAA []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "parent-glue", Title: "Glue Records"}
defer func() { res.DurationMs = measureDuration(start) }()
// Check which NS are in-bailiwick.
var inBailiwick []string
for _, ns := range delegationNS {
if isInBailiwick(ns, domain) {
inBailiwick = append(inBailiwick, ns)
}
}
if len(inBailiwick) == 0 {
res.Status = StatusInfo
res.Message = "Your nameservers are not under your domain, so glue records are not required"
return res
}
allGlue := append(glueA, glueAAAA...)
if len(allGlue) == 0 {
res.Status = StatusFail
res.Message = fmt.Sprintf("Your nameservers (%s) are under your own domain (in-bailiwick), which requires glue records at the parent zone. No glue records were found. Contact your registrar to add glue (A) records for your nameservers.", strings.Join(inBailiwick, ", "))
return res
}
// Compare glue IPs with actual IPs from the nameservers themselves.
glueMap := make(map[string][]string) // ns -> glue IPs
for _, g := range glueA {
parts := strings.SplitN(g, " -> ", 2)
if len(parts) == 2 {
glueMap[strings.ToLower(parts[0])] = append(glueMap[strings.ToLower(parts[0])], parts[1])
}
}
var mismatches []string
for _, ns := range inBailiwick {
nsLower := strings.ToLower(ns)
glueIPs := glueMap[nsLower]
actualIPs := resolveNS(ns, r)
if len(glueIPs) > 0 && len(actualIPs) > 0 {
glueSorted := sortedStrings(glueIPs)
// Only compare A records (filter out IPv6 from actual).
var actualV4 []string
for _, ip := range actualIPs {
if !strings.Contains(ip, ":") {
actualV4 = append(actualV4, ip)
}
}
actualSorted := sortedStrings(actualV4)
if strings.Join(glueSorted, ",") != strings.Join(actualSorted, ",") {
mismatches = append(mismatches, fmt.Sprintf("For %s the parent reported: %v and your nameservers reported: %v", ns, glueSorted, actualSorted))
}
}
}
if len(mismatches) > 0 {
res.Status = StatusWarn
res.Message = "The A records (glue) from the parent zone are different from those reported by your nameservers. Make sure your parent zone has the same IP addresses for your nameservers as your actual DNS configuration."
res.Details = mismatches
res.Details = append(res.Details, "")
res.Details = append(res.Details, "Glue records from parent:")
res.Details = append(res.Details, allGlue...)
return res
}
res.Status = StatusPass
res.Message = fmt.Sprintf("Glue records present and consistent for %d in-bailiwick nameservers", len(inBailiwick))
res.Details = allGlue
return res
}
func checkParentNSCount(delegationNS []string) CheckResult {
start := time.Now()
res := CheckResult{ID: "parent-ns-count", Title: "Parent NS Count"}
defer func() { res.DurationMs = measureDuration(start) }()
count := len(delegationNS)
switch {
case count == 0:
res.Status = StatusFail
res.Message = "No nameservers found at parent zone. Your domain will not resolve. Add NS records at your registrar."
case count == 1:
res.Status = StatusWarn
res.Message = fmt.Sprintf("You only have 1 nameserver (%s). RFC 2182 recommends at least 2 nameservers for redundancy. If this server goes down, your domain will be unreachable.", delegationNS[0])
res.Details = delegationNS
default:
res.Status = StatusPass
res.Message = fmt.Sprintf("You have %d nameservers, which is good for redundancy", count)
res.Details = delegationNS
}
return res
}
func checkParentConsistency(domain string, delegationNS []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "parent-consistency", Title: "Parent/Auth NS Consistency"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(delegationNS) == 0 {
res.Status = StatusFail
res.Message = "No parent NS to compare"
return res
}
// Get authoritative NS set by querying one of the delegated NS.
var authNS []string
for _, ns := range delegationNS {
ips := resolveNS(ns, r)
for _, ip := range ips {
authNS = nsNamesFromAuthNS(domain, ip, r)
if len(authNS) > 0 {
break
}
}
if len(authNS) > 0 {
break
}
}
if len(authNS) == 0 {
res.Status = StatusWarn
res.Message = "Could not retrieve authoritative NS set for comparison"
return res
}
parentSet := sortedStrings(delegationNS)
authSet := sortedStrings(authNS)
parentStr := strings.Join(parentSet, ",")
authStr := strings.Join(authSet, ",")
if parentStr == authStr {
res.Status = StatusPass
res.Message = "The NS records at the parent zone match the NS records your nameservers return. Everything is consistent."
res.Details = append(res.Details, fmt.Sprintf("NS at parent: %s", strings.Join(parentSet, ", ")))
res.Details = append(res.Details, fmt.Sprintf("NS at auth: %s", strings.Join(authSet, ", ")))
} else {
res.Status = StatusWarn
res.Message = "The NS records at the parent zone do NOT match what your nameservers report. You need to update either your registrar's NS records or your zone's NS records so they are consistent."
onlyParent := diff(parentSet, authSet)
onlyAuth := diff(authSet, parentSet)
if len(onlyParent) > 0 {
for _, ns := range onlyParent {
res.Details = append(res.Details, fmt.Sprintf("%s is listed at the parent zone but NOT in your zone — either add it to your zone or remove it from the parent (registrar)", ns))
}
}
if len(onlyAuth) > 0 {
for _, ns := range onlyAuth {
res.Details = append(res.Details, fmt.Sprintf("%s is in your zone but NOT at the parent — either add it at your registrar or remove it from your zone", ns))
}
}
res.Details = append(res.Details, fmt.Sprintf("Parent reports: %s", strings.Join(parentSet, ", ")))
res.Details = append(res.Details, fmt.Sprintf("Your zone reports: %s", strings.Join(authSet, ", ")))
}
return res
}
func diff(a, b []string) []string {
sort.Strings(a)
sort.Strings(b)
set := make(map[string]bool)
for _, v := range b {
set[v] = true
}
var result []string
for _, v := range a {
if !set[v] {
result = append(result, v)
}
}
return result
}

View File

@@ -0,0 +1,293 @@
package checker
import (
"fmt"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkSOA runs the 7 SOA checks.
func checkSOA(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "soa", Title: "SOA Record"}
domain = dns.Fqdn(domain)
// Get the SOA record.
resp, err := r.Query(domain, "8.8.8.8", dns.TypeSOA)
if err != nil || resp == nil {
cat.Checks = append(cat.Checks, CheckResult{
ID: "soa-present", Title: "SOA Present",
Status: StatusFail, Message: fmt.Sprintf("Failed to query SOA: %v", err),
})
return cat
}
var soa *dns.SOA
for _, rr := range resp.Answer {
if s, ok := rr.(*dns.SOA); ok {
soa = s
break
}
}
// 1. soa-present
cat.Checks = append(cat.Checks, checkSOAPresent(soa))
if soa == nil {
// Remaining checks require a SOA record.
return cat
}
// Get NS list for serial consistency check.
nsResp, _ := r.Query(domain, "8.8.8.8", dns.TypeNS)
var nsNames []string
if nsResp != nil {
for _, rr := range nsResp.Answer {
if ns, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, ns.Ns)
}
}
}
// 2. soa-serial-consistent
cat.Checks = append(cat.Checks, checkSOASerialConsistent(domain, nsNames, r))
// 3. soa-mname-valid
cat.Checks = append(cat.Checks, checkSOAMnameValid(soa, r))
// 4. soa-rname-valid
cat.Checks = append(cat.Checks, checkSOARnameValid(soa))
// 5. soa-refresh
cat.Checks = append(cat.Checks, checkSOARefresh(soa))
// 6. soa-retry
cat.Checks = append(cat.Checks, checkSOARetry(soa))
// 7. soa-expire
cat.Checks = append(cat.Checks, checkSOAExpire(soa))
return cat
}
func checkSOAPresent(soa *dns.SOA) CheckResult {
start := time.Now()
res := CheckResult{ID: "soa-present", Title: "SOA Present"}
defer func() { res.DurationMs = measureDuration(start) }()
if soa == nil {
res.Status = StatusFail
res.Message = "No SOA record found"
return res
}
res.Status = StatusPass
res.Message = "SOA record found"
res.Details = []string{
fmt.Sprintf("MNAME: %s", soa.Ns),
fmt.Sprintf("RNAME: %s", soa.Mbox),
fmt.Sprintf("Serial: %d", soa.Serial),
fmt.Sprintf("Refresh: %d", soa.Refresh),
fmt.Sprintf("Retry: %d", soa.Retry),
fmt.Sprintf("Expire: %d", soa.Expire),
fmt.Sprintf("Minimum TTL: %d", soa.Minttl),
}
return res
}
func checkSOASerialConsistent(domain string, nsNames []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "soa-serial-consistent", Title: "SOA Serial Consistency"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(nsNames) == 0 {
res.Status = StatusWarn
res.Message = "No nameservers to compare serials"
return res
}
serials := make(map[uint32][]string)
for _, ns := range nsNames {
ips := resolveNS(ns, r)
for _, ip := range ips {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): error", ns, ip))
continue
}
for _, rr := range resp.Answer {
if soa, ok := rr.(*dns.SOA); ok {
serials[soa.Serial] = append(serials[soa.Serial], fmt.Sprintf("%s (%s)", ns, ip))
res.Details = append(res.Details, fmt.Sprintf("%s (%s): serial %d", ns, ip, soa.Serial))
}
}
break // only first IP per NS
}
}
if len(serials) == 0 {
res.Status = StatusWarn
res.Message = "Could not retrieve SOA serial from any nameserver"
} else if len(serials) == 1 {
res.Status = StatusPass
for serial := range serials {
res.Message = fmt.Sprintf("All nameservers have consistent serial %d", serial)
}
} else {
res.Status = StatusFail
res.Message = fmt.Sprintf("Inconsistent SOA serials across nameservers (%d different values)", len(serials))
}
return res
}
func checkSOAMnameValid(soa *dns.SOA, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "soa-mname-valid", Title: "SOA MNAME Valid"}
defer func() { res.DurationMs = measureDuration(start) }()
mname := soa.Ns
if mname == "" || mname == "." {
res.Status = StatusFail
res.Message = "MNAME is empty or root"
return res
}
res.Details = append(res.Details, fmt.Sprintf("MNAME: %s", mname))
// Check if it resolves to an A record.
resp, err := r.Query(mname, "8.8.8.8", dns.TypeA)
if err != nil {
res.Status = StatusWarn
res.Message = fmt.Sprintf("MNAME %s could not be resolved", mname)
return res
}
hasA := false
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
hasA = true
res.Details = append(res.Details, fmt.Sprintf("Resolves to: %s", a.A.String()))
}
}
if hasA {
res.Status = StatusPass
res.Message = fmt.Sprintf("MNAME %s is valid and resolves", mname)
} else {
res.Status = StatusWarn
res.Message = fmt.Sprintf("MNAME %s does not have an A record", mname)
}
return res
}
func checkSOARnameValid(soa *dns.SOA) CheckResult {
start := time.Now()
res := CheckResult{ID: "soa-rname-valid", Title: "SOA RNAME Valid"}
defer func() { res.DurationMs = measureDuration(start) }()
rname := soa.Mbox
if rname == "" || rname == "." {
res.Status = StatusFail
res.Message = "RNAME is empty"
return res
}
// RNAME should be an email address encoded as DNS name.
// The first "." that is not escaped represents the "@".
parts := dns.SplitDomainName(rname)
if len(parts) < 2 {
res.Status = StatusWarn
res.Message = fmt.Sprintf("RNAME %s appears malformed", rname)
return res
}
// Convert to email format for display.
email := parts[0] + "@" + strings.Join(parts[1:], ".")
res.Details = append(res.Details, fmt.Sprintf("RNAME: %s (email: %s)", rname, email))
res.Status = StatusPass
res.Message = fmt.Sprintf("RNAME is properly formatted (%s)", email)
return res
}
func checkSOARefresh(soa *dns.SOA) CheckResult {
start := time.Now()
res := CheckResult{ID: "soa-refresh", Title: "SOA Refresh"}
defer func() { res.DurationMs = measureDuration(start) }()
val := soa.Refresh
res.Details = append(res.Details, fmt.Sprintf("Refresh: %d seconds (%s)", val, humanDuration(val)))
if val >= 1200 && val <= 43200 {
res.Status = StatusPass
res.Message = fmt.Sprintf("Refresh value %d is within recommended range (1200-43200)", val)
} else {
res.Status = StatusWarn
if val < 1200 {
res.Message = fmt.Sprintf("Refresh value %d is too low (recommended minimum 1200)", val)
} else {
res.Message = fmt.Sprintf("Refresh value %d is too high (recommended maximum 43200)", val)
}
}
return res
}
func checkSOARetry(soa *dns.SOA) CheckResult {
start := time.Now()
res := CheckResult{ID: "soa-retry", Title: "SOA Retry"}
defer func() { res.DurationMs = measureDuration(start) }()
val := soa.Retry
res.Details = append(res.Details, fmt.Sprintf("Retry: %d seconds (%s)", val, humanDuration(val)))
if val >= 120 && val <= 7200 {
res.Status = StatusPass
res.Message = fmt.Sprintf("Retry value %d is within recommended range (120-7200)", val)
} else {
res.Status = StatusWarn
if val < 120 {
res.Message = fmt.Sprintf("Retry value %d is too low (recommended minimum 120)", val)
} else {
res.Message = fmt.Sprintf("Retry value %d is too high (recommended maximum 7200)", val)
}
}
return res
}
func checkSOAExpire(soa *dns.SOA) CheckResult {
start := time.Now()
res := CheckResult{ID: "soa-expire", Title: "SOA Expire"}
defer func() { res.DurationMs = measureDuration(start) }()
val := soa.Expire
res.Details = append(res.Details, fmt.Sprintf("Expire: %d seconds (%s)", val, humanDuration(val)))
if val >= 604800 && val <= 4838400 {
res.Status = StatusPass
res.Message = fmt.Sprintf("Expire value %d is within recommended range (604800-4838400)", val)
} else {
res.Status = StatusWarn
if val < 604800 {
res.Message = fmt.Sprintf("Expire value %d is too low (recommended minimum 604800 = 1 week)", val)
} else {
res.Message = fmt.Sprintf("Expire value %d is too high (recommended maximum 4838400 = 8 weeks)", val)
}
}
return res
}
func humanDuration(seconds uint32) string {
if seconds < 60 {
return fmt.Sprintf("%d seconds", seconds)
}
if seconds < 3600 {
return fmt.Sprintf("%d minutes", seconds/60)
}
if seconds < 86400 {
return fmt.Sprintf("%.1f hours", float64(seconds)/3600)
}
return fmt.Sprintf("%.1f days", float64(seconds)/86400)
}

View File

@@ -0,0 +1,45 @@
package checker
// CheckStatus represents the outcome of a single check.
type CheckStatus string
const (
StatusPass CheckStatus = "pass"
StatusWarn CheckStatus = "warn"
StatusFail CheckStatus = "fail"
StatusInfo CheckStatus = "info"
)
// CheckResult holds the outcome of one check.
type CheckResult struct {
ID string `json:"id"`
Title string `json:"title"`
Status CheckStatus `json:"status"`
Message string `json:"message"`
Details []string `json:"details,omitempty"`
DurationMs int64 `json:"duration_ms"`
}
// Category groups related checks together.
type Category struct {
Name string `json:"name"`
Title string `json:"title"`
Checks []CheckResult `json:"checks"`
}
// Summary tallies check outcomes.
type Summary struct {
Pass int `json:"pass"`
Warn int `json:"warn"`
Fail int `json:"fail"`
Info int `json:"info"`
}
// Report is the top-level result for a domain check.
type Report struct {
Domain string `json:"domain"`
Timestamp string `json:"timestamp"`
DurationMs int64 `json:"duration_ms"`
Summary Summary `json:"summary"`
Categories []Category `json:"categories"`
}

View File

@@ -0,0 +1,246 @@
package checker
import (
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// isPublicIP returns true if the IP is a globally routable unicast address.
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return false
}
// Check private ranges.
privateRanges := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
"100.64.0.0/10", // Carrier-grade NAT
"169.254.0.0/16", // Link-local
"192.0.0.0/24", // IETF protocol assignments
"198.18.0.0/15", // Benchmarking
}
for _, cidr := range privateRanges {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(ip) {
return false
}
}
return true
}
// reverseDNS builds the in-addr.arpa or ip6.arpa name for an IP.
func reverseDNS(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
if ip4 := ip.To4(); ip4 != nil {
return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa.", ip4[3], ip4[2], ip4[1], ip4[0])
}
// IPv6
full := ip.To16()
if full == nil {
return ""
}
var buf strings.Builder
for i := len(full) - 1; i >= 0; i-- {
b := full[i]
buf.WriteString(fmt.Sprintf("%x.%x.", b&0x0f, (b>>4)&0x0f))
}
buf.WriteString("ip6.arpa.")
return buf.String()
}
// findParentNS locates the parent zone's nameservers for the given domain by
// querying the TLD nameservers. Returns NS hostnames and their resolved IPs.
func findParentNS(domain string, r *resolver.Resolver) ([]string, []string) {
domain = dns.Fqdn(domain)
parts := dns.SplitDomainName(domain)
if len(parts) < 2 {
return nil, nil
}
// Build the parent zone (e.g., for "example.com." -> "com.")
parent := dns.Fqdn(strings.Join(parts[1:], "."))
// First, find the TLD/parent zone nameservers.
parentNS := findNSForZone(parent, r)
if len(parentNS) == 0 {
return nil, nil
}
// Resolve a parent NS to an IP so we can query it.
parentIPs := resolveNames(parentNS, r)
if len(parentIPs) == 0 {
return nil, nil
}
// Query a parent NS for the domain's NS records (non-recursive).
var nsNames []string
for _, pip := range parentIPs {
resp, err := r.QueryNoRecurse(domain, pip, dns.TypeNS)
if err != nil {
continue
}
// The NS records might be in the authority section (delegation) or answer.
for _, rr := range resp.Ns {
if nsRR, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, nsRR.Ns)
}
}
for _, rr := range resp.Answer {
if nsRR, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, nsRR.Ns)
}
}
if len(nsNames) > 0 {
break
}
}
// Collect glue / resolve NS IPs.
var nsIPs []string
for _, ns := range nsNames {
ips := resolveNS(ns, r)
nsIPs = append(nsIPs, ips...)
}
return nsNames, nsIPs
}
// findNSForZone returns the nameserver hostnames for a zone.
func findNSForZone(zone string, r *resolver.Resolver) []string {
// Use the system resolver (recursive) to find NS for the zone.
resp, err := r.Query(zone, "8.8.8.8", dns.TypeNS)
if err != nil {
return nil
}
var names []string
for _, rr := range resp.Answer {
if ns, ok := rr.(*dns.NS); ok {
names = appendUniqLower(names, ns.Ns)
}
}
return names
}
// resolveNS returns IP addresses for a nameserver hostname.
func resolveNS(name string, r *resolver.Resolver) []string {
var ips []string
resp, err := r.Query(name, "8.8.8.8", dns.TypeA)
if err == nil {
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
}
resp, err = r.Query(name, "8.8.8.8", dns.TypeAAAA)
if err == nil {
for _, rr := range resp.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
ips = append(ips, aaaa.AAAA.String())
}
}
}
return ips
}
// resolveNames resolves a list of hostnames to IPs.
func resolveNames(names []string, r *resolver.Resolver) []string {
var ips []string
for _, n := range names {
ips = append(ips, resolveNS(n, r)...)
}
return ips
}
// appendUniqLower adds s (lowered, FQDN) to the slice if not already present.
func appendUniqLower(slice []string, s string) []string {
s = strings.ToLower(dns.Fqdn(s))
for _, v := range slice {
if v == s {
return slice
}
}
return append(slice, s)
}
// measureDuration returns the elapsed time in milliseconds since start.
func measureDuration(start time.Time) int64 {
return time.Since(start).Milliseconds()
}
// sortedStrings returns a sorted copy.
func sortedStrings(s []string) []string {
c := make([]string, len(s))
copy(c, s)
sort.Strings(c)
return c
}
// nsNamesFromAuthNS queries the authoritative nameservers for the domain's NS
// records directly.
func nsNamesFromAuthNS(domain string, nsIP string, r *resolver.Resolver) []string {
resp, err := r.QueryNoRecurse(domain, nsIP, dns.TypeNS)
if err != nil {
return nil
}
var names []string
for _, rr := range resp.Answer {
if ns, ok := rr.(*dns.NS); ok {
names = appendUniqLower(names, ns.Ns)
}
}
return names
}
// subnet24 returns the /24 prefix string for an IPv4 address.
func subnet24(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
return ip
}
if v4 := parsed.To4(); v4 != nil {
return fmt.Sprintf("%d.%d.%d.0/24", v4[0], v4[1], v4[2])
}
// For IPv6 just use the first 48 bits as a rough grouping.
full := parsed.To16()
return fmt.Sprintf("%x:%x:%x::/48", uint16(full[0])<<8|uint16(full[1]),
uint16(full[2])<<8|uint16(full[3]), uint16(full[4])<<8|uint16(full[5]))
}
// subnet16 returns the /16 prefix string as a rough AS-diversity proxy.
func subnet16(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
return ip
}
if v4 := parsed.To4(); v4 != nil {
return fmt.Sprintf("%d.%d.0.0/16", v4[0], v4[1])
}
full := parsed.To16()
return fmt.Sprintf("%x:%x::/32", uint16(full[0])<<8|uint16(full[1]),
uint16(full[2])<<8|uint16(full[3]))
}
// isInBailiwick returns true if the NS hostname is under the domain.
func isInBailiwick(nsName, domain string) bool {
nsName = strings.ToLower(dns.Fqdn(nsName))
domain = strings.ToLower(dns.Fqdn(domain))
return dns.IsSubDomain(domain, nsName)
}

View File

@@ -0,0 +1,469 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
)
// checkDomainWhois checks domain registration and expiry via RDAP.
func checkDomainWhois(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "registration", Title: "Domain Registration"}
cleanDomain := strings.TrimSuffix(domain, ".")
// Get RDAP info, fall back to whois CLI
rdap := fetchRDAP(cleanDomain)
var whoisData *whoisInfo
if rdap == nil {
whoisData = fetchWhoisCLI(cleanDomain)
}
// 1. Domain expiry check
if rdap != nil {
cat.Checks = append(cat.Checks, checkDomainExpiry(cleanDomain, rdap))
} else {
cat.Checks = append(cat.Checks, checkDomainExpiryWhois(cleanDomain, whoisData))
}
// 2. Registration info
if rdap != nil {
cat.Checks = append(cat.Checks, checkRegistrationInfo(cleanDomain, rdap))
} else {
cat.Checks = append(cat.Checks, checkRegistrationInfoWhois(cleanDomain, whoisData))
}
return cat
}
// whoisInfo holds parsed whois CLI output.
type whoisInfo struct {
Registered string
Expires string
Registrar string
RegistrarURL string
Status string
Contact string
RawLines []string
}
func fetchWhoisCLI(domain string) *whoisInfo {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "whois", domain)
out, err := cmd.Output()
if err != nil {
return nil
}
info := &whoisInfo{}
lines := strings.Split(string(out), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "%") || line == "" {
continue
}
info.RawLines = append(info.RawLines, line)
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(strings.ToLower(parts[0]))
val := strings.TrimSpace(parts[1])
switch {
case key == "expires" || key == "expiry date" || key == "registry expiry date" || key == "paid-till" || key == "expire date":
info.Expires = val
case key == "registered" || key == "creation date" || key == "created":
info.Registered = val
case key == "registrar" || key == "registrar name":
info.Registrar = val
case key == "registrar website" || key == "registrar url":
info.RegistrarURL = val
case key == "status" || key == "domain status":
if info.Status == "" {
info.Status = val
} else {
info.Status += ", " + val
}
case key == "contact organization" || key == "registrant organization":
info.Contact = val
}
}
return info
}
func checkDomainExpiryWhois(domain string, w *whoisInfo) CheckResult {
start := time.Now()
res := CheckResult{ID: "domain-expiry", Title: "Domain Expiry"}
defer func() { res.DurationMs = measureDuration(start) }()
if w == nil || w.Expires == "" {
res.Status = StatusInfo
res.Message = "Could not retrieve domain expiry information"
return res
}
expiry, err := parseFlexDate(w.Expires)
if err != nil {
res.Status = StatusInfo
res.Message = fmt.Sprintf("Domain expires: %s", w.Expires)
return res
}
daysLeft := int(time.Until(expiry).Hours() / 24)
switch {
case daysLeft < 0:
res.Status = StatusFail
res.Message = fmt.Sprintf("Domain %s EXPIRED %d days ago (%s)! Renew immediately.", domain, -daysLeft, expiry.Format("2006-01-02"))
case daysLeft <= 15:
res.Status = StatusFail
res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Renew urgently!", domain, daysLeft, expiry.Format("2006-01-02"))
case daysLeft <= 30:
res.Status = StatusWarn
res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Consider renewing soon.", domain, daysLeft, expiry.Format("2006-01-02"))
default:
res.Status = StatusPass
res.Message = fmt.Sprintf("Domain %s expires in %d days (%s)", domain, daysLeft, expiry.Format("2006-01-02"))
}
if w.Registered != "" {
res.Details = append(res.Details, fmt.Sprintf("Registered: %s", w.Registered))
}
res.Details = append(res.Details, fmt.Sprintf("Expires: %s (%d days left)", expiry.Format("2006-01-02"), daysLeft))
return res
}
func checkRegistrationInfoWhois(domain string, w *whoisInfo) CheckResult {
start := time.Now()
res := CheckResult{ID: "domain-registrar", Title: "Registrar"}
defer func() { res.DurationMs = measureDuration(start) }()
if w == nil {
res.Status = StatusInfo
res.Message = "Could not retrieve registrar information"
return res
}
res.Status = StatusInfo
if w.Registrar != "" {
res.Message = fmt.Sprintf("Registered through %s", w.Registrar)
res.Details = append(res.Details, fmt.Sprintf("Registrar: %s", w.Registrar))
} else {
res.Message = "Registrar information not available"
}
if w.RegistrarURL != "" {
res.Details = append(res.Details, fmt.Sprintf("Registrar website: %s", w.RegistrarURL))
}
if w.Status != "" {
res.Details = append(res.Details, fmt.Sprintf("Status: %s", w.Status))
}
if w.Contact != "" {
res.Details = append(res.Details, fmt.Sprintf("Registrant: %s", w.Contact))
}
return res
}
func parseFlexDate(s string) (time.Time, error) {
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02",
"02-Jan-2006",
"2006.01.02",
"02/01/2006",
"01/02/2006",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
t, err := time.Parse(layout, s)
if err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse date: %s", s)
}
type rdapResponse struct {
Handle string `json:"handle"`
Name string `json:"ldhName"`
Status []string `json:"status"`
Events []rdapEvent `json:"events"`
Entities []rdapEntity `json:"entities"`
Nameservers []rdapNS `json:"nameservers"`
}
type rdapEvent struct {
Action string `json:"eventAction"`
Date string `json:"eventDate"`
}
type rdapEntity struct {
Roles []string `json:"roles"`
Handle string `json:"handle"`
VcardArr []interface{} `json:"vcardArray"`
Entities []rdapEntity `json:"entities"`
PublicIDs []rdapPublicID `json:"publicIds"`
}
type rdapPublicID struct {
Type string `json:"type"`
Identifier string `json:"identifier"`
}
type rdapNS struct {
LdhName string `json:"ldhName"`
}
func fetchRDAP(domain string) *rdapResponse {
// Determine RDAP server based on TLD
tld := domain
if idx := strings.LastIndex(domain, "."); idx >= 0 {
tld = domain[idx+1:]
}
// Try IANA bootstrap first, then known servers
urls := []string{
fmt.Sprintf("https://rdap.org/domain/%s", domain),
}
// Add known TLD-specific RDAP servers
switch strings.ToLower(tld) {
case "com", "net":
urls = append([]string{fmt.Sprintf("https://rdap.verisign.com/com/v1/domain/%s", domain)}, urls...)
case "org":
urls = append([]string{fmt.Sprintf("https://rdap.org/domain/%s", domain)}, urls...)
case "lt":
urls = append([]string{fmt.Sprintf("https://rdap.domreg.lt/domain/%s", domain)}, urls...)
case "eu":
urls = append([]string{fmt.Sprintf("https://rdap.eu/domain/%s", domain)}, urls...)
case "io":
urls = append([]string{fmt.Sprintf("https://rdap.nic.io/domain/%s", domain)}, urls...)
case "dev":
urls = append([]string{fmt.Sprintf("https://rdap.nic.google/domain/%s", domain)}, urls...)
}
client := &http.Client{Timeout: 5 * time.Second}
for _, url := range urls {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
continue
}
req.Header.Set("Accept", "application/rdap+json")
resp, err := client.Do(req)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
if err != nil {
continue
}
var rdap rdapResponse
if err := json.Unmarshal(body, &rdap); err != nil {
continue
}
return &rdap
}
return nil
}
func checkDomainExpiry(domain string, rdap *rdapResponse) CheckResult {
start := time.Now()
res := CheckResult{ID: "domain-expiry", Title: "Domain Expiry"}
defer func() { res.DurationMs = measureDuration(start) }()
if rdap == nil {
res.Status = StatusInfo
res.Message = "Could not retrieve domain registration information"
return res
}
var expiryDate, registrationDate, lastChanged string
for _, event := range rdap.Events {
switch event.Action {
case "expiration":
expiryDate = event.Date
case "registration":
registrationDate = event.Date
case "last changed", "last update of RDAP database":
if lastChanged == "" || event.Action == "last changed" {
lastChanged = event.Date
}
}
}
if expiryDate == "" {
res.Status = StatusInfo
res.Message = "Domain expiry date not available"
if registrationDate != "" {
res.Details = append(res.Details, fmt.Sprintf("Registered: %s", formatDate(registrationDate)))
}
return res
}
expiry, err := time.Parse(time.RFC3339, expiryDate)
if err != nil {
// Try other formats
for _, layout := range []string{"2006-01-02T15:04:05Z", "2006-01-02", "2006-01-02 15:04:05"} {
expiry, err = time.Parse(layout, expiryDate)
if err == nil {
break
}
}
}
if err != nil {
res.Status = StatusInfo
res.Message = fmt.Sprintf("Domain expires: %s (could not parse date)", expiryDate)
return res
}
daysLeft := int(time.Until(expiry).Hours() / 24)
switch {
case daysLeft < 0:
res.Status = StatusFail
res.Message = fmt.Sprintf("Domain %s EXPIRED %d days ago (%s)! Renew immediately or risk losing the domain.", domain, -daysLeft, formatDate(expiryDate))
case daysLeft <= 15:
res.Status = StatusFail
res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Renew urgently!", domain, daysLeft, formatDate(expiryDate))
case daysLeft <= 30:
res.Status = StatusWarn
res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Consider renewing soon.", domain, daysLeft, formatDate(expiryDate))
default:
res.Status = StatusPass
res.Message = fmt.Sprintf("Domain %s expires in %d days (%s)", domain, daysLeft, formatDate(expiryDate))
}
if registrationDate != "" {
res.Details = append(res.Details, fmt.Sprintf("Registered: %s", formatDate(registrationDate)))
}
res.Details = append(res.Details, fmt.Sprintf("Expires: %s (%d days left)", formatDate(expiryDate), daysLeft))
if lastChanged != "" {
res.Details = append(res.Details, fmt.Sprintf("Last changed: %s", formatDate(lastChanged)))
}
return res
}
func checkRegistrationInfo(domain string, rdap *rdapResponse) CheckResult {
start := time.Now()
res := CheckResult{ID: "domain-registrar", Title: "Registrar"}
defer func() { res.DurationMs = measureDuration(start) }()
if rdap == nil {
res.Status = StatusInfo
res.Message = "Could not retrieve registrar information"
return res
}
// Find registrar entity
var registrarName string
var registrarHandle string
for _, entity := range rdap.Entities {
for _, role := range entity.Roles {
if strings.ToLower(role) == "registrar" {
registrarHandle = entity.Handle
// Try to extract name from vcard
registrarName = extractVcardName(entity.VcardArr)
if registrarName == "" {
registrarName = entity.Handle
}
}
}
}
// Domain status
if len(rdap.Status) > 0 {
res.Details = append(res.Details, fmt.Sprintf("Status: %s", strings.Join(rdap.Status, ", ")))
}
if registrarName != "" {
res.Status = StatusInfo
res.Message = fmt.Sprintf("Registered through %s", registrarName)
if registrarHandle != "" && registrarHandle != registrarName {
res.Details = append(res.Details, fmt.Sprintf("Registrar: %s (%s)", registrarName, registrarHandle))
} else {
res.Details = append(res.Details, fmt.Sprintf("Registrar: %s", registrarName))
}
} else {
res.Status = StatusInfo
res.Message = "Registrar information not available"
}
// Nameservers from RDAP
if len(rdap.Nameservers) > 0 {
var nsNames []string
for _, ns := range rdap.Nameservers {
nsNames = append(nsNames, ns.LdhName)
}
res.Details = append(res.Details, fmt.Sprintf("Nameservers: %s", strings.Join(nsNames, ", ")))
}
return res
}
func extractVcardName(vcardArr []interface{}) string {
if len(vcardArr) < 2 {
return ""
}
entries, ok := vcardArr[1].([]interface{})
if !ok {
return ""
}
for _, entry := range entries {
arr, ok := entry.([]interface{})
if !ok || len(arr) < 4 {
continue
}
propName, ok := arr[0].(string)
if !ok {
continue
}
if propName == "fn" || propName == "org" {
if val, ok := arr[3].(string); ok && val != "" {
return val
}
}
}
return ""
}
func formatDate(dateStr string) string {
t, err := time.Parse(time.RFC3339, dateStr)
if err != nil {
for _, layout := range []string{"2006-01-02T15:04:05Z", "2006-01-02"} {
t, err = time.Parse(layout, dateStr)
if err == nil {
break
}
}
}
if err != nil {
return dateStr
}
return t.Format("2006-01-02")
}

View File

@@ -0,0 +1,105 @@
package checker
import (
"fmt"
"net"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkWWW runs the 2 WWW checks.
func checkWWW(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "www", Title: "WWW"}
domain = dns.Fqdn(domain)
wwwName := "www." + domain
// 1. www-a-record
cat.Checks = append(cat.Checks, checkWWWARecord(wwwName, r))
// 2. www-cname
cat.Checks = append(cat.Checks, checkWWWCNAME(wwwName, r))
return cat
}
func checkWWWARecord(wwwName string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "www-a-record", Title: "WWW A Record"}
defer func() { res.DurationMs = measureDuration(start) }()
resp, err := r.Query(wwwName, "8.8.8.8", dns.TypeA)
if err != nil {
res.Status = StatusWarn
res.Message = fmt.Sprintf("Failed to query A record for %s: %v", wwwName, err)
return res
}
var ips []string
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
if len(ips) == 0 {
res.Status = StatusInfo
res.Message = fmt.Sprintf("No A record found for %s", wwwName)
return res
}
allPublic := true
for _, ipStr := range ips {
ip := net.ParseIP(ipStr)
if isPublicIP(ip) {
res.Details = append(res.Details, fmt.Sprintf("%s -> %s (public)", wwwName, ipStr))
} else {
allPublic = false
res.Details = append(res.Details, fmt.Sprintf("%s -> %s (NOT public)", wwwName, ipStr))
}
}
if allPublic {
res.Status = StatusPass
res.Message = fmt.Sprintf("%s has A record(s) with public IP(s)", wwwName)
} else {
res.Status = StatusWarn
res.Message = fmt.Sprintf("%s has A record(s) but some IPs are not public", wwwName)
}
return res
}
func checkWWWCNAME(wwwName string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "www-cname", Title: "WWW CNAME"}
defer func() { res.DurationMs = measureDuration(start) }()
resp, err := r.Query(wwwName, "8.8.8.8", dns.TypeCNAME)
if err != nil {
res.Status = StatusInfo
res.Message = fmt.Sprintf("Could not check CNAME for %s", wwwName)
return res
}
var targets []string
for _, rr := range resp.Answer {
if cname, ok := rr.(*dns.CNAME); ok {
targets = append(targets, cname.Target)
}
}
if len(targets) > 0 {
res.Status = StatusInfo
res.Message = fmt.Sprintf("%s is a CNAME to %s", wwwName, strings.Join(targets, ", "))
for _, t := range targets {
res.Details = append(res.Details, fmt.Sprintf("CNAME target: %s", t))
}
} else {
res.Status = StatusInfo
res.Message = fmt.Sprintf("%s is not a CNAME (direct A/AAAA record)", wwwName)
}
return res
}

View File

@@ -0,0 +1,234 @@
package resolver
import (
"fmt"
"strings"
"time"
"github.com/miekg/dns"
)
// Resolver wraps miekg/dns with timeout and retry logic.
type Resolver struct {
Timeout time.Duration
Retries int
}
// NewResolver creates a Resolver with sensible defaults.
func NewResolver() *Resolver {
return &Resolver{
Timeout: 3 * time.Second,
Retries: 1,
}
}
// Query sends a UDP DNS query. If the response is truncated it automatically
// retries over TCP.
func (r *Resolver) Query(name string, server string, qtype uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("udp query %s @%s: %w", dns.TypeToString[qtype], server, err)
}
// Fall back to TCP on truncation.
if resp.Truncated {
return r.QueryTCP(name, server, qtype)
}
return resp, nil
}
// QueryTCP sends a DNS query over TCP.
func (r *Resolver) QueryTCP(name string, server string, qtype uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "tcp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("tcp query %s @%s: %w", dns.TypeToString[qtype], server, err)
}
return resp, nil
}
// QueryNoRecurse sends a UDP query with RD=0 (non-recursive). Falls back to
// TCP on truncation.
func (r *Resolver) QueryNoRecurse(name string, server string, qtype uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = false
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("udp query (no recurse) %s @%s: %w", dns.TypeToString[qtype], server, err)
}
if resp.Truncated {
m.RecursionDesired = false
c2 := new(dns.Client)
c2.Timeout = r.Timeout
c2.Net = "tcp"
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c2.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("tcp query (no recurse) %s @%s: %w", dns.TypeToString[qtype], server, err)
}
}
return resp, nil
}
// QueryEDNS sends a UDP query with EDNS0 buffer size set.
func (r *Resolver) QueryEDNS(name string, server string, qtype uint16, bufsize uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = false
m.SetEdns0(bufsize, false)
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("edns query %s @%s: %w", dns.TypeToString[qtype], server, err)
}
return resp, nil
}
// QueryVersionBind asks for version.bind TXT in the CH class.
func (r *Resolver) QueryVersionBind(server string) (string, error) {
m := new(dns.Msg)
m.SetQuestion("version.bind.", dns.TypeTXT)
m.Question[0].Qclass = dns.ClassCHAOS
m.RecursionDesired = false
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return "", err
}
for _, rr := range resp.Answer {
if txt, ok := rr.(*dns.TXT); ok {
if len(txt.Txt) > 0 {
return txt.Txt[0], nil
}
}
}
return "", nil
}
// QueryAXFR attempts a zone transfer. Returns true if the server allows it.
// Uses a short timeout to avoid blocking on unresponsive servers.
func (r *Resolver) QueryAXFR(name string, server string) (bool, error) {
type axfrResult struct {
allowed bool
err error
}
ch := make(chan axfrResult, 1)
go func() {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeAXFR)
tr := new(dns.Transfer)
tr.DialTimeout = 3 * time.Second
tr.ReadTimeout = 3 * time.Second
env, err := tr.In(m, ensurePort(server))
if err != nil {
ch <- axfrResult{false, nil}
return
}
for e := range env {
if e.Error != nil {
ch <- axfrResult{false, nil}
return
}
if len(e.RR) > 0 {
ch <- axfrResult{true, nil}
return
}
}
ch <- axfrResult{false, nil}
}()
select {
case res := <-ch:
return res.allowed, res.err
case <-time.After(5 * time.Second):
return false, nil
}
}
func ensurePort(server string) string {
// Already has port (IPv4:port or [IPv6]:port).
if strings.Contains(server, "]:") || (!strings.Contains(server, "[") && strings.Count(server, ":") == 1) {
return server
}
// IPv6 without brackets/port.
if strings.Contains(server, ":") && !strings.Contains(server, "[") {
return "[" + server + "]:53"
}
return server + ":53"
}

View File

@@ -0,0 +1,196 @@
package store
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/intodns/backend/internal/checker"
_ "modernc.org/sqlite"
)
// HistoryEntry represents a saved DNS check result (without full report).
type HistoryEntry struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
Timestamp string `json:"timestamp"`
DurationMs int64 `json:"duration_ms"`
Summary struct {
Pass int `json:"pass"`
Warn int `json:"warn"`
Fail int `json:"fail"`
Info int `json:"info"`
} `json:"summary"`
ClientIP string `json:"client_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
// FullHistoryEntry includes the parsed report.
type FullHistoryEntry struct {
HistoryEntry
Report *checker.Report `json:"report"`
}
// Store wraps SQLite for persisting DNS check results.
type Store struct {
db *sql.DB
}
// NewStore opens the SQLite database at dbPath and creates the schema.
func NewStore(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// Enable WAL mode for better concurrency.
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("set WAL mode: %w", err)
}
schema := `
CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
timestamp TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
summary_pass INTEGER NOT NULL DEFAULT 0,
summary_warn INTEGER NOT NULL DEFAULT 0,
summary_fail INTEGER NOT NULL DEFAULT 0,
summary_info INTEGER NOT NULL DEFAULT 0,
client_ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
report_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_checks_domain ON checks(domain);
CREATE INDEX IF NOT EXISTS idx_checks_timestamp ON checks(timestamp);
`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("create schema: %w", err)
}
// Migrate: add columns if they don't exist (safe for existing DBs).
for _, col := range []string{
"ALTER TABLE checks ADD COLUMN client_ip TEXT NOT NULL DEFAULT ''",
"ALTER TABLE checks ADD COLUMN user_agent TEXT NOT NULL DEFAULT ''",
} {
db.Exec(col) // ignore errors — column may already exist
}
return &Store{db: db}, nil
}
// SaveReport serializes the report to JSON and inserts a row.
func (s *Store) SaveReport(report *checker.Report, clientIP, userAgent string) error {
reportJSON, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}
_, err = s.db.Exec(
`INSERT INTO checks (domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent, report_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
report.Domain,
report.Timestamp,
report.DurationMs,
report.Summary.Pass,
report.Summary.Warn,
report.Summary.Fail,
report.Summary.Info,
clientIP,
userAgent,
string(reportJSON),
)
if err != nil {
return fmt.Errorf("insert check: %w", err)
}
return nil
}
// GetHistory returns the latest N check entries for a specific domain.
func (s *Store) GetHistory(domain string, limit int) ([]HistoryEntry, error) {
rows, err := s.db.Query(
`SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent
FROM checks WHERE domain = ? ORDER BY id DESC LIMIT ?`,
domain, limit,
)
if err != nil {
return nil, fmt.Errorf("query history: %w", err)
}
defer rows.Close()
return scanEntries(rows)
}
// GetRecent returns the latest N check entries across all domains.
func (s *Store) GetRecent(limit int) ([]HistoryEntry, error) {
rows, err := s.db.Query(
`SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent
FROM checks ORDER BY id DESC LIMIT ?`,
limit,
)
if err != nil {
return nil, fmt.Errorf("query recent: %w", err)
}
defer rows.Close()
return scanEntries(rows)
}
// GetReport returns a single history entry with the full parsed report.
func (s *Store) GetReport(id int64) (*FullHistoryEntry, error) {
row := s.db.QueryRow(
`SELECT id, domain, timestamp, duration_ms, summary_pass, summary_warn, summary_fail, summary_info, client_ip, user_agent, report_json
FROM checks WHERE id = ?`,
id,
)
var e FullHistoryEntry
var reportJSON string
err := row.Scan(
&e.ID, &e.Domain, &e.Timestamp, &e.DurationMs,
&e.Summary.Pass, &e.Summary.Warn, &e.Summary.Fail, &e.Summary.Info,
&e.ClientIP, &e.UserAgent,
&reportJSON,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scan report: %w", err)
}
var report checker.Report
if err := json.Unmarshal([]byte(reportJSON), &report); err != nil {
return nil, fmt.Errorf("unmarshal report: %w", err)
}
e.Report = &report
return &e, nil
}
// Close closes the underlying database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func scanEntries(rows *sql.Rows) ([]HistoryEntry, error) {
var entries []HistoryEntry
for rows.Next() {
var e HistoryEntry
if err := rows.Scan(
&e.ID, &e.Domain, &e.Timestamp, &e.DurationMs,
&e.Summary.Pass, &e.Summary.Warn, &e.Summary.Fail, &e.Summary.Info,
&e.ClientIP, &e.UserAgent,
); err != nil {
return nil, fmt.Errorf("scan entry: %w", err)
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration: %w", err)
}
return entries, nil
}

35
backend/main.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"log"
"net/http"
"os"
"github.com/intodns/backend/internal/api"
"github.com/intodns/backend/internal/checker"
"github.com/intodns/backend/internal/store"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
dbPath := os.Getenv("DNSTEST_DB")
if dbPath == "" {
dbPath = "/opt/dnstest/data/history.db"
}
st, err := store.NewStore(dbPath)
if err != nil {
log.Fatalf("Failed to open store: %v", err)
}
defer st.Close()
ch := checker.NewChecker()
router := api.NewRouter(ch, st)
addr := ":8080"
log.Printf("Starting DNS health checker on %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("Server failed: %v", err)
}
}

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

54
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,54 @@
<template>
<div class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<header class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
<NuxtLink to="/" class="flex items-center gap-2 text-xl font-bold tracking-tight text-indigo-600 dark:text-indigo-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
DNS Test
</NuxtLink>
<button
@click="toggleTheme"
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label="Toggle theme"
>
<svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
</div>
</header>
<main>
<NuxtPage />
</main>
<footer class="mt-16 border-t border-gray-200 bg-white py-8 dark:border-gray-800 dark:bg-gray-900">
<div class="mx-auto max-w-5xl px-4 text-center text-sm text-gray-500 dark:text-gray-400 sm:px-6">
DNS Test &mdash; Check your domain's DNS health
</div>
</footer>
</div>
</template>
<script setup lang="ts">
const isDark = ref(false)
function toggleTheme() {
isDark.value = !isDark.value
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
onMounted(() => {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDark.value = true
document.documentElement.classList.add('dark')
}
})
</script>

View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -0,0 +1,74 @@
<template>
<div class="border-b border-gray-100 last:border-b-0 dark:border-gray-800">
<div
class="flex items-start gap-3 px-4"
:class="[
compact ? 'py-2' : 'py-3',
hasDetails ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50' : 'cursor-default'
]"
@click="hasDetails && (detailsOpen = !detailsOpen)"
>
<div class="mt-0.5 shrink-0" v-if="!compact">
<StatusBadge :status="check.status" />
</div>
<div v-else class="mt-1.5 shrink-0">
<span class="inline-block h-1.5 w-1.5 rounded-full"
:class="{
'bg-green-500': check.status === 'pass',
'bg-blue-500': check.status === 'info',
}"
></span>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between gap-2">
<h4 :class="compact ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-sm font-medium text-gray-900 dark:text-white'">
{{ check.title }}
<span v-if="compact" class="text-gray-400 dark:text-gray-500"> {{ check.message }}</span>
</h4>
</div>
<p v-if="!compact" class="mt-0.5 text-sm text-gray-600 dark:text-gray-400">{{ check.message }}</p>
</div>
<div v-if="hasDetails && !compact" class="mt-1 shrink-0 text-gray-400 dark:text-gray-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': detailsOpen }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div v-if="detailsOpen && hasDetails" class="border-t border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-800/30">
<ul class="space-y-1">
<li
v-for="(detail, i) in check.details"
:key="i"
class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400"
>
<span class="mt-1.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"></span>
<span class="font-mono text-xs leading-relaxed">{{ detail }}</span>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import type { CheckResult as CheckResultType } from '~/types/dns'
const props = withDefaults(defineProps<{
check: CheckResultType
defaultExpanded?: boolean
compact?: boolean
}>(), {
defaultExpanded: false,
compact: false,
})
const detailsOpen = ref(props.defaultExpanded)
const hasDetails = computed(() => props.check.details && props.check.details.length > 0)
</script>

View File

@@ -0,0 +1,51 @@
<template>
<form @submit.prevent="onSubmit" class="flex flex-col gap-3 sm:flex-row">
<div class="relative flex-1">
<input
v-model="input"
type="text"
placeholder="Enter domain name (e.g., example.com)"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-base shadow-sm placeholder:text-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
/>
</div>
<button
type="submit"
:disabled="!input.trim()"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-base font-semibold text-white shadow-sm transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-600"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Check DNS
</button>
</form>
</template>
<script setup lang="ts">
const props = defineProps<{
initialValue?: string
}>()
const emit = defineEmits<{
submit: [domain: string]
}>()
const input = ref(props.initialValue || '')
function cleanDomain(raw: string): string {
let domain = raw.trim()
domain = domain.replace(/^https?:\/\//, '')
domain = domain.replace(/^www\./, '')
domain = domain.replace(/\/+$/, '')
domain = domain.split('/')[0]
return domain.toLowerCase()
}
function onSubmit() {
const domain = cleanDomain(input.value)
if (domain) {
input.value = domain
emit('submit', domain)
}
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="flex flex-col items-center justify-center py-20">
<div class="relative h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div class="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-indigo-600 dark:border-t-indigo-400"></div>
</div>
<p class="mt-6 text-lg font-medium text-gray-600 dark:text-gray-400">
Checking DNS configuration...
</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">
This may take a few seconds
</p>
</div>
</template>

View File

@@ -0,0 +1,173 @@
<template>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<button
@click="expanded = !expanded"
class="flex w-full items-center justify-between px-5 py-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ category.title }}</h3>
<div class="flex items-center gap-1.5">
<span v-if="passCount > 0" class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
{{ passCount }} passed
</span>
<span v-if="warnCount > 0" class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
{{ warnCount }} {{ warnCount === 1 ? 'warning' : 'warnings' }}
</span>
<span v-if="failCount > 0" class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ failCount }} {{ failCount === 1 ? 'problem' : 'problems' }}
</span>
<span v-if="infoCount > 0" class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{{ infoCount }} info
</span>
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-gray-400 transition-transform dark:text-gray-500"
:class="{ 'rotate-180': expanded }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-if="expanded" class="border-t border-gray-200 dark:border-gray-800">
<!-- When filtering by status, show only those checks directly -->
<template v-if="props.statusFilter">
<CheckResult
v-for="check in filteredByStatus"
:key="check.id"
:check="check"
:default-expanded="true"
/>
</template>
<template v-else>
<!-- Problems (fail) always shown expanded with details -->
<CheckResult
v-for="check in failChecks"
:key="check.id"
:check="check"
:default-expanded="true"
/>
<!-- Warnings shown expanded with details -->
<CheckResult
v-for="check in warnChecks"
:key="check.id"
:check="check"
:default-expanded="true"
/>
<!-- Passed checks condensed into a compact list -->
<div v-if="passChecks.length > 0" class="border-b border-gray-100 last:border-b-0 dark:border-gray-800">
<div
class="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50"
@click="passExpanded = !passExpanded"
>
<div class="mt-0.5 shrink-0">
<StatusBadge status="pass" />
</div>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">
{{ passChecks.length }} {{ passChecks.length === 1 ? 'check' : 'checks' }} passed
</h4>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
{{ passChecks.map(c => c.title).join(' · ') }}
</p>
</div>
<div class="mt-1 shrink-0 text-gray-400 dark:text-gray-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': passExpanded }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div v-if="passExpanded" class="border-t border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/20">
<CheckResult
v-for="check in passChecks"
:key="check.id"
:check="check"
compact
/>
</div>
</div>
<!-- Info checks condensed similarly -->
<div v-if="infoChecks.length > 0" class="border-b border-gray-100 last:border-b-0 dark:border-gray-800">
<div
class="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50"
@click="infoExpanded = !infoExpanded"
>
<div class="mt-0.5 shrink-0">
<StatusBadge status="info" />
</div>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">
{{ infoChecks.length }} informational {{ infoChecks.length === 1 ? 'note' : 'notes' }}
</h4>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
{{ infoChecks.map(c => c.title).join(' · ') }}
</p>
</div>
<div class="mt-1 shrink-0 text-gray-400 dark:text-gray-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': infoExpanded }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div v-if="infoExpanded" class="border-t border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/20">
<CheckResult
v-for="check in infoChecks"
:key="check.id"
:check="check"
compact
/>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Category, CheckStatus } from '~/types/dns'
const props = defineProps<{
category: Category
statusFilter?: CheckStatus | null
}>()
const expanded = ref(true)
const passExpanded = ref(false)
const infoExpanded = ref(false)
const passChecks = computed(() => props.category.checks.filter(c => c.status === 'pass'))
const warnChecks = computed(() => props.category.checks.filter(c => c.status === 'warn'))
const failChecks = computed(() => props.category.checks.filter(c => c.status === 'fail'))
const infoChecks = computed(() => props.category.checks.filter(c => c.status === 'info'))
const filteredByStatus = computed(() => props.statusFilter ? props.category.checks.filter(c => c.status === props.statusFilter) : [])
const passCount = computed(() => passChecks.value.length)
const warnCount = computed(() => warnChecks.value.length)
const failCount = computed(() => failChecks.value.length)
const infoCount = computed(() => infoChecks.value.length)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<span :class="classes" class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wide">
{{ label }}
</span>
</template>
<script setup lang="ts">
import type { CheckStatus } from '~/types/dns'
const props = defineProps<{
status: CheckStatus
}>()
const classes = computed(() => {
switch (props.status) {
case 'pass':
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
case 'warn':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
case 'fail':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
case 'info':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}
})
const label = computed(() => props.status)
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Summary</h2>
<div class="flex items-center gap-2">
<button
v-if="activeFilter"
@click="emit('filter', null)"
class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
Show all
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ total }} checks</span>
</div>
</div>
<!-- Bar -->
<div class="mb-4 flex h-3 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800" v-if="total > 0">
<div
v-if="summary.pass > 0"
class="bg-green-500 cursor-pointer transition-all hover:brightness-110"
:style="{ width: pct(summary.pass) }"
@click="emit('filter', 'pass')"
></div>
<div
v-if="summary.info > 0"
class="bg-blue-500 cursor-pointer transition-all hover:brightness-110"
:style="{ width: pct(summary.info) }"
@click="emit('filter', 'info')"
></div>
<div
v-if="summary.warn > 0"
class="bg-yellow-500 cursor-pointer transition-all hover:brightness-110"
:style="{ width: pct(summary.warn) }"
@click="emit('filter', 'warn')"
></div>
<div
v-if="summary.fail > 0"
class="bg-red-500 cursor-pointer transition-all hover:brightness-110"
:style="{ width: pct(summary.fail) }"
@click="emit('filter', 'fail')"
></div>
</div>
<!-- Counts (clickable) -->
<div class="flex flex-wrap gap-4 text-sm">
<button
v-if="summary.fail > 0"
@click="emit('filter', activeFilter === 'fail' ? null : 'fail')"
class="flex items-center gap-1.5 rounded-md px-2 py-0.5 transition"
:class="activeFilter === 'fail' ? 'bg-red-100 dark:bg-red-900/30' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
>
<span class="inline-block h-3 w-3 rounded-full bg-red-500"></span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ summary.fail }} failed</span>
</button>
<button
v-if="summary.warn > 0"
@click="emit('filter', activeFilter === 'warn' ? null : 'warn')"
class="flex items-center gap-1.5 rounded-md px-2 py-0.5 transition"
:class="activeFilter === 'warn' ? 'bg-yellow-100 dark:bg-yellow-900/30' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
>
<span class="inline-block h-3 w-3 rounded-full bg-yellow-500"></span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ summary.warn }} warnings</span>
</button>
<button
@click="emit('filter', activeFilter === 'pass' ? null : 'pass')"
class="flex items-center gap-1.5 rounded-md px-2 py-0.5 transition"
:class="activeFilter === 'pass' ? 'bg-green-100 dark:bg-green-900/30' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
>
<span class="inline-block h-3 w-3 rounded-full bg-green-500"></span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ summary.pass }} passed</span>
</button>
<button
v-if="summary.info > 0"
@click="emit('filter', activeFilter === 'info' ? null : 'info')"
class="flex items-center gap-1.5 rounded-md px-2 py-0.5 transition"
:class="activeFilter === 'info' ? 'bg-blue-100 dark:bg-blue-900/30' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
>
<span class="inline-block h-3 w-3 rounded-full bg-blue-500"></span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ summary.info }} info</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Summary, CheckStatus } from '~/types/dns'
const props = defineProps<{
summary: Summary
activeFilter?: CheckStatus | null
}>()
const emit = defineEmits<{
filter: [status: CheckStatus | null]
}>()
const total = computed(() => props.summary.pass + props.summary.warn + props.summary.fail + props.summary.info)
function pct(count: number): string {
if (total.value === 0) return '0%'
return `${(count / total.value * 100).toFixed(1)}%`
}
</script>

View File

@@ -0,0 +1,106 @@
import type { DnsReport, Category, Summary } from '~/types/dns'
interface StreamEvent {
domain: string
category: Category
done?: boolean
}
export function useDnsCheck() {
const config = useRuntimeConfig()
const report = ref<DnsReport | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const progress = ref(0)
const totalCategories = 8
async function checkDomain(domain: string) {
loading.value = true
error.value = null
report.value = null
progress.value = 0
// Initialize empty report
const startTime = Date.now()
const partialReport: DnsReport = {
domain,
timestamp: new Date().toISOString(),
duration_ms: 0,
summary: { pass: 0, warn: 0, fail: 0, info: 0 },
categories: [],
}
try {
const url = `${config.public.apiBase}/check/stream?domain=${encodeURIComponent(domain)}`
const eventSource = new EventSource(url)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
eventSource.close()
reject(new Error('Request timed out after 30 seconds'))
}, 30000)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as StreamEvent & { done?: boolean }
if (data.done) {
clearTimeout(timeout)
eventSource.close()
partialReport.duration_ms = Date.now() - startTime
resolve()
return
}
if (data.category) {
partialReport.categories.push(data.category)
progress.value = partialReport.categories.length
// Recalculate summary
const summary: Summary = { pass: 0, warn: 0, fail: 0, info: 0 }
for (const cat of partialReport.categories) {
for (const check of cat.checks) {
summary[check.status]++
}
}
partialReport.summary = summary
partialReport.duration_ms = Date.now() - startTime
// Update reactively
report.value = { ...partialReport, categories: [...partialReport.categories] }
}
} catch {
// ignore parse errors
}
}
eventSource.onerror = () => {
clearTimeout(timeout)
eventSource.close()
if (partialReport.categories.length > 0) {
// Got some results, show them
partialReport.duration_ms = Date.now() - startTime
report.value = { ...partialReport }
resolve()
} else {
reject(new Error('Connection to DNS check server failed'))
}
}
})
} catch (e: any) {
// Fallback to non-streaming API
try {
const data = await $fetch<DnsReport>(`${config.public.apiBase}/check?domain=${encodeURIComponent(domain)}`)
report.value = data
progress.value = totalCategories
} catch (e2: any) {
error.value = e2.data?.error || e2.message || e.message || 'Failed to check domain'
}
} finally {
loading.value = false
progress.value = totalCategories
}
}
return { report, loading, error, progress, totalCategories, checkDomain }
}

View File

@@ -0,0 +1,140 @@
<template>
<div class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<div class="mb-8">
<DomainInput :initial-value="domain" @submit="onNewDomain" />
</div>
<!-- Loading state with progress -->
<div v-if="loading && !report" class="flex flex-col items-center justify-center py-20">
<div class="relative h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div class="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-indigo-600 dark:border-t-indigo-400"></div>
</div>
<p class="mt-6 text-lg font-medium text-gray-600 dark:text-gray-400">
Checking DNS configuration...
</p>
<div class="mt-4 w-64">
<div class="flex h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="bg-indigo-600 transition-all duration-500 dark:bg-indigo-400"
:style="{ width: `${(progress / totalCategories) * 100}%` }"
></div>
</div>
<p class="mt-2 text-center text-xs text-gray-400 dark:text-gray-500">
{{ progress }} / {{ totalCategories }} categories
</p>
</div>
</div>
<!-- Error state -->
<div v-else-if="error && !report" class="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950/30">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h2 class="mt-4 text-lg font-semibold text-red-800 dark:text-red-300">DNS Check Failed</h2>
<p class="mt-2 text-red-600 dark:text-red-400">{{ error }}</p>
<button
@click="checkDomain(domain)"
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Try Again
</button>
</div>
<!-- Results (shown progressively as categories arrive) -->
<template v-if="report">
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Report for <span class="text-indigo-600 dark:text-indigo-400">{{ report.domain }}</span>
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<template v-if="loading">Checking...</template>
<template v-else>Completed in {{ report.duration_ms }}ms</template>
&middot; {{ formatTimestamp(report.timestamp) }}
</p>
</div>
</div>
<!-- Progress bar while still loading -->
<div v-if="loading" class="mb-4">
<div class="flex h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="bg-indigo-600 transition-all duration-500 dark:bg-indigo-400"
:style="{ width: `${(progress / totalCategories) * 100}%` }"
></div>
</div>
</div>
<SummaryBar :summary="report.summary" :active-filter="statusFilter" @filter="onFilter" />
<div class="mt-8 space-y-6">
<ReportSection
v-for="category in filteredCategories"
:key="category.name"
:category="category"
:status-filter="statusFilter"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
import type { CheckStatus } from '~/types/dns'
const { report, loading, error, progress, totalCategories, checkDomain } = useDnsCheck()
const statusFilter = ref<CheckStatus | null>(null)
const domain = computed(() => {
const params = route.params.domain
if (Array.isArray(params)) {
return params.join('/')
}
return params || ''
})
// Sort categories in consistent order as they arrive
const categoryOrder = ['overview', 'registration', 'parent', 'nameservers', 'soa', 'mx', 'mail-auth', 'www']
const sortedCategories = computed(() => {
if (!report.value) return []
return [...report.value.categories].sort((a, b) => {
return categoryOrder.indexOf(a.name) - categoryOrder.indexOf(b.name)
})
})
// Filter categories to only those with matching checks
const filteredCategories = computed(() => {
if (!statusFilter.value) return sortedCategories.value
return sortedCategories.value.filter(cat =>
cat.checks.some(c => c.status === statusFilter.value)
)
})
function onFilter(status: CheckStatus | null) {
statusFilter.value = status
}
function onNewDomain(newDomain: string) {
router.push(`/${newDomain}`)
}
function formatTimestamp(ts: string) {
try {
return new Date(ts).toLocaleString()
} catch {
return ts
}
}
watch(domain, (val) => {
if (val) {
checkDomain(val)
}
}, { immediate: true })
useHead({
title: () => domain.value ? `DNS Test - ${domain.value}` : 'DNS Test',
})
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="mx-auto max-w-5xl px-4 sm:px-6">
<section class="py-16 text-center sm:py-24">
<h1 class="text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white sm:text-5xl">
DNS Test
</h1>
<p class="mx-auto mt-4 max-w-xl text-lg text-gray-600 dark:text-gray-400">
Check your domain's DNS health. Get a comprehensive report on your nameservers, mail servers, and DNS configuration.
</p>
<div class="mx-auto mt-10 max-w-xl">
<DomainInput @submit="onSubmit" />
</div>
</section>
<section class="grid gap-6 pb-16 sm:grid-cols-3">
<div class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-3 inline-flex rounded-lg bg-indigo-100 p-2 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white">Nameserver Checks</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Verify NS records, delegation, and nameserver responsiveness.</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-3 inline-flex rounded-lg bg-green-100 p-2 text-green-600 dark:bg-green-900/30 dark:text-green-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white">Mail Configuration</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Check MX records, SPF, DKIM, and DMARC policies.</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-3 inline-flex rounded-lg bg-amber-100 p-2 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white">Security & DNSSEC</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Validate DNSSEC configuration and security best practices.</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
const router = useRouter()
function onSubmit(domain: string) {
router.push(`/${domain}`)
}
</script>

31
frontend/app/types/dns.ts Normal file
View File

@@ -0,0 +1,31 @@
export type CheckStatus = 'pass' | 'warn' | 'fail' | 'info'
export interface CheckResult {
id: string
title: string
status: CheckStatus
message: string
details?: string[]
duration_ms: number
}
export interface Category {
name: string
title: string
checks: CheckResult[]
}
export interface Summary {
pass: number
warn: number
fail: number
info: number
}
export interface DnsReport {
domain: string
timestamp: string
duration_ms: number
summary: Summary
categories: Category[]
}

45
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,45 @@
import tailwindcss from '@tailwindcss/vite'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
vite: {
plugins: [
tailwindcss(),
],
},
css: ['~/assets/css/main.css'],
app: {
baseURL: '/dnstest/',
head: {
title: 'DNS Test',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'Check your domain\'s DNS health with comprehensive DNS testing and reporting.' },
],
htmlAttrs: {
lang: 'en',
},
},
},
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/dnstest/api',
},
},
nitro: {
devProxy: {
'/dnstest/api/': {
target: 'http://localhost:8080/dnstest/api/',
changeOrigin: true,
},
},
},
})

10312
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"nuxt": "^4.4.2",
"tailwindcss": "^4.2.2",
"vue": "^3.5.30",
"vue-router": "^5.0.3"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

18
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}