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
5 changes: 4 additions & 1 deletion cmd/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ var allCmd = &cobra.Command{
Short: "Run all lookups combined (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
input := normalizeDomain(args[0])
input, err := normalizeDomain(args[0])
if err != nil {
return err
}
result := allResult{}

isIP := net.ParseIP(input) != nil
Expand Down
11 changes: 7 additions & 4 deletions cmd/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package cmd
import (
"encoding/json"
"fmt"
"strings"

"github.com/retlehs/quien/internal/dns"
"github.com/retlehs/quien/internal/resolver"
"github.com/retlehs/quien/internal/retry"
"github.com/spf13/cobra"
)
Expand All @@ -15,7 +15,10 @@ var dnsCmd = &cobra.Command{
Short: "DNS record lookup (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
domain, err := normalizeDomain(args[0])
if err != nil {
return err
}
records, err := retry.Do(func() (*dns.Records, error) {
return dns.Lookup(domain)
})
Expand All @@ -30,8 +33,8 @@ func init() {
rootCmd.AddCommand(dnsCmd)
}

func normalizeDomain(s string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".")
func normalizeDomain(s string) (string, error) {
return resolver.NormalizeDomain(s)
}

func printJSON(v any) error {
Expand Down
5 changes: 4 additions & 1 deletion cmd/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ var httpCmd = &cobra.Command{
Short: "HTTP header and redirect lookup (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
domain, err := normalizeDomain(args[0])
if err != nil {
return err
}
result, err := retry.Do(func() (*httpinfo.Result, error) {
return httpinfo.Lookup(domain)
})
Expand Down
5 changes: 4 additions & 1 deletion cmd/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ var mailCmd = &cobra.Command{
Short: "Mail configuration lookup — MX, SPF, DMARC, DKIM, BIMI (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
domain, err := normalizeDomain(args[0])
if err != nil {
return err
}
records, err := retry.Do(func() (*mail.Records, error) {
return mail.Lookup(domain)
})
Expand Down
10 changes: 9 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ var rootCmd = &cobra.Command{
return runLookup(input, isIP)
}

input := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(args[0])), ".")
input, err := normalizeDomain(args[0])
if err != nil {
return err
}

isIP := net.ParseIP(input) != nil

Expand All @@ -97,6 +100,11 @@ var rootCmd = &cobra.Command{

func runLookup(input string, isIP bool) error {
if !isIP {
normalized, err := normalizeDomain(input)
if err != nil {
return err
}
input = normalized
if _, err := resolver.RegistrableDomain(input); err != nil {
return err
}
Expand Down
5 changes: 4 additions & 1 deletion cmd/seo.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ var seoCmd = &cobra.Command{
Short: "SEO and Core Web Vitals analysis (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
domain, err := normalizeDomain(args[0])
if err != nil {
return err
}
result, err := retry.Do(func() (*seo.Result, error) {
return seo.Analyze(domain)
})
Expand Down
5 changes: 4 additions & 1 deletion cmd/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ var stackCmd = &cobra.Command{
Short: "Detect technology stack — CMS, frameworks, libraries (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
domain, err := normalizeDomain(args[0])
if err != nil {
return err
}
result, err := retry.Do(func() (*stack.Result, error) {
return stack.Detect(domain)
})
Expand Down
5 changes: 4 additions & 1 deletion cmd/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ var tlsCmd = &cobra.Command{
Short: "SSL/TLS certificate lookup (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domain := normalizeDomain(args[0])
domain, err := normalizeDomain(args[0])
if err != nil {
return err
}
cert, err := retry.Do(func() (*tlsinfo.CertInfo, error) {
return tlsinfo.Lookup(domain)
})
Expand Down
5 changes: 4 additions & 1 deletion cmd/whois.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ var whoisCmd = &cobra.Command{
Short: "WHOIS/RDAP registration lookup (JSON output)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
input := normalizeDomain(args[0])
input, err := normalizeDomain(args[0])
if err != nil {
return err
}
if net.ParseIP(input) != nil {
info, err := resolver.LookupIP(input)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ require (
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
34 changes: 34 additions & 0 deletions internal/resolver/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,40 @@ package resolver

import (
"fmt"
"net"
"strings"

"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)

// idnaProfile parses and validates domains under the IDNA2008 "lookup" rules,
// mapping case/width and enforcing the Bidi rule before converting to ASCII.
var idnaProfile = idna.New(idna.MapForLookup(), idna.BidiRule(), idna.Transitional(false))

// toASCII converts a domain to its ASCII (punycode) form, returning an error
// for input that isn't a valid domain under IDNA lookup rules.
func toASCII(s string) (string, error) {
return idnaProfile.ToASCII(s)
}

// NormalizeDomain trims surrounding whitespace and a trailing dot from s and
// returns its lowercased ASCII (punycode) form. IP literals pass through
// unchanged — they aren't domains and IDNA rejects the colons in IPv6 — while
// any other input that isn't a valid domain under IDNA lookup rules yields an
// error so callers can reject it instead of dispatching a doomed lookup.
func NormalizeDomain(s string) (string, error) {
s = strings.TrimSuffix(strings.TrimSpace(s), ".")
if net.ParseIP(s) != nil {
return strings.ToLower(s), nil
}
ascii, err := toASCII(s)
if err != nil {
return "", fmt.Errorf("%q is not a valid domain", s)
}
return ascii, nil
}

// RegistrableDomain returns the effective TLD+1 for s — the registrable
// domain that a WHOIS/RDAP registry will actually answer queries for.
// For "mail.google.com" it returns "google.com"; for "sub.example.co.jp" it
Expand All @@ -16,6 +45,11 @@ import (
// "co.jp", anything without a dot) return an error so callers can reject them
// cleanly instead of dispatching a doomed WHOIS query.
func RegistrableDomain(s string) (string, error) {
ascii, err := toASCII(s)
if err != nil {
return "", fmt.Errorf("%q is not a valid domain", s)
}
s = ascii
if len(s) < 3 || len(s) > 253 {
return "", fmt.Errorf("%q is not a valid domain", s)
}
Expand Down
124 changes: 124 additions & 0 deletions internal/resolver/normalize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package resolver

import (
"strings"
"testing"
"unicode/utf8"
)

func TestNormalizeDomain(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"cyrillic IDN to punycode", "евау.world", "xn--80adi2d.world"},
{"already punycode is idempotent", "xn--80adi2d.world", "xn--80adi2d.world"},
{"uppercase ASCII lowercased", "EXAMPLE.COM", "example.com"},
{"plain ASCII unchanged", "example.com", "example.com"},
{"trailing dot trimmed", "евау.world.", "xn--80adi2d.world"},
{"surrounding whitespace trimmed", " example.com ", "example.com"},
{"IP literal passes through", "8.8.8.8", "8.8.8.8"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeDomain(tt.in)
if err != nil {
t.Fatalf("NormalizeDomain(%q) returned error: %v", tt.in, err)
}
if got != tt.want {
t.Errorf("NormalizeDomain(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

func TestNormalizeDomainIDNProducesASCII(t *testing.T) {
for _, in := range []string{"ébay.it", "iphone.セール", "zwick.ελ"} {
got, err := NormalizeDomain(in)
if err != nil {
t.Errorf("NormalizeDomain(%q) returned error: %v", in, err)
continue
}
if utf8.RuneCountInString(got) != len(got) {
t.Errorf("NormalizeDomain(%q) = %q, want all-ASCII", in, got)
}
if !strings.Contains(got, "xn--") {
t.Errorf("NormalizeDomain(%q) = %q, want a punycode (xn--) label", in, got)
}
}
}

// Malformed IDNs (here a Bidi-rule violation) must be rejected rather than
// passed through as raw Unicode, otherwise a doomed lookup gets dispatched.
func TestNormalizeDomainRejectsInvalidIDN(t *testing.T) {
for _, in := range []string{"صلa1.com", "xn--a.com"} {
if got, err := NormalizeDomain(in); err == nil {
t.Errorf("NormalizeDomain(%q) = %q, want error", in, got)
}
}
}

func TestRegistrableDomainIDN(t *testing.T) {
// Cases where the exact registrable domain is known.
exact := []struct {
in string
want string
}{
{"евау.world", "xn--80adi2d.world"},
{"www.евау.world", "xn--80adi2d.world"},
}
for _, tt := range exact {
got, err := RegistrableDomain(tt.in)
if err != nil {
t.Errorf("RegistrableDomain(%q) returned error: %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("RegistrableDomain(%q) = %q, want %q", tt.in, got, tt.want)
}
}

structural := []string{"ébay.it", "iphone.セール", "zwick.ελ"}
for _, in := range structural {
got, err := RegistrableDomain(in)
if err != nil {
t.Errorf("RegistrableDomain(%q) returned error: %v", in, err)
continue
}
if utf8.RuneCountInString(got) != len(got) {
t.Errorf("RegistrableDomain(%q) = %q, want all-ASCII", in, got)
}
if !strings.Contains(got, "xn--") {
t.Errorf("RegistrableDomain(%q) = %q, want a punycode (xn--) label", in, got)
}
}
}

func TestRegistrableDomainASCIIRegression(t *testing.T) {
ok := []struct {
in string
want string
}{
{"mail.google.com", "google.com"},
{"sub.example.co.jp", "example.co.jp"},
{"example.com", "example.com"},
}
for _, tt := range ok {
got, err := RegistrableDomain(tt.in)
if err != nil {
t.Errorf("RegistrableDomain(%q) returned error: %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("RegistrableDomain(%q) = %q, want %q", tt.in, got, tt.want)
}
}

bad := []string{"hello", "co.jp", "x"}
for _, in := range bad {
if got, err := RegistrableDomain(in); err == nil {
t.Errorf("RegistrableDomain(%q) = %q, want error", in, got)
}
}
}