Skip to content

Commit 6160f3e

Browse files
retlehsclaude
andauthored
Add HTTPS (SVCB) resource record reporting to the DNS panel (#40)
Reports HTTPS resource records (RFC 9460) in DNS lookups, surfacing the SvcParams operators care about: ALPN, target, priority, port, IPv4/IPv6 hints, and ECH config presence. Records render in the DNS panel and flow into the dns/all JSON output automatically. Closes #36. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6bd4f22 commit 6160f3e

4 files changed

Lines changed: 248 additions & 0 deletions

File tree

internal/display/dns.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ func RenderDNS(records *dns.Records) string {
4545
}
4646
}
4747

48+
if len(records.HTTPS) > 0 {
49+
hasRecords = true
50+
b.WriteString("\n")
51+
b.WriteString(section("HTTPS"))
52+
for _, h := range records.HTTPS {
53+
b.WriteString(renderHTTPSRecord(h))
54+
}
55+
}
56+
4857
if len(records.MX) > 0 {
4958
hasRecords = true
5059
b.WriteString("\n")
@@ -113,3 +122,53 @@ func RenderDNS(records *dns.Records) string {
113122

114123
return b.String()
115124
}
125+
126+
// renderHTTPSRecord renders a single HTTPS (SVCB) record. Priority 0 is alias
127+
// mode and only carries a target; otherwise the SvcParams are listed. A target
128+
// of "." (the owner name itself) is the common case and shown without a header.
129+
func renderHTTPSRecord(h dns.HTTPSRecord) string {
130+
var b strings.Builder
131+
132+
target := h.Target
133+
if target == "." {
134+
target = ""
135+
}
136+
137+
if h.Priority == 0 {
138+
alias := dimStyle.Render("alias → ")
139+
if target == "" {
140+
return row("", alias+dimStyle.Render("(self)"))
141+
}
142+
return row("", alias+nsStyle.Render(target))
143+
}
144+
145+
// Service mode: always emit an identity row so priority is visible and
146+
// multiple records stay visually separated, even for the common "." target.
147+
pri := dimStyle.Render(fmt.Sprintf("(%d)", h.Priority))
148+
if target == "" {
149+
b.WriteString(row("", dimStyle.Render("(self)")+" "+pri))
150+
} else {
151+
b.WriteString(row("", nsStyle.Render(target)+" "+pri))
152+
}
153+
154+
if len(h.ALPN) > 0 {
155+
b.WriteString(row("alpn", strings.Join(h.ALPN, ", ")))
156+
}
157+
if h.Port != 0 {
158+
b.WriteString(row("port", fmt.Sprintf("%d", h.Port)))
159+
}
160+
for _, ip := range h.IPv4Hint {
161+
b.WriteString(row("ipv4hint", nsStyle.Render(ip)))
162+
}
163+
for _, ip := range h.IPv6Hint {
164+
b.WriteString(row("ipv6hint", nsStyle.Render(ip)))
165+
}
166+
if h.ECHConfig != "" {
167+
b.WriteString(row("ech", lipgloss.NewStyle().Foreground(green).Render("present")))
168+
}
169+
for _, p := range h.Params {
170+
b.WriteString(row("", dimStyle.Render(p)))
171+
}
172+
173+
return b.String()
174+
}

internal/display/dns_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package display
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/retlehs/quien/internal/dns"
8+
)
9+
10+
func TestRenderHTTPSRecord(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
record dns.HTTPSRecord
14+
want []string // substrings expected in the output
15+
notWant []string
16+
}{
17+
{
18+
name: "service mode self target shows priority and self",
19+
record: dns.HTTPSRecord{
20+
Priority: 1,
21+
Target: ".",
22+
ALPN: []string{"h3", "h2"},
23+
IPv4Hint: []string{"104.16.132.229"},
24+
ECHConfig: "AAE=",
25+
},
26+
want: []string{"(self)", "(1)", "alpn", "h3, h2", "ipv4hint", "104.16.132.229", "ech", "present"},
27+
},
28+
{
29+
name: "alias mode shows target",
30+
record: dns.HTTPSRecord{Priority: 0, Target: "svc.example.net"},
31+
want: []string{"alias", "svc.example.net"},
32+
notWant: []string{"alpn", "(self)"},
33+
},
34+
{
35+
name: "service mode named target shows target and priority",
36+
record: dns.HTTPSRecord{Priority: 16, Target: "svc.example.net", Port: 8443},
37+
want: []string{"svc.example.net", "(16)", "port", "8443"},
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
got := renderHTTPSRecord(tt.record)
44+
for _, sub := range tt.want {
45+
if !strings.Contains(got, sub) {
46+
t.Errorf("output missing %q\ngot:\n%s", sub, got)
47+
}
48+
}
49+
for _, sub := range tt.notWant {
50+
if strings.Contains(got, sub) {
51+
t.Errorf("output unexpectedly contains %q\ngot:\n%s", sub, got)
52+
}
53+
}
54+
})
55+
}
56+
}

internal/dns/dns.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type Records struct {
1414
A []string
1515
AAAA []string
1616
CNAME []string
17+
HTTPS []HTTPSRecord
1718
MX []MXRecord
1819
NS []string
1920
TXT []string
@@ -22,6 +23,20 @@ type Records struct {
2223
DNSSEC bool
2324
}
2425

26+
// HTTPSRecord is an HTTPS (SVCB-family) resource record (RFC 9460).
27+
// Priority 0 is alias mode; a non-zero priority is service mode and
28+
// carries the SvcParams below. A Target of "." means the owner name.
29+
type HTTPSRecord struct {
30+
Priority uint16
31+
Target string
32+
ALPN []string
33+
Port uint16
34+
IPv4Hint []string
35+
IPv6Hint []string
36+
ECHConfig string // base64 ECHConfigList, empty when absent
37+
Params []string // other/unknown SvcParams as "key=value"
38+
}
39+
2540
type PTRRecord struct {
2641
IP string
2742
Hostname string
@@ -111,6 +126,10 @@ func Lookup(domain string) (*Records, error) {
111126
}
112127
}
113128

129+
if rr, err := query(domain, mdns.TypeHTTPS, resolver); err == nil {
130+
records.HTTPS = parseHTTPS(rr)
131+
}
132+
114133
if rr, err := query(domain, mdns.TypeMX, resolver); err == nil {
115134
for _, r := range rr {
116135
if mx, ok := r.(*mdns.MX); ok {
@@ -182,6 +201,48 @@ func Lookup(domain string) (*Records, error) {
182201
return records, nil
183202
}
184203

204+
// parseHTTPS extracts the SvcParams from HTTPS (SVCB) resource records into a
205+
// display-friendly form. Well-known keys (ALPN, port, IP hints, ECH) get their
206+
// own fields; anything else is preserved verbatim in Params.
207+
func parseHTTPS(rr []mdns.RR) []HTTPSRecord {
208+
var out []HTTPSRecord
209+
for _, r := range rr {
210+
h, ok := r.(*mdns.HTTPS)
211+
if !ok {
212+
continue
213+
}
214+
rec := HTTPSRecord{Priority: h.Priority, Target: h.Target}
215+
if rec.Target != "." {
216+
rec.Target = strings.TrimSuffix(rec.Target, ".")
217+
}
218+
for _, kv := range h.Value {
219+
switch v := kv.(type) {
220+
case *mdns.SVCBAlpn:
221+
rec.ALPN = v.Alpn
222+
case *mdns.SVCBPort:
223+
rec.Port = v.Port
224+
case *mdns.SVCBIPv4Hint:
225+
for _, ip := range v.Hint {
226+
rec.IPv4Hint = append(rec.IPv4Hint, ip.String())
227+
}
228+
case *mdns.SVCBIPv6Hint:
229+
for _, ip := range v.Hint {
230+
rec.IPv6Hint = append(rec.IPv6Hint, ip.String())
231+
}
232+
case *mdns.SVCBECHConfig:
233+
rec.ECHConfig = v.String()
234+
default:
235+
rec.Params = append(rec.Params, kv.Key().String()+"="+kv.String())
236+
}
237+
}
238+
out = append(out, rec)
239+
}
240+
sort.Slice(out, func(i, j int) bool {
241+
return out[i].Priority < out[j].Priority
242+
})
243+
return out
244+
}
245+
185246
func query(domain string, qtype uint16, resolver string) ([]mdns.RR, error) {
186247
msg := new(mdns.Msg)
187248
msg.SetQuestion(domain, qtype)

internal/dns/dns_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dns
22

33
import (
44
"net"
5+
"slices"
56
"testing"
67

78
mdns "github.com/miekg/dns"
@@ -114,6 +115,77 @@ func TestQueryUDPSuccessSkipsTCPFallback(t *testing.T) {
114115
}
115116
}
116117

118+
func TestParseHTTPS(t *testing.T) {
119+
hdr := mdns.RR_Header{Name: "example.com.", Rrtype: mdns.TypeHTTPS, Class: mdns.ClassINET, Ttl: 300}
120+
121+
service := &mdns.HTTPS{SVCB: mdns.SVCB{
122+
Hdr: hdr,
123+
Priority: 2,
124+
Target: ".",
125+
Value: []mdns.SVCBKeyValue{
126+
&mdns.SVCBAlpn{Alpn: []string{"h3", "h2"}},
127+
&mdns.SVCBPort{Port: 8443},
128+
&mdns.SVCBIPv4Hint{Hint: []net.IP{net.ParseIP("192.0.2.1")}},
129+
&mdns.SVCBIPv6Hint{Hint: []net.IP{net.ParseIP("2001:db8::1")}},
130+
&mdns.SVCBECHConfig{ECH: []byte{0x00, 0x01}},
131+
&mdns.SVCBMandatory{Code: []mdns.SVCBKey{mdns.SVCB_ALPN}},
132+
},
133+
}}
134+
alias := &mdns.HTTPS{SVCB: mdns.SVCB{
135+
Hdr: hdr,
136+
Priority: 0,
137+
Target: "svc.example.net.",
138+
}}
139+
140+
// Pass the higher-priority record first to confirm sorting by priority.
141+
got := parseHTTPS([]mdns.RR{service, alias})
142+
143+
if len(got) != 2 {
144+
t.Fatalf("expected 2 records, got %d", len(got))
145+
}
146+
147+
// Alias (priority 0) should sort ahead of the service record.
148+
if got[0].Priority != 0 || got[0].Target != "svc.example.net" {
149+
t.Errorf("record[0] = %+v, want alias mode targeting svc.example.net", got[0])
150+
}
151+
152+
svc := got[1]
153+
if svc.Priority != 2 {
154+
t.Errorf("priority = %d, want 2", svc.Priority)
155+
}
156+
if svc.Target != "." {
157+
t.Errorf("target = %q, want \".\" (self)", svc.Target)
158+
}
159+
if want := []string{"h3", "h2"}; !slices.Equal(svc.ALPN, want) {
160+
t.Errorf("alpn = %v, want %v", svc.ALPN, want)
161+
}
162+
if svc.Port != 8443 {
163+
t.Errorf("port = %d, want 8443", svc.Port)
164+
}
165+
if want := []string{"192.0.2.1"}; !slices.Equal(svc.IPv4Hint, want) {
166+
t.Errorf("ipv4hint = %v, want %v", svc.IPv4Hint, want)
167+
}
168+
if want := []string{"2001:db8::1"}; !slices.Equal(svc.IPv6Hint, want) {
169+
t.Errorf("ipv6hint = %v, want %v", svc.IPv6Hint, want)
170+
}
171+
if svc.ECHConfig != "AAE=" {
172+
t.Errorf("ech config = %q, want base64 of {0x00,0x01}", svc.ECHConfig)
173+
}
174+
if want := []string{"mandatory=alpn"}; !slices.Equal(svc.Params, want) {
175+
t.Errorf("params = %v, want %v", svc.Params, want)
176+
}
177+
}
178+
179+
func TestParseHTTPSIgnoresNonHTTPS(t *testing.T) {
180+
a := &mdns.A{
181+
Hdr: mdns.RR_Header{Name: "example.com.", Rrtype: mdns.TypeA, Class: mdns.ClassINET, Ttl: 60},
182+
A: net.ParseIP("192.0.2.1"),
183+
}
184+
if got := parseHTTPS([]mdns.RR{a}); got != nil {
185+
t.Errorf("expected nil for non-HTTPS records, got %v", got)
186+
}
187+
}
188+
117189
func TestDecodeRNAME(t *testing.T) {
118190
tests := []struct {
119191
name string

0 commit comments

Comments
 (0)