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