Skip to content

Commit 358b03e

Browse files
retlehsclaude
andauthored
Allow configurable DKIM selectors (#33)
The hard-coded common-selector probe missed domains using custom or provider-specific selectors, silently reporting "no DKIM" even when records existed. User-supplied selectors are probed alongside the built-in list, with the DKIM probes now running in parallel so adding selectors does not scale latency. The flag is persistent on the root command and writes through to QUIEN_DKIM_SELECTORS, so the TUI, JSON subcommands, and `quien all` all honor it. Closes #31 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b2e1db commit 358b03e

5 files changed

Lines changed: 281 additions & 15 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ quien seo example.com
8282
quien stack example.com
8383
quien all example.com
8484
85+
# Probe additional DKIM selectors beyond the built-in common list
86+
quien mail example.com --dkim-selector mySelector --dkim-selector other
87+
88+
# Set default DKIM selectors via environment variable
89+
QUIEN_DKIM_SELECTORS=mySelector,other quien example.com
90+
8591
# Use a specific DNS resolver for this run
8692
quien mail example.com --resolver 9.9.9.9
8793
@@ -91,6 +97,8 @@ QUIEN_RESOLVER=1.1.1.1 quien dns example.com
9197

9298
Resolver precedence: `--resolver` > `QUIEN_RESOLVER` > system resolver.
9399

100+
DKIM selector precedence: `--dkim-selector` > `QUIEN_DKIM_SELECTORS`. User selectors are probed in addition to the built-in common-selector list.
101+
94102
## Core Web Vitals
95103

96104
The SEO tab includes local checks out of the box. For Core Web Vitals field data (real-user metrics from Chrome), set a CrUX API key:

cmd/root.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import (
99
tea "charm.land/bubbletea/v2"
1010
"github.com/retlehs/quien/internal/display"
1111
"github.com/retlehs/quien/internal/dnsutil"
12+
"github.com/retlehs/quien/internal/mail"
1213
"github.com/retlehs/quien/internal/resolver"
1314
"github.com/spf13/cobra"
1415
"golang.org/x/term"
1516
)
1617

1718
var jsonFlag bool
1819
var resolverFlag string
20+
var dkimSelectorFlag []string
1921

2022
var rootCmd = &cobra.Command{
2123
Use: "quien [domain or IP]",
@@ -24,6 +26,12 @@ var rootCmd = &cobra.Command{
2426
Args: cobra.MaximumNArgs(1),
2527
SilenceUsage: true,
2628
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
29+
if len(dkimSelectorFlag) > 0 {
30+
if err := os.Setenv(mail.DKIMSelectorsEnvVar, strings.Join(dkimSelectorFlag, ",")); err != nil {
31+
return err
32+
}
33+
}
34+
2735
if resolverFlag != "" {
2836
normalized, err := dnsutil.NormalizeResolver(resolverFlag)
2937
if err != nil {
@@ -127,6 +135,7 @@ func runLookup(input string, isIP bool) error {
127135
func init() {
128136
rootCmd.Flags().BoolVar(&jsonFlag, "json", false, "output as JSON")
129137
rootCmd.PersistentFlags().StringVar(&resolverFlag, "resolver", "", "DNS resolver to use for DNS/mail lookups (host or host:port). Overrides "+dnsutil.ResolverEnvVar)
138+
rootCmd.PersistentFlags().StringSliceVar(&dkimSelectorFlag, "dkim-selector", nil, "DKIM selector(s) to probe in addition to the built-in common list (repeatable, comma-separated). Overrides "+mail.DKIMSelectorsEnvVar)
130139
}
131140

132141
func Execute(version, commit, date string) {

internal/display/mail.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func RenderMail(records *mail.Records, mxResolutions []mail.MXResolution, spfDep
8181
b.WriteString(wrapRecord(dk.Value))
8282
}
8383
} else {
84-
b.WriteString(row("Status", dimStyle.Render("no records found (checked common selectors)")))
84+
b.WriteString(row("Status", dimStyle.Render("no records found for checked selectors")))
8585
}
8686

8787
// BIMI

internal/mail/mail.go

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net"
7+
"os"
78
"sort"
89
"strings"
910
"sync"
@@ -13,6 +14,10 @@ import (
1314
"github.com/retlehs/quien/internal/dnsutil"
1415
)
1516

17+
// DKIMSelectorsEnvVar holds a comma-separated list of DKIM selectors that are
18+
// probed in addition to the built-in common-selector list.
19+
const DKIMSelectorsEnvVar = "QUIEN_DKIM_SELECTORS"
20+
1621
type Records struct {
1722
MX []MXRecord
1823
SPF string
@@ -70,11 +75,33 @@ var dkimSelectors = []string{
7075
"sig1", // Hubspot
7176
}
7277

78+
// LookupOptions tunes Lookup behavior.
79+
type LookupOptions struct {
80+
// DKIMSelectors are user-supplied selectors probed in addition to the
81+
// built-in common-selector list. User selectors are probed first; the
82+
// merged list is deduped (case-insensitive, whitespace-trimmed).
83+
// When empty, QUIEN_DKIM_SELECTORS is consulted as a fallback.
84+
DKIMSelectors []string
85+
}
86+
7387
// Lookup queries MX, SPF, DKIM, and DMARC records for the given domain.
88+
// User selectors from QUIEN_DKIM_SELECTORS, if set, are probed alongside the
89+
// built-in common-selector list.
7490
func Lookup(domain string) (*Records, error) {
91+
return LookupWithOptions(domain, LookupOptions{})
92+
}
93+
94+
// LookupWithOptions is Lookup with caller-controlled options. When
95+
// opts.DKIMSelectors is empty, QUIEN_DKIM_SELECTORS is used as a fallback.
96+
func LookupWithOptions(domain string, opts LookupOptions) (*Records, error) {
7597
resolver := findResolver()
7698
records := &Records{}
7799

100+
userSelectors := opts.DKIMSelectors
101+
if len(userSelectors) == 0 {
102+
userSelectors = selectorsFromEnv()
103+
}
104+
78105
// MX
79106
if rr, err := query(domain+".", mdns.TypeMX, resolver); err == nil {
80107
for _, r := range rr {
@@ -129,25 +156,87 @@ func Lookup(domain string) (*Records, error) {
129156
records.BIMI.VMC = fetchVMC(records.BIMI.VMCURL)
130157
}
131158

132-
// DKIM — probe common selectors
133-
for _, sel := range dkimSelectors {
134-
qname := sel + "._domainkey." + domain + "."
135-
if rr, err := query(qname, mdns.TypeTXT, resolver); err == nil {
159+
// DKIM — probe merged selector list in parallel
160+
records.DKIM = lookupDKIM(domain, mergeDKIMSelectors(userSelectors, dkimSelectors), resolver)
161+
162+
return records, nil
163+
}
164+
165+
// selectorsFromEnv parses QUIEN_DKIM_SELECTORS into a slice. The env var is a
166+
// comma-separated list; whitespace is trimmed and empty entries are dropped.
167+
func selectorsFromEnv() []string {
168+
raw := strings.TrimSpace(os.Getenv(DKIMSelectorsEnvVar))
169+
if raw == "" {
170+
return nil
171+
}
172+
parts := strings.Split(raw, ",")
173+
out := make([]string, 0, len(parts))
174+
for _, p := range parts {
175+
if s := strings.TrimSpace(p); s != "" {
176+
out = append(out, s)
177+
}
178+
}
179+
return out
180+
}
181+
182+
// mergeDKIMSelectors returns user selectors first, then defaults, deduped
183+
// (case-insensitive after trimming) and dropping any empty entries.
184+
func mergeDKIMSelectors(user, defaults []string) []string {
185+
merged := make([]string, 0, len(user)+len(defaults))
186+
seen := map[string]struct{}{}
187+
for _, src := range [][]string{user, defaults} {
188+
for _, s := range src {
189+
s = strings.TrimSpace(s)
190+
if s == "" {
191+
continue
192+
}
193+
key := strings.ToLower(s)
194+
if _, ok := seen[key]; ok {
195+
continue
196+
}
197+
seen[key] = struct{}{}
198+
merged = append(merged, s)
199+
}
200+
}
201+
return merged
202+
}
203+
204+
// lookupDKIM probes each selector concurrently and returns matching v=DKIM1
205+
// TXT records in selector-order.
206+
func lookupDKIM(domain string, selectors []string, resolver string) []DKIMRecord {
207+
if len(selectors) == 0 {
208+
return nil
209+
}
210+
results := make([][]DKIMRecord, len(selectors))
211+
var wg sync.WaitGroup
212+
for i, sel := range selectors {
213+
wg.Add(1)
214+
go func(i int, sel string) {
215+
defer wg.Done()
216+
qname := sel + "._domainkey." + domain + "."
217+
rr, err := query(qname, mdns.TypeTXT, resolver)
218+
if err != nil {
219+
return
220+
}
136221
for _, r := range rr {
137-
if txt, ok := r.(*mdns.TXT); ok {
138-
val := strings.Join(txt.Txt, "")
139-
if strings.Contains(strings.ToLower(val), "v=dkim1") {
140-
records.DKIM = append(records.DKIM, DKIMRecord{
141-
Selector: sel,
142-
Value: val,
143-
})
144-
}
222+
txt, ok := r.(*mdns.TXT)
223+
if !ok {
224+
continue
225+
}
226+
val := strings.Join(txt.Txt, "")
227+
if strings.Contains(strings.ToLower(val), "v=dkim1") {
228+
results[i] = append(results[i], DKIMRecord{Selector: sel, Value: val})
145229
}
146230
}
147-
}
231+
}(i, sel)
148232
}
233+
wg.Wait()
149234

150-
return records, nil
235+
var out []DKIMRecord
236+
for _, r := range results {
237+
out = append(out, r...)
238+
}
239+
return out
151240
}
152241

153242
// MXResolution pairs an MX host with its resolved IP addresses (and reverse DNS).

internal/mail/mail_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package mail
22

33
import (
44
"net"
5+
"reflect"
6+
"strings"
57
"testing"
68

79
mdns "github.com/miekg/dns"
@@ -87,6 +89,164 @@ func TestQueryTCPFallbackOnTruncation(t *testing.T) {
8789
}
8890
}
8991

92+
func TestMergeDKIMSelectors(t *testing.T) {
93+
tests := []struct {
94+
name string
95+
user []string
96+
defaults []string
97+
want []string
98+
}{
99+
{
100+
name: "no user selectors keeps defaults in order",
101+
defaults: []string{"default", "google", "selector1"},
102+
want: []string{"default", "google", "selector1"},
103+
},
104+
{
105+
name: "user selectors come first",
106+
user: []string{"foo", "bar"},
107+
defaults: []string{"default", "google"},
108+
want: []string{"foo", "bar", "default", "google"},
109+
},
110+
{
111+
name: "dedupes case-insensitively, preserving user spelling",
112+
user: []string{"Google", "custom"},
113+
defaults: []string{"default", "google"},
114+
want: []string{"Google", "custom", "default"},
115+
},
116+
{
117+
name: "trims whitespace and drops empty entries",
118+
user: []string{" foo ", "", " "},
119+
defaults: []string{"default"},
120+
want: []string{"foo", "default"},
121+
},
122+
}
123+
124+
for _, tc := range tests {
125+
t.Run(tc.name, func(t *testing.T) {
126+
got := mergeDKIMSelectors(tc.user, tc.defaults)
127+
if !reflect.DeepEqual(got, tc.want) {
128+
t.Errorf("mergeDKIMSelectors(%v, %v) = %v, want %v", tc.user, tc.defaults, got, tc.want)
129+
}
130+
})
131+
}
132+
}
133+
134+
// startDKIMServer spins up a UDP DNS server that returns a v=DKIM1 TXT record
135+
// for each selector in records, and NXDOMAIN otherwise.
136+
func startDKIMServer(t *testing.T, domain string, records map[string]string) string {
137+
t.Helper()
138+
139+
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
140+
if err != nil {
141+
t.Fatal(err)
142+
}
143+
144+
handler := mdns.HandlerFunc(func(w mdns.ResponseWriter, r *mdns.Msg) {
145+
m := new(mdns.Msg)
146+
m.SetReply(r)
147+
q := r.Question[0]
148+
suffix := "._domainkey." + domain + "."
149+
if q.Qtype == mdns.TypeTXT && strings.HasSuffix(q.Name, suffix) {
150+
sel := strings.TrimSuffix(q.Name, suffix)
151+
if val, ok := records[sel]; ok {
152+
m.Answer = append(m.Answer, &mdns.TXT{
153+
Hdr: mdns.RR_Header{Name: q.Name, Rrtype: mdns.TypeTXT, Class: mdns.ClassINET, Ttl: 60},
154+
Txt: []string{val},
155+
})
156+
} else {
157+
m.Rcode = mdns.RcodeNameError
158+
}
159+
}
160+
_ = w.WriteMsg(m)
161+
})
162+
163+
server := &mdns.Server{PacketConn: conn, Handler: handler, Net: "udp"}
164+
go func() { _ = server.ActivateAndServe() }()
165+
t.Cleanup(func() { _ = server.Shutdown() })
166+
167+
return conn.LocalAddr().String()
168+
}
169+
170+
func TestLookupDKIMUserSelectorHit(t *testing.T) {
171+
domain := "example.com"
172+
resolver := startDKIMServer(t, domain, map[string]string{
173+
"custom": "v=DKIM1; k=rsa; p=PUBKEY",
174+
})
175+
176+
got := lookupDKIM(domain, []string{"custom", "default"}, resolver)
177+
if len(got) != 1 {
178+
t.Fatalf("expected 1 DKIM record, got %d", len(got))
179+
}
180+
if got[0].Selector != "custom" {
181+
t.Errorf("selector: got %q, want %q", got[0].Selector, "custom")
182+
}
183+
if !strings.Contains(strings.ToLower(got[0].Value), "v=dkim1") {
184+
t.Errorf("value missing v=dkim1: %q", got[0].Value)
185+
}
186+
}
187+
188+
func TestLookupDKIMPreservesSelectorOrder(t *testing.T) {
189+
domain := "example.com"
190+
resolver := startDKIMServer(t, domain, map[string]string{
191+
"google": "v=DKIM1; k=rsa; p=A",
192+
"custom": "v=DKIM1; k=rsa; p=B",
193+
"default": "v=DKIM1; k=rsa; p=C",
194+
})
195+
196+
// Order: user-supplied "custom" first, then built-ins "default", "google".
197+
selectors := mergeDKIMSelectors([]string{"custom"}, []string{"default", "google"})
198+
got := lookupDKIM(domain, selectors, resolver)
199+
200+
want := []string{"custom", "default", "google"}
201+
if len(got) != len(want) {
202+
t.Fatalf("expected %d records, got %d", len(want), len(got))
203+
}
204+
for i, sel := range want {
205+
if got[i].Selector != sel {
206+
t.Errorf("position %d: got %q, want %q", i, got[i].Selector, sel)
207+
}
208+
}
209+
}
210+
211+
func TestSelectorsFromEnv(t *testing.T) {
212+
tests := []struct {
213+
name string
214+
env string
215+
want []string
216+
}{
217+
{name: "unset", env: "", want: nil},
218+
{name: "single value", env: "foo", want: []string{"foo"}},
219+
{name: "comma separated", env: "foo,bar,baz", want: []string{"foo", "bar", "baz"}},
220+
{name: "trims whitespace and drops empties", env: " foo , , bar ,", want: []string{"foo", "bar"}},
221+
}
222+
223+
for _, tc := range tests {
224+
t.Run(tc.name, func(t *testing.T) {
225+
t.Setenv(DKIMSelectorsEnvVar, tc.env)
226+
got := selectorsFromEnv()
227+
if !reflect.DeepEqual(got, tc.want) {
228+
t.Errorf("selectorsFromEnv() = %v, want %v", got, tc.want)
229+
}
230+
})
231+
}
232+
}
233+
234+
func TestLookupDKIMSkipsNonDKIMTXT(t *testing.T) {
235+
domain := "example.com"
236+
resolver := startDKIMServer(t, domain, map[string]string{
237+
"foo": "not a dkim record",
238+
"bar": "v=DKIM1; k=rsa; p=PUBKEY",
239+
})
240+
241+
got := lookupDKIM(domain, []string{"foo", "bar"}, resolver)
242+
if len(got) != 1 {
243+
t.Fatalf("expected 1 DKIM record, got %d", len(got))
244+
}
245+
if got[0].Selector != "bar" {
246+
t.Errorf("selector: got %q, want %q", got[0].Selector, "bar")
247+
}
248+
}
249+
90250
func TestQueryUDPSuccessSkipsTCPFallback(t *testing.T) {
91251
domain := "small.example."
92252
spf := "v=spf1 -all"

0 commit comments

Comments
 (0)