Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
}
7 changes: 7 additions & 0 deletions cmd/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <domain>",
Short: "DNS record lookup (JSON output)",
Expand All @@ -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)
}

Expand Down
16 changes: 16 additions & 0 deletions cmd/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <domain>",
Short: "Mail configuration lookup — MX, SPF, DMARC, DKIM, BIMI (JSON output)",
Expand All @@ -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)
}
23 changes: 23 additions & 0 deletions cmd/mail_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 21 additions & 2 deletions internal/display/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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))
}
}
}
}
}

Expand Down
31 changes: 31 additions & 0 deletions internal/display/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/retlehs/quien/internal/dns"
"github.com/retlehs/quien/internal/dnsutil"
)

func TestRenderHTTPSRecord(t *testing.T) {
Expand Down Expand Up @@ -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)
}
}
56 changes: 51 additions & 5 deletions internal/display/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)}
}
}

Expand Down
9 changes: 5 additions & 4 deletions internal/display/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"

"charm.land/lipgloss/v2"
"github.com/retlehs/quien/internal/dnsutil"
"github.com/retlehs/quien/internal/mail"
)

Expand All @@ -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
}
Expand All @@ -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))
}
Expand Down
21 changes: 11 additions & 10 deletions internal/dns/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading