From 12d9457e71461c6864eb4be5ed3e94de32c9aa9c Mon Sep 17 00:00:00 2001 From: Ingmar Van Glabbeek <32110797+IngmarVG-IB@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:46:41 -0700 Subject: [PATCH] plugin/file: expand SVCB/HTTPS record support (#7950) * plugin/file: expand SVCB/HTTPS record support Add proper SVCB (type 64) and HTTPS (type 65) handling: - Additional section processing: include A/AAAA glue for in-bailiwick SVCB/HTTPS targets, matching existing SRV/MX behavior - Target name normalization: lowercase SVCB/HTTPS Target on zone insert, consistent with CNAME/MX handling - Metrics: add TypeSVCB to monitored query types (TypeHTTPS was already present) - Test helpers: add SVCB()/HTTPS() constructors and Section comparison cases - Tests: basic queries with glue, AliasMode, wildcards, NoData, NXDOMAIN, target normalization, and DNS-AID private-use key (65400-65408) round-trip Signed-off-by: Ingmar * plugin/file: simplify HTTPS target access via field promotion dns.HTTPS embeds dns.SVCB, so .Target is directly accessible without the redundant .SVCB. qualifier. Fixes gosimple S1027. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Ingmar --------- Signed-off-by: Ingmar Co-authored-by: Claude Opus 4.6 (1M context) --- plugin/file/lookup.go | 4 + plugin/file/svcb_test.go | 224 +++++++++++++++++++++++++++++++++ plugin/file/zone.go | 4 + plugin/metrics/vars/monitor.go | 1 + plugin/test/helpers.go | 28 +++++ 5 files changed, 261 insertions(+) create mode 100644 plugin/file/svcb_test.go diff --git a/plugin/file/lookup.go b/plugin/file/lookup.go index d2c57cab5..0948a0724 100644 --- a/plugin/file/lookup.go +++ b/plugin/file/lookup.go @@ -409,6 +409,10 @@ func (z *Zone) additionalProcessing(answer []dns.RR, do bool) (extra []dns.RR) { name = x.Target case *dns.MX: name = x.Mx + case *dns.SVCB: + name = x.Target + case *dns.HTTPS: + name = x.Target } if len(name) == 0 || !dns.IsSubDomain(z.origin, name) { continue diff --git a/plugin/file/svcb_test.go b/plugin/file/svcb_test.go new file mode 100644 index 000000000..7041b5b88 --- /dev/null +++ b/plugin/file/svcb_test.go @@ -0,0 +1,224 @@ +package file + +import ( + "context" + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +var svcbAuth = []dns.RR{ + test.NS("example.com. 1800 IN NS ns.example.com."), +} + +var svcbTestCases = []test.Case{ + { + // Basic SVCB query with additional section glue for in-bailiwick target + Qname: "_8443._foo.example.com.", Qtype: dns.TypeSVCB, + Answer: []dns.RR{ + test.SVCB("_8443._foo.example.com. 1800 IN SVCB 1 svc-target.example.com. alpn=\"h2,h3\" port=\"8443\""), + }, + Ns: svcbAuth, + Extra: []dns.RR{ + test.A("svc-target.example.com. 1800 IN A 192.0.2.10"), + test.AAAA("svc-target.example.com. 1800 IN AAAA 2001:db8::10"), + }, + }, + { + // Basic HTTPS query with additional section glue for in-bailiwick target + Qname: "www.example.com.", Qtype: dns.TypeHTTPS, + Answer: []dns.RR{ + test.HTTPS("www.example.com. 1800 IN HTTPS 1 svc-target.example.com. alpn=\"h2,h3\""), + }, + Ns: svcbAuth, + Extra: []dns.RR{ + test.A("svc-target.example.com. 1800 IN A 192.0.2.10"), + test.AAAA("svc-target.example.com. 1800 IN AAAA 2001:db8::10"), + }, + }, + { + // SVCB AliasMode (Priority=0) — glue still added for in-bailiwick target + Qname: "_alias._foo.example.com.", Qtype: dns.TypeSVCB, + Answer: []dns.RR{ + test.SVCB("_alias._foo.example.com. 1800 IN SVCB 0 svc-target.example.com."), + }, + Ns: svcbAuth, + Extra: []dns.RR{ + test.A("svc-target.example.com. 1800 IN A 192.0.2.10"), + test.AAAA("svc-target.example.com. 1800 IN AAAA 2001:db8::10"), + }, + }, + { + // Wildcard SVCB expansion (no additional section — wildcards don't run additionalProcessing) + Qname: "_http._tcp.example.com.", Qtype: dns.TypeSVCB, + Answer: []dns.RR{ + test.SVCB("_http._tcp.example.com. 1800 IN SVCB 1 svc-target.example.com. port=\"443\""), + }, + Ns: svcbAuth, + }, + { + // NoData: existing name, no SVCB record + Qname: "svc-target.example.com.", Qtype: dns.TypeSVCB, + Ns: []dns.RR{ + test.SOA("example.com. 1800 IN SOA ns.example.com. admin.example.com. 2024010100 14400 3600 604800 14400"), + }, + }, + { + // NXDOMAIN + Qname: "nonexistent.example.com.", Qtype: dns.TypeSVCB, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA("example.com. 1800 IN SOA ns.example.com. admin.example.com. 2024010100 14400 3600 604800 14400"), + }, + }, +} + +func TestLookupSVCB(t *testing.T) { + zone, err := Parse(strings.NewReader(dbSVCBExample), testSVCBOrigin, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testSVCBOrigin: zone}, Names: []string{testSVCBOrigin}}} + ctx := context.TODO() + + for _, tc := range svcbTestCases { + m := tc.Msg() + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error for %q/%d, got %v", tc.Qname, tc.Qtype, err) + continue + } + + resp := rec.Msg + if err := test.SortAndCheck(resp, tc); err != nil { + t.Errorf("Test %q/%d: %v", tc.Qname, tc.Qtype, err) + } + } +} + +func TestSVCBTargetNormalization(t *testing.T) { + // Zone with mixed-case SVCB target — should be lowercased on insert + const dbMixedCase = ` +$TTL 30M +$ORIGIN example.com. +@ IN SOA ns.example.com. admin.example.com. ( 2024010100 14400 3600 604800 14400 ) + IN NS ns.example.com. +ns IN A 192.0.2.1 +svc IN A 192.0.2.10 +_foo IN SVCB 1 SVC.Example.COM. alpn=h2 +` + zone, err := Parse(strings.NewReader(dbMixedCase), testSVCBOrigin, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testSVCBOrigin: zone}, Names: []string{testSVCBOrigin}}} + ctx := context.TODO() + + m := new(dns.Msg) + m.SetQuestion("_foo.example.com.", dns.TypeSVCB) + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err = fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + resp := rec.Msg + if len(resp.Answer) != 1 { + t.Fatalf("Expected 1 answer, got %d", len(resp.Answer)) + } + + svcb, ok := resp.Answer[0].(*dns.SVCB) + if !ok { + t.Fatalf("Expected SVCB record, got %T", resp.Answer[0]) + } + if svcb.Target != "svc.example.com." { + t.Errorf("Expected lowercased target %q, got %q", "svc.example.com.", svcb.Target) + } + + // Verify additional section contains glue (target was normalized so lookup works) + if len(resp.Extra) != 1 { + t.Fatalf("Expected 1 extra record (A glue), got %d", len(resp.Extra)) + } +} + +func TestSVCBPrivateKeys(t *testing.T) { + // Test DNS-AID private-use SvcParamKeys (65400-65408) round-trip + zone, err := Parse(strings.NewReader(dbSVCBExample), testSVCBOrigin, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testSVCBOrigin: zone}, Names: []string{testSVCBOrigin}}} + ctx := context.TODO() + + m := new(dns.Msg) + m.SetQuestion("_dnsaid.example.com.", dns.TypeHTTPS) + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err = fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + resp := rec.Msg + if len(resp.Answer) != 1 { + t.Fatalf("Expected 1 answer, got %d", len(resp.Answer)) + } + + https, ok := resp.Answer[0].(*dns.HTTPS) + if !ok { + t.Fatalf("Expected HTTPS record, got %T", resp.Answer[0]) + } + + // Verify private-use keys survived the round-trip + rr := https.String() + for _, key := range []string{"key65400=", "key65401=", "key65406="} { + if !strings.Contains(rr, key) { + t.Errorf("Expected response to contain %s, got: %s", key, rr) + } + } +} + +const testSVCBOrigin = "example.com." + +const dbSVCBExample = ` +$TTL 30M +$ORIGIN example.com. +@ IN SOA ns.example.com. admin.example.com. ( + 2024010100 ; serial + 14400 ; refresh (4 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 14400 ; minimum (4 hours) + ) + IN NS ns.example.com. + +; A/AAAA records for glue +ns IN A 192.0.2.1 +svc-target IN A 192.0.2.10 + IN AAAA 2001:db8::10 + +; SVCB ServiceMode with in-bailiwick target (tests additional section processing) +_8443._foo IN SVCB 1 svc-target.example.com. alpn=h2,h3 port=8443 + +; HTTPS ServiceMode with in-bailiwick target +www IN HTTPS 1 svc-target.example.com. alpn=h2,h3 + +; SVCB AliasMode (Priority=0) +_alias._foo IN SVCB 0 svc-target.example.com. + +; Wildcard SVCB +*._tcp IN SVCB 1 svc-target.example.com. port=443 + +; DNS-AID private-use keys (65400-65408) +_dnsaid IN HTTPS 1 svc-target.example.com. alpn=h2 key65400="https://aid.example.com/cap" key65401="sha256:e3b0c44298fc" key65406="apphub-psc" +` diff --git a/plugin/file/zone.go b/plugin/file/zone.go index 53a53ee41..29b903f1d 100644 --- a/plugin/file/zone.go +++ b/plugin/file/zone.go @@ -118,6 +118,10 @@ func (z *Zone) Insert(r dns.RR) error { r.(*dns.MX).Mx = strings.ToLower(r.(*dns.MX).Mx) case dns.TypeSRV: // r.(*dns.SRV).Target = strings.ToLower(r.(*dns.SRV).Target) + case dns.TypeSVCB: + r.(*dns.SVCB).Target = strings.ToLower(r.(*dns.SVCB).Target) + case dns.TypeHTTPS: + r.(*dns.HTTPS).Target = strings.ToLower(r.(*dns.HTTPS).Target) } z.Tree.Insert(r) diff --git a/plugin/metrics/vars/monitor.go b/plugin/metrics/vars/monitor.go index 191324e59..ef8fc2605 100644 --- a/plugin/metrics/vars/monitor.go +++ b/plugin/metrics/vars/monitor.go @@ -20,6 +20,7 @@ var monitorType = map[uint16]struct{}{ dns.TypeSRV: {}, dns.TypeTXT: {}, dns.TypeHTTPS: {}, + dns.TypeSVCB: {}, // Meta Qtypes dns.TypeIXFR: {}, dns.TypeAXFR: {}, diff --git a/plugin/test/helpers.go b/plugin/test/helpers.go index 420f3b876..ae82d47fb 100644 --- a/plugin/test/helpers.go +++ b/plugin/test/helpers.go @@ -111,6 +111,12 @@ func DS(rr string) *dns.DS { r, _ := dns.NewRR(rr); return r.(*dns.DS) } // NAPTR returns a NAPTR record from rr. It panics on errors. func NAPTR(rr string) *dns.NAPTR { r, _ := dns.NewRR(rr); return r.(*dns.NAPTR) } +// SVCB returns a SVCB record from rr. It panics on errors. +func SVCB(rr string) *dns.SVCB { r, _ := dns.NewRR(rr); return r.(*dns.SVCB) } + +// HTTPS returns an HTTPS record from rr. It panics on errors. +func HTTPS(rr string) *dns.HTTPS { r, _ := dns.NewRR(rr); return r.(*dns.HTTPS) } + // OPT returns an OPT record with UDP buffer size set to bufsize and the DO bit set to do. func OPT(bufsize int, do bool) *dns.OPT { o := new(dns.OPT) @@ -256,6 +262,28 @@ func Section(tc Case, sec sect, rr []dns.RR) error { if x.Do() != tt.Do() { return fmt.Errorf("OPT DO should be %t, but is %t", tt.Do(), x.Do()) } + case *dns.SVCB: + tt := section[i].(*dns.SVCB) + if x.Priority != tt.Priority { + return fmt.Errorf("RR %d should have a Priority of %d, but has %d", i, tt.Priority, x.Priority) + } + if x.Target != tt.Target { + return fmt.Errorf("RR %d should have a Target of %q, but has %q", i, tt.Target, x.Target) + } + if x.String() != tt.String() { + return fmt.Errorf("RR %d should have value %q, but has %q", i, tt.String(), x.String()) + } + case *dns.HTTPS: + tt := section[i].(*dns.HTTPS) + if x.Priority != tt.Priority { + return fmt.Errorf("RR %d should have a Priority of %d, but has %d", i, tt.Priority, x.Priority) + } + if x.Target != tt.Target { + return fmt.Errorf("RR %d should have a Target of %q, but has %q", i, tt.Target, x.Target) + } + if x.String() != tt.String() { + return fmt.Errorf("RR %d should have value %q, but has %q", i, tt.String(), x.String()) + } } } return nil