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:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
16
backend/go.mod
Normal 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
|
||||
)
|
||||
220
backend/internal/api/handler.go
Normal file
220
backend/internal/api/handler.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/intodns/backend/internal/checker"
|
||||
"github.com/intodns/backend/internal/store"
|
||||
)
|
||||
|
||||
var validHostname = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
func cleanDomain(raw string) string {
|
||||
domain := raw
|
||||
domain = strings.TrimPrefix(domain, "http://")
|
||||
domain = strings.TrimPrefix(domain, "https://")
|
||||
domain = strings.TrimRight(domain, "/")
|
||||
if idx := strings.Index(domain, "/"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
if idx := strings.LastIndex(domain, ":"); idx != -1 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(domain))
|
||||
}
|
||||
|
||||
// CheckHandler returns the full report as JSON (non-streaming).
|
||||
func CheckHandler(ch *checker.Checker, st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
domain := cleanDomain(r.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "missing 'domain' query parameter"})
|
||||
return
|
||||
}
|
||||
if !validHostname.MatchString(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid domain name"})
|
||||
return
|
||||
}
|
||||
|
||||
report := ch.Check(domain)
|
||||
|
||||
if st != nil {
|
||||
if err := st.SaveReport(report, extractIP(r), r.UserAgent()); err != nil {
|
||||
log.Printf("failed to save report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckStreamHandler streams results via Server-Sent Events as each category completes.
|
||||
func CheckStreamHandler(ch *checker.Checker, st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
domain := cleanDomain(r.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "missing 'domain' query parameter"})
|
||||
return
|
||||
}
|
||||
if !validHostname.MatchString(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid domain name"})
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
// Fallback to non-streaming.
|
||||
report := ch.Check(domain)
|
||||
if st != nil {
|
||||
if err := st.SaveReport(report, extractIP(r), r.UserAgent()); err != nil {
|
||||
log.Printf("failed to save report: %v", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
start := time.Now()
|
||||
catCh := ch.CheckStream(domain)
|
||||
var categories []checker.Category
|
||||
for event := range catCh {
|
||||
categories = append(categories, event.Category)
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// Build and save the final report.
|
||||
if st != nil {
|
||||
var summary checker.Summary
|
||||
for _, cat := range categories {
|
||||
for _, check := range cat.Checks {
|
||||
switch check.Status {
|
||||
case checker.StatusPass:
|
||||
summary.Pass++
|
||||
case checker.StatusWarn:
|
||||
summary.Warn++
|
||||
case checker.StatusFail:
|
||||
summary.Fail++
|
||||
case checker.StatusInfo:
|
||||
summary.Info++
|
||||
}
|
||||
}
|
||||
}
|
||||
report := &checker.Report{
|
||||
Domain: domain,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
Summary: summary,
|
||||
Categories: categories,
|
||||
}
|
||||
if err := st.SaveReport(report, extractIP(r), r.UserAgent()); err != nil {
|
||||
log.Printf("failed to save streamed report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "data: {\"done\":true}\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryHandler returns recent check history as JSON.
|
||||
// Query params: domain (optional), limit (default 20, max 100).
|
||||
func HistoryHandler(st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
domain := r.URL.Query().Get("domain")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
|
||||
limit := 20
|
||||
if limitStr != "" {
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
var entries []store.HistoryEntry
|
||||
var err error
|
||||
|
||||
if domain != "" {
|
||||
entries, err = st.GetHistory(domain, limit)
|
||||
} else {
|
||||
entries, err = st.GetRecent(limit)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("history query error: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to query history"})
|
||||
return
|
||||
}
|
||||
|
||||
if entries == nil {
|
||||
entries = []store.HistoryEntry{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
}
|
||||
|
||||
// ReportHandler returns the full saved report JSON for a specific history entry.
|
||||
// Query param: id (required).
|
||||
func ReportHandler(st *store.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "missing 'id' query parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid 'id' parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := st.GetReport(id)
|
||||
if err != nil {
|
||||
log.Printf("report query error: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to query report"})
|
||||
return
|
||||
}
|
||||
if entry == nil {
|
||||
writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "report not found"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// HealthHandler returns an http.HandlerFunc for the health endpoint.
|
||||
func HealthHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
11
backend/internal/api/response.go
Normal file
11
backend/internal/api/response.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package api
|
||||
|
||||
// ErrorResponse is returned on invalid requests.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// HealthResponse is returned by the health endpoint.
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
158
backend/internal/api/router.go
Normal file
158
backend/internal/api/router.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/intodns/backend/internal/checker"
|
||||
"github.com/intodns/backend/internal/store"
|
||||
)
|
||||
|
||||
// NewRouter builds the HTTP mux with all routes and middleware.
|
||||
func NewRouter(ch *checker.Checker, st *store.Store) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/dnstest/api/check/stream", CheckStreamHandler(ch, st))
|
||||
mux.HandleFunc("/dnstest/api/check", CheckHandler(ch, st))
|
||||
mux.HandleFunc("/dnstest/api/health", HealthHandler())
|
||||
mux.HandleFunc("/dnstest/api/history", HistoryHandler(st))
|
||||
mux.HandleFunc("/dnstest/api/report", ReportHandler(st))
|
||||
|
||||
// Stack middleware: logging -> CORS -> rate limit -> mux.
|
||||
var handler http.Handler = mux
|
||||
handler = rateLimitMiddleware(handler)
|
||||
handler = corsMiddleware(handler)
|
||||
handler = loggingMiddleware(handler)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// --- CORS middleware ---
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Rate limiting middleware (10 req/min per IP) ---
|
||||
|
||||
type ipEntry struct {
|
||||
count int
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
rateMu sync.Mutex
|
||||
rateMap = make(map[string]*ipEntry)
|
||||
rateOnce sync.Once
|
||||
)
|
||||
|
||||
func startRateCleanup() {
|
||||
rateOnce.Do(func() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Minute)
|
||||
rateMu.Lock()
|
||||
now := time.Now()
|
||||
for ip, entry := range rateMap {
|
||||
if now.Sub(entry.windowStart) > 2*time.Minute {
|
||||
delete(rateMap, ip)
|
||||
}
|
||||
}
|
||||
rateMu.Unlock()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func rateLimitMiddleware(next http.Handler) http.Handler {
|
||||
startRateCleanup()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := extractIP(r)
|
||||
|
||||
rateMu.Lock()
|
||||
entry, ok := rateMap[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !ok || now.Sub(entry.windowStart) > time.Minute {
|
||||
// New window.
|
||||
rateMap[ip] = &ipEntry{count: 1, windowStart: now}
|
||||
rateMu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
entry.count++
|
||||
if entry.count > 10 {
|
||||
rateMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", "60")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error":"rate limit exceeded, try again in 1 minute"}`))
|
||||
return
|
||||
}
|
||||
rateMu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func extractIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For first.
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first IP in the chain.
|
||||
for i := 0; i < len(xff); i++ {
|
||||
if xff[i] == ',' {
|
||||
return xff[:i]
|
||||
}
|
||||
}
|
||||
return xff
|
||||
}
|
||||
// Fall back to RemoteAddr (strip port).
|
||||
addr := r.RemoteAddr
|
||||
for i := len(addr) - 1; i >= 0; i-- {
|
||||
if addr[i] == ':' {
|
||||
return addr[:i]
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// --- Logging middleware ---
|
||||
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(lw, r)
|
||||
log.Printf("%s %s %d %s %s",
|
||||
r.Method, r.URL.Path, lw.statusCode,
|
||||
time.Since(start).Round(time.Millisecond), extractIP(r))
|
||||
})
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (lw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lw.statusCode = code
|
||||
lw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (lw *loggingResponseWriter) Flush() {
|
||||
if f, ok := lw.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
145
backend/internal/checker/checker.go
Normal file
145
backend/internal/checker/checker.go
Normal 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},
|
||||
}
|
||||
}
|
||||
329
backend/internal/checker/mail.go
Normal file
329
backend/internal/checker/mail.go
Normal 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] + "..."
|
||||
}
|
||||
449
backend/internal/checker/mx.go
Normal file
449
backend/internal/checker/mx.go
Normal 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
|
||||
}
|
||||
713
backend/internal/checker/ns.go
Normal file
713
backend/internal/checker/ns.go
Normal 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
|
||||
}
|
||||
381
backend/internal/checker/overview.go
Normal file
381
backend/internal/checker/overview.go
Normal 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"
|
||||
}
|
||||
}
|
||||
313
backend/internal/checker/parent.go
Normal file
313
backend/internal/checker/parent.go
Normal 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
|
||||
}
|
||||
293
backend/internal/checker/soa.go
Normal file
293
backend/internal/checker/soa.go
Normal 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)
|
||||
}
|
||||
45
backend/internal/checker/types.go
Normal file
45
backend/internal/checker/types.go
Normal 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"`
|
||||
}
|
||||
246
backend/internal/checker/util.go
Normal file
246
backend/internal/checker/util.go
Normal 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)
|
||||
}
|
||||
469
backend/internal/checker/whois.go
Normal file
469
backend/internal/checker/whois.go
Normal 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")
|
||||
}
|
||||
105
backend/internal/checker/www.go
Normal file
105
backend/internal/checker/www.go
Normal 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
|
||||
}
|
||||
234
backend/internal/resolver/resolver.go
Normal file
234
backend/internal/resolver/resolver.go
Normal 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"
|
||||
}
|
||||
196
backend/internal/store/store.go
Normal file
196
backend/internal/store/store.go
Normal 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
35
backend/main.go
Normal 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
24
frontend/.gitignore
vendored
Normal 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
54
frontend/app/app.vue
Normal 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 — 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>
|
||||
3
frontend/app/assets/css/main.css
Normal file
3
frontend/app/assets/css/main.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
74
frontend/app/components/CheckResult.vue
Normal file
74
frontend/app/components/CheckResult.vue
Normal 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>
|
||||
51
frontend/app/components/DomainInput.vue
Normal file
51
frontend/app/components/DomainInput.vue
Normal 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>
|
||||
14
frontend/app/components/LoadingSpinner.vue
Normal file
14
frontend/app/components/LoadingSpinner.vue
Normal 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>
|
||||
173
frontend/app/components/ReportSection.vue
Normal file
173
frontend/app/components/ReportSection.vue
Normal 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>
|
||||
30
frontend/app/components/StatusBadge.vue
Normal file
30
frontend/app/components/StatusBadge.vue
Normal 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>
|
||||
104
frontend/app/components/SummaryBar.vue
Normal file
104
frontend/app/components/SummaryBar.vue
Normal 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>
|
||||
106
frontend/app/composables/useDnsCheck.ts
Normal file
106
frontend/app/composables/useDnsCheck.ts
Normal 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 }
|
||||
}
|
||||
140
frontend/app/pages/[...domain].vue
Normal file
140
frontend/app/pages/[...domain].vue
Normal 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>
|
||||
· {{ 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>
|
||||
53
frontend/app/pages/index.vue
Normal file
53
frontend/app/pages/index.vue
Normal 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
31
frontend/app/types/dns.ts
Normal 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
45
frontend/nuxt.config.ts
Normal 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
10312
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
frontend/tsconfig.json
Normal file
18
frontend/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user