Files
dnstest/backend/internal/checker/overview.go
robertas_stauskas a70f3262e0 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
2026-03-20 13:39:57 +02:00

382 lines
10 KiB
Go

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