diff --git a/cmd/all.go b/cmd/all.go index 77ff478..337c3ff 100644 --- a/cmd/all.go +++ b/cmd/all.go @@ -5,6 +5,7 @@ import ( "net" "github.com/retlehs/quien/internal/dns" + "github.com/retlehs/quien/internal/dnsutil" "github.com/retlehs/quien/internal/httpinfo" "github.com/retlehs/quien/internal/mail" "github.com/retlehs/quien/internal/resolver" @@ -56,11 +57,17 @@ var allCmd = &cobra.Command{ // DNS if records, err := retry.Do(func() (*dns.Records, error) { return dns.Lookup(input) }); err == nil { + if allResolveFlag { + records.NSResolved = dnsutil.ResolveHosts(records.NS) + } result.DNS = records } // Mail if records, err := retry.Do(func() (*mail.Records, error) { return mail.Lookup(input) }); err == nil { + if allResolveFlag { + records.MXResolved = dnsutil.ResolveHosts(mxHosts(records.MX)) + } result.Mail = records } @@ -84,6 +91,9 @@ var allCmd = &cobra.Command{ }, } +var allResolveFlag bool + func init() { + allCmd.Flags().BoolVar(&allResolveFlag, "resolve", false, "resolve NS and MX hostnames to IP addresses and reverse DNS") rootCmd.AddCommand(allCmd) } diff --git a/cmd/dns.go b/cmd/dns.go index a6943e8..56f7e5a 100644 --- a/cmd/dns.go +++ b/cmd/dns.go @@ -5,11 +5,14 @@ import ( "fmt" "github.com/retlehs/quien/internal/dns" + "github.com/retlehs/quien/internal/dnsutil" "github.com/retlehs/quien/internal/resolver" "github.com/retlehs/quien/internal/retry" "github.com/spf13/cobra" ) +var dnsResolveFlag bool + var dnsCmd = &cobra.Command{ Use: "dns ", Short: "DNS record lookup (JSON output)", @@ -25,11 +28,15 @@ var dnsCmd = &cobra.Command{ if err != nil { return fmt.Errorf("DNS lookup failed: %w", err) } + if dnsResolveFlag { + records.NSResolved = dnsutil.ResolveHosts(records.NS) + } return printJSON(records) }, } func init() { + dnsCmd.Flags().BoolVar(&dnsResolveFlag, "resolve", false, "resolve NS hostnames to IP addresses and reverse DNS") rootCmd.AddCommand(dnsCmd) } diff --git a/cmd/mail.go b/cmd/mail.go index b3858b3..d4e8d76 100644 --- a/cmd/mail.go +++ b/cmd/mail.go @@ -3,11 +3,14 @@ package cmd import ( "fmt" + "github.com/retlehs/quien/internal/dnsutil" "github.com/retlehs/quien/internal/mail" "github.com/retlehs/quien/internal/retry" "github.com/spf13/cobra" ) +var mailResolveFlag bool + var mailCmd = &cobra.Command{ Use: "mail ", Short: "Mail configuration lookup — MX, SPF, DMARC, DKIM, BIMI (JSON output)", @@ -23,10 +26,23 @@ var mailCmd = &cobra.Command{ if err != nil { return fmt.Errorf("mail lookup failed: %w", err) } + if mailResolveFlag { + records.MXResolved = dnsutil.ResolveHosts(mxHosts(records.MX)) + } return printJSON(records) }, } +// mxHosts extracts the hostnames from a slice of MX records. +func mxHosts(mx []mail.MXRecord) []string { + hosts := make([]string, len(mx)) + for i, r := range mx { + hosts[i] = r.Host + } + return hosts +} + func init() { + mailCmd.Flags().BoolVar(&mailResolveFlag, "resolve", false, "resolve MX hostnames to IP addresses and reverse DNS") rootCmd.AddCommand(mailCmd) } diff --git a/cmd/mail_test.go b/cmd/mail_test.go new file mode 100644 index 0000000..2814922 --- /dev/null +++ b/cmd/mail_test.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "reflect" + "testing" + + "github.com/retlehs/quien/internal/mail" +) + +func TestMXHosts(t *testing.T) { + got := mxHosts([]mail.MXRecord{ + {Host: "alt1.aspmx.l.google.com", Priority: 1}, + {Host: "alt2.aspmx.l.google.com", Priority: 5}, + }) + want := []string{"alt1.aspmx.l.google.com", "alt2.aspmx.l.google.com"} + if !reflect.DeepEqual(got, want) { + t.Errorf("mxHosts() = %v, want %v", got, want) + } + + if got := mxHosts(nil); len(got) != 0 { + t.Errorf("mxHosts(nil) = %v, want empty", got) + } +} diff --git a/internal/display/dns.go b/internal/display/dns.go index 6507ce9..e27cca7 100644 --- a/internal/display/dns.go +++ b/internal/display/dns.go @@ -6,10 +6,12 @@ import ( "charm.land/lipgloss/v2" "github.com/retlehs/quien/internal/dns" + "github.com/retlehs/quien/internal/dnsutil" ) -// RenderDNS returns a lipgloss-styled string for DNS records. -func RenderDNS(records *dns.Records) string { +// RenderDNS returns a lipgloss-styled string for DNS records. nsResolutions +// (optional) adds expanded IP/rDNS info under each NS host. +func RenderDNS(records *dns.Records, nsResolutions []dnsutil.HostResolution) string { var b strings.Builder b.WriteString(domainSectionTitle("DNS Records")) @@ -68,8 +70,25 @@ func RenderDNS(records *dns.Records) string { hasRecords = true b.WriteString("\n") b.WriteString(section("NS")) + resolutionByHost := map[string]dnsutil.HostResolution{} + for _, r := range nsResolutions { + resolutionByHost[r.Host] = r + } for _, ns := range records.NS { b.WriteString(row("", nsStyle.Render(ns))) + if res, ok := resolutionByHost[ns]; ok { + if res.Err != "" { + b.WriteString(row("", dimStyle.Render(" "+notFoundStyle.Render(res.Err)))) + } else { + for _, ip := range res.IPs { + line := recordStyle.Render(" " + ip.IP) + if len(ip.PTRs) > 0 { + line += " " + dimStyle.Render("("+strings.Join(ip.PTRs, ", ")+")") + } + b.WriteString(row("", line)) + } + } + } } } diff --git a/internal/display/dns_test.go b/internal/display/dns_test.go index 88be478..88faf2d 100644 --- a/internal/display/dns_test.go +++ b/internal/display/dns_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/retlehs/quien/internal/dns" + "github.com/retlehs/quien/internal/dnsutil" ) func TestRenderHTTPSRecord(t *testing.T) { @@ -54,3 +55,33 @@ func TestRenderHTTPSRecord(t *testing.T) { }) } } + +func TestRenderDNSNSResolution(t *testing.T) { + records := &dns.Records{NS: []string{"ns1.example.com", "ns2.example.com"}} + resolutions := []dnsutil.HostResolution{ + {Host: "ns1.example.com", IPs: []dnsutil.HostIP{ + {IP: "192.0.2.1", PTRs: []string{"a.example.com", "b.example.com"}}, + }}, + {Host: "ns2.example.com", Err: "no such host"}, + } + + out := RenderDNS(records, resolutions) + + for _, want := range []string{"192.0.2.1", "a.example.com", "b.example.com", "no such host"} { + if !strings.Contains(out, want) { + t.Errorf("RenderDNS output missing %q\n%s", want, out) + } + } +} + +func TestRenderDNSWithoutResolution(t *testing.T) { + // Passing no resolutions renders the bare NS list (default behavior). + records := &dns.Records{NS: []string{"ns1.example.com"}} + out := RenderDNS(records, nil) + if !strings.Contains(out, "ns1.example.com") { + t.Errorf("RenderDNS output missing NS host\n%s", out) + } + if strings.Contains(out, "192.0.2") { + t.Errorf("RenderDNS rendered resolved IPs without resolutions\n%s", out) + } +} diff --git a/internal/display/interactive.go b/internal/display/interactive.go index 29e8b5b..f73cb1c 100644 --- a/internal/display/interactive.go +++ b/internal/display/interactive.go @@ -79,8 +79,11 @@ type Model struct { ipInfo *rdap.IPInfo whoisErr error dnsData *dns.Records + nsResolved []dnsutil.HostResolution + nsExpanded bool + nsResolving bool mailData *mail.Records - mxResolved []mail.MXResolution + mxResolved []dnsutil.HostResolution mxExpanded bool mxResolving bool spfDepth int // 0 = top-level only, N = N layers, SPFExpandAll = full @@ -147,7 +150,11 @@ type mailResultMsg struct { } type mxResolveMsg struct { - resolutions []mail.MXResolution + resolutions []dnsutil.HostResolution +} + +type nsResolveMsg struct { + resolutions []dnsutil.HostResolution } type tlsResultMsg struct { @@ -289,6 +296,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewport() return m, resolveFirstIP(m.domain, m.dnsData) } + if !m.isIP && m.active == tabDNS && m.dnsData != nil && len(m.dnsData.NS) > 0 { + if m.nsResolved != nil { + m.nsExpanded = !m.nsExpanded + m.updateViewport() + return m, nil + } + if !m.nsResolving { + m.nsResolving = true + m.nsExpanded = true + m.updateViewport() + hosts := append([]string(nil), m.dnsData.NS...) + return m, resolveNS(hosts) + } + } if !m.isIP && m.active == tabMail && m.mailData != nil && len(m.mailData.MX) > 0 { if m.mxResolved != nil { m.mxExpanded = !m.mxExpanded @@ -389,6 +410,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewport() return m, nil + case nsResolveMsg: + m.nsResolving = false + m.nsResolved = msg.resolutions + m.updateViewport() + return m, nil + case mailResultMsg: m.setFetching(tabMail, false) m.updateLoading() @@ -587,7 +614,7 @@ func (m Model) contentForTab(t tab) string { } else if m.mailErr != nil { return errorBox("Mail Lookup Failed", m.mailErr) } else if m.mailData != nil { - var res []mail.MXResolution + var res []dnsutil.HostResolution if m.mxExpanded { res = m.mxResolved } @@ -599,7 +626,11 @@ func (m Model) contentForTab(t tab) string { } else if m.dnsErr != nil { return errorBox("DNS Lookup Failed", m.dnsErr) } else if m.dnsData != nil { - return RenderDNS(m.dnsData) + var res []dnsutil.HostResolution + if m.nsExpanded { + res = m.nsResolved + } + return RenderDNS(m.dnsData, res) } case tabTLS: if m.loading { @@ -706,6 +737,15 @@ func (m Model) View() tea.View { footerParts = append(footerParts, "i failed") } else if !m.isIP && m.active == tabWhois { footerParts = append(footerParts, "i inspect ip") + } else if !m.isIP && m.active == tabDNS && m.dnsData != nil && len(m.dnsData.NS) > 0 { + switch { + case m.nsResolving: + footerParts = append(footerParts, "i resolving...") + case m.nsExpanded: + footerParts = append(footerParts, "i collapse") + default: + footerParts = append(footerParts, "i resolve ns") + } } else if !m.isIP && m.active == tabMail && m.mailData != nil && len(m.mailData.MX) > 0 { switch { case m.mxResolving: @@ -940,7 +980,13 @@ func fetchWhois(domain string) tea.Cmd { func resolveMX(hosts []string) tea.Cmd { return func() tea.Msg { - return mxResolveMsg{resolutions: mail.ResolveMX(hosts)} + return mxResolveMsg{resolutions: dnsutil.ResolveHosts(hosts)} + } +} + +func resolveNS(hosts []string) tea.Cmd { + return func() tea.Msg { + return nsResolveMsg{resolutions: dnsutil.ResolveHosts(hosts)} } } diff --git a/internal/display/mail.go b/internal/display/mail.go index 0b6cc56..24dd44a 100644 --- a/internal/display/mail.go +++ b/internal/display/mail.go @@ -5,6 +5,7 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/retlehs/quien/internal/dnsutil" "github.com/retlehs/quien/internal/mail" ) @@ -21,13 +22,13 @@ const SPFExpandAll = -1 // mxResolutions (optional) adds expanded IP/rDNS info under each MX host. // spfDepth controls how many layers of include/redirect to render in the SPF // tree: 0 = top-level terms only, N = N nested layers, SPFExpandAll = full. -func RenderMail(records *mail.Records, mxResolutions []mail.MXResolution, spfDepth int) string { +func RenderMail(records *mail.Records, mxResolutions []dnsutil.HostResolution, spfDepth int) string { var b strings.Builder b.WriteString(domainSectionTitle("Mail Configuration")) b.WriteString("\n\n") - resolutionByHost := map[string]mail.MXResolution{} + resolutionByHost := map[string]dnsutil.HostResolution{} for _, r := range mxResolutions { resolutionByHost[r.Host] = r } @@ -44,8 +45,8 @@ func RenderMail(records *mail.Records, mxResolutions []mail.MXResolution, spfDep } else { for _, ip := range res.IPs { line := recordStyle.Render(" " + ip.IP) - if ip.PTR != "" { - line += " " + dimStyle.Render("("+ip.PTR+")") + if len(ip.PTRs) > 0 { + line += " " + dimStyle.Render("("+strings.Join(ip.PTRs, ", ")+")") } b.WriteString(row("", line)) } diff --git a/internal/dns/dns.go b/internal/dns/dns.go index efd4349..e44e391 100644 --- a/internal/dns/dns.go +++ b/internal/dns/dns.go @@ -11,16 +11,17 @@ import ( ) type Records struct { - A []string - AAAA []string - CNAME []string - HTTPS []HTTPSRecord - MX []MXRecord - NS []string - TXT []string - PTR []PTRRecord - SOA *SOARecord - DNSSEC bool + A []string + AAAA []string + CNAME []string + HTTPS []HTTPSRecord + MX []MXRecord + NS []string + NSResolved []dnsutil.HostResolution `json:",omitempty"` + TXT []string + PTR []PTRRecord + SOA *SOARecord + DNSSEC bool } // HTTPSRecord is an HTTPS (SVCB-family) resource record (RFC 9460). diff --git a/internal/dnsutil/resolve.go b/internal/dnsutil/resolve.go new file mode 100644 index 0000000..84e7a47 --- /dev/null +++ b/internal/dnsutil/resolve.go @@ -0,0 +1,81 @@ +package dnsutil + +import ( + "context" + "net" + "strings" + "sync" + "time" +) + +const resolveTimeout = 5 * time.Second + +// HostResolution pairs a hostname with its resolved IP addresses (and reverse +// DNS). Err is set instead of IPs when the forward lookup fails. +type HostResolution struct { + Host string + IPs []HostIP + Err string `json:",omitempty"` +} + +// HostIP is a single resolved address and its reverse DNS names (if any). An +// address can map to more than one PTR record, so all are kept. +type HostIP struct { + IP string + PTRs []string `json:",omitempty"` +} + +// hostResolver is the subset of *net.Resolver used by resolveHosts, extracted +// so the resolution logic can be tested without real DNS. +type hostResolver interface { + LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) +} + +// ResolveHosts looks up A/AAAA records and reverse DNS for each host +// concurrently, using the configured resolver. It returns one HostResolution +// per input host, in the same order. +func ResolveHosts(hosts []string) []HostResolution { + return resolveHosts(hosts, GoResolver(resolveTimeout)) +} + +func resolveHosts(hosts []string, resolver hostResolver) []HostResolution { + out := make([]HostResolution, len(hosts)) + var wg sync.WaitGroup + for i, h := range hosts { + wg.Add(1) + go func(i int, host string) { + defer wg.Done() + out[i].Host = host + ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout) + defer cancel() + addrs, err := resolver.LookupIPAddr(ctx, host) + if err != nil { + out[i].Err = err.Error() + return + } + ips := make([]HostIP, len(addrs)) + var inner sync.WaitGroup + for j, a := range addrs { + ips[j].IP = a.IP.String() + inner.Add(1) + go func(j int, ip string) { + defer inner.Done() + rctx, rcancel := context.WithTimeout(context.Background(), resolveTimeout) + defer rcancel() + names, err := resolver.LookupAddr(rctx, ip) + if err != nil { + return + } + for _, name := range names { + ips[j].PTRs = append(ips[j].PTRs, strings.TrimSuffix(name, ".")) + } + }(j, ips[j].IP) + } + inner.Wait() + out[i].IPs = ips + }(i, h) + } + wg.Wait() + return out +} diff --git a/internal/dnsutil/resolve_test.go b/internal/dnsutil/resolve_test.go new file mode 100644 index 0000000..1b79825 --- /dev/null +++ b/internal/dnsutil/resolve_test.go @@ -0,0 +1,104 @@ +package dnsutil + +import ( + "context" + "errors" + "net" + "reflect" + "testing" +) + +type fakeResolver struct { + ips map[string][]net.IPAddr + ipErr map[string]error + ptrs map[string][]string + ptrErr map[string]error +} + +func (f fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) { + if err := f.ipErr[host]; err != nil { + return nil, err + } + return f.ips[host], nil +} + +func (f fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) { + if err := f.ptrErr[addr]; err != nil { + return nil, err + } + return f.ptrs[addr], nil +} + +func ipAddrs(ips ...string) []net.IPAddr { + out := make([]net.IPAddr, len(ips)) + for i, ip := range ips { + out[i] = net.IPAddr{IP: net.ParseIP(ip)} + } + return out +} + +func TestResolveHosts(t *testing.T) { + r := fakeResolver{ + ips: map[string][]net.IPAddr{ + "ns1.example.com": ipAddrs("192.0.2.1", "2001:db8::1"), + "ns2.example.com": ipAddrs("192.0.2.2"), + "noptr.example.com": ipAddrs("192.0.2.9"), + }, + ptrs: map[string][]string{ + // Two PTRs for one address, with trailing dots to be trimmed. + "192.0.2.1": {"a.example.com.", "b.example.com."}, + "2001:db8::1": {"v6.example.com."}, + "192.0.2.2": {"ns2.example.com."}, + }, + ipErr: map[string]error{ + "broken.example.com": errors.New("no such host"), + }, + ptrErr: map[string]error{ + "192.0.2.9": errors.New("reverse failed"), + }, + } + + hosts := []string{"ns1.example.com", "ns2.example.com", "broken.example.com", "noptr.example.com"} + got := resolveHosts(hosts, r) + + // Order is preserved 1:1 with the input. + if len(got) != len(hosts) { + t.Fatalf("got %d resolutions, want %d", len(got), len(hosts)) + } + for i, h := range hosts { + if got[i].Host != h { + t.Errorf("resolution[%d].Host = %q, want %q", i, got[i].Host, h) + } + } + + // Multiple IPs, and multiple PTRs per IP, with trailing dots trimmed. + ns1 := got[0] + if ns1.Err != "" { + t.Errorf("ns1 unexpected Err: %q", ns1.Err) + } + want := []HostIP{ + {IP: "192.0.2.1", PTRs: []string{"a.example.com", "b.example.com"}}, + {IP: "2001:db8::1", PTRs: []string{"v6.example.com"}}, + } + if !reflect.DeepEqual(ns1.IPs, want) { + t.Errorf("ns1.IPs = %+v, want %+v", ns1.IPs, want) + } + + // Forward-lookup failure records Err and no IPs. + broken := got[2] + if broken.Err == "" { + t.Errorf("broken: expected Err to be set") + } + if len(broken.IPs) != 0 { + t.Errorf("broken: expected no IPs, got %+v", broken.IPs) + } + + // Reverse-lookup failure leaves the IP present with no PTRs. + noptr := got[3] + if len(noptr.IPs) != 1 || noptr.IPs[0].IP != "192.0.2.9" { + t.Fatalf("noptr.IPs = %+v, want single 192.0.2.9", noptr.IPs) + } + if len(noptr.IPs[0].PTRs) != 0 { + t.Errorf("noptr PTRs = %v, want none", noptr.IPs[0].PTRs) + } +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 6168f2d..70a213b 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -1,9 +1,7 @@ package mail import ( - "context" "fmt" - "net" "os" "sort" "strings" @@ -20,6 +18,7 @@ const DKIMSelectorsEnvVar = "QUIEN_DKIM_SELECTORS" type Records struct { MX []MXRecord + MXResolved []dnsutil.HostResolution `json:",omitempty"` SPF string SPFAnalysis *SPFAnalysis `json:",omitempty"` DMARC string @@ -239,62 +238,6 @@ func lookupDKIM(domain string, selectors []string, resolver string) []DKIMRecord return out } -// MXResolution pairs an MX host with its resolved IP addresses (and reverse DNS). -type MXResolution struct { - Host string - IPs []MXIP - Err string -} - -type MXIP struct { - IP string - PTR string -} - -// ResolveMX looks up A/AAAA records and reverse DNS for each MX host concurrently. -func ResolveMX(hosts []string) []MXResolution { - out := make([]MXResolution, len(hosts)) - resolver := resolverForMX() - var wg sync.WaitGroup - for i, h := range hosts { - wg.Add(1) - go func(i int, host string) { - defer wg.Done() - out[i].Host = host - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - addrs, err := resolver.LookupIPAddr(ctx, host) - if err != nil { - out[i].Err = err.Error() - return - } - ips := make([]MXIP, len(addrs)) - var inner sync.WaitGroup - for j, a := range addrs { - ips[j].IP = a.IP.String() - inner.Add(1) - go func(j int, ip string) { - defer inner.Done() - rctx, rcancel := context.WithTimeout(context.Background(), timeout) - defer rcancel() - names, err := resolver.LookupAddr(rctx, ip) - if err == nil && len(names) > 0 { - ips[j].PTR = strings.TrimSuffix(names[0], ".") - } - }(j, ips[j].IP) - } - inner.Wait() - out[i].IPs = ips - }(i, h) - } - wg.Wait() - return out -} - -func resolverForMX() *net.Resolver { - return dnsutil.GoResolver(timeout) -} - func query(name string, qtype uint16, resolver string) ([]mdns.RR, error) { msg := new(mdns.Msg) msg.SetQuestion(name, qtype)