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
382 lines
10 KiB
Go
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"
|
|
}
|
|
}
|