Skip to content

Commit 5bc0813

Browse files
authored
Prepare 1.3.3 recursion hotfix release (#156)
* Fix parent-scoped glue handling in recursive resolution * Fix recursive DS routing and authoritative failover budgets * Prepare 1.3.3 recursion hotfix release
1 parent b085637 commit 5bc0813

7 files changed

Lines changed: 223 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ Create a passphrase-sealed application backup before upgrading and keep
88
mixed-version cluster windows short. Cross-version restore and downgrade
99
compatibility are not yet a published contract.
1010

11+
## [1.3.3] - 2026-09-13
12+
13+
Sable 1.3.3 is a hotfix for direct recursive DNS resolution. It fixes `.com`
14+
delegation failures, DNSSEC validation after cached lookups, and failover when
15+
an authoritative nameserver stops responding.
16+
17+
- Accept valid root-supplied addresses for `.com` nameservers under
18+
`gtld-servers.net`, fixing `delegation for com. has no resolvable name servers`.
19+
Referral addresses remain restricted to the named servers within the
20+
referring parent's scope.
21+
- Query the parent authority for DNSSEC DS records even when a previous lookup
22+
cached the child delegation, allowing validation to follow the correct
23+
chain of trust.
24+
- Reserve time for alternative authoritative nameservers so retries against a
25+
silent server cannot consume the entire resolution timeout before failover.
26+
27+
Existing recursive resolver configurations do not need to change. Conditional
28+
forwarding routes and Forwarder zones continue to use their configured upstreams.
29+
1130
## [1.3.2] - 2026-09-13
1231

1332
Sable 1.3.2 lets you migrate Technitium forwarder zones with their local overrides,

docs/guides/technitium-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Build Sable alongside your current DNS service, move one test zone, and verify it before moving clients. This guide covers migration to Sable: zone-file import, a one-time AXFR snapshot, synchronized Secondary staging, or bulk import from a catalog. The older-version cutover procedure is retained below.
44

55
> [!NOTE]
6-
> **Version availability:** This guide covers Sable 1.3.2. Authoritative conversion and Import from Catalog arrived in 1.2.0; forwarder import, synchronization, and conversion require 1.3.2. Users on 1.1.0 and earlier can upgrade or follow the older export/remove/import procedure below.
6+
> **Version availability:** This guide covers Sable 1.3.3. Authoritative conversion and Import from Catalog arrived in 1.2.0; forwarder import, synchronization, and conversion require 1.3.2. Users on 1.1.0 and earlier can upgrade or follow the older export/remove/import procedure below.
77
88
## Choose a migration path
99

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ Start with one Sable server and one test device. Get your first DNS answer, then
1818
- **Configuration and operations references** describe exact behavior when you need to go deeper.
1919

2020
> [!NOTE]
21-
These guides cover Sable 1.3.2. [Download the release](https://github.com/drudge/sable/releases/tag/v1.3.2) or read the [release notes](../CHANGELOG.md).
21+
These guides cover Sable 1.3.3. [Download the release](https://github.com/drudge/sable/releases/tag/v1.3.3) or read the [release notes](../CHANGELOG.md).
2222

2323
## Migrating from Technitium
2424

2525

2626
- [Migrate from Technitium](guides/technitium-migration.md) into a new instance or cluster using zone files or DNS transfers.
2727

28-
Existing console screenshots were captured on Sable 1.0.0; the written procedures describe 1.3.2.
28+
Existing console screenshots were captured on Sable 1.0.0; the written procedures describe 1.3.3.
2929

3030
## Make DNS work for your network
3131

docs/navigation.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"version": "1.3.2",
3-
"versionLabel": "1.3.2",
2+
"version": "1.3.3",
3+
"versionLabel": "1.3.3",
44
"navigationLabels": {
55
"getting-started": "First server",
66
"guides/technitium-migration": "From Technitium",

internal/dnsserver/iterative.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,13 @@ func (handler *Handler) resolveIterativeQuestion(
199199
}
200200
servers := append([]string(nil), runtime.rootHints...)
201201
closestZone := ""
202-
if zone, cachedServers, found := runtime.delegations.get(question.Name, time.Now()); found {
202+
// DS records belong to the parent side of a delegation, even when the
203+
// child authority was cached by an earlier lookup.
204+
cacheName := question.Name
205+
if question.Qtype == dns.TypeDS {
206+
cacheName = parentFQDN(question.Name)
207+
}
208+
if zone, cachedServers, found := runtime.delegations.get(cacheName, time.Now()); found {
203209
closestZone, servers = zone, cachedServers
204210
}
205211
visited := make(map[string]struct{})
@@ -222,10 +228,11 @@ func (handler *Handler) resolveIterativeQuestion(
222228
return nil, fmt.Errorf("iterative resolution encountered a referral loop at %s", dns.Fqdn(zone))
223229
}
224230
visited[zone] = struct{}{}
225-
servers, err = handler.referralServers(ctx, zone, names, response.Extra, runtime, budget, depth+1)
231+
servers, err = handler.referralServers(ctx, closestZone, zone, names, response.Extra, runtime, budget, depth+1)
226232
if err != nil {
227233
return nil, err
228234
}
235+
closestZone = zone
229236
runtime.delegations.set(zone, servers, referralTTL(response), time.Now())
230237
}
231238

@@ -259,10 +266,11 @@ func (handler *Handler) resolveIterativeQuestion(
259266
return nil, fmt.Errorf("iterative resolution encountered a referral loop at %s", dns.Fqdn(zone))
260267
}
261268
visited[zone] = struct{}{}
262-
servers, err = handler.referralServers(ctx, zone, names, response.Extra, runtime, budget, depth+1)
269+
servers, err = handler.referralServers(ctx, closestZone, zone, names, response.Extra, runtime, budget, depth+1)
263270
if err != nil {
264271
return nil, err
265272
}
273+
closestZone = zone
266274
runtime.delegations.set(zone, servers, referralTTL(response), time.Now())
267275
}
268276
return nil, errors.New("iterative resolution exceeded the maximum alias depth")
@@ -307,7 +315,9 @@ func (handler *Handler) exchangeIterative(
307315
}
308316
budget.remaining--
309317
server := servers[(start+uint64(offset))%uint64(len(servers))]
310-
response, err := handler.exchangeWithRetries(ctx, request, "udp://"+server, runtime.retryTimeout, runtime.retries)
318+
attemptContext, release := forwarderBudget(ctx, len(servers)-offset)
319+
response, err := handler.exchangeWithRetries(attemptContext, request, "udp://"+server, runtime.retryTimeout, runtime.retries)
320+
release()
311321
if err != nil {
312322
failures = append(failures, fmt.Errorf("%s: %w", server, err))
313323
continue
@@ -365,6 +375,7 @@ func referralTTL(response *dns.Msg) uint32 {
365375

366376
func (handler *Handler) referralServers(
367377
ctx context.Context,
378+
parentZone string,
368379
zone string,
369380
nameServers []string,
370381
additional []dns.RR,
@@ -380,7 +391,9 @@ func (handler *Handler) referralServers(
380391
resolved := make(map[string]bool, len(nameServers))
381392
for _, record := range additional {
382393
owner := normalizeName(record.Header().Name)
383-
if _, matches := wanted[owner]; !matches || (owner != zone && !strings.HasSuffix(owner, "."+zone)) {
394+
// Glue is scoped to the referring parent, which may supply sibling
395+
// addresses (for example, the root supplies .com servers under .net).
396+
if _, matches := wanted[owner]; !matches || !dns.IsSubDomain(dns.Fqdn(parentZone), dns.Fqdn(owner)) {
384397
continue
385398
}
386399
if address, ok := addressFromRecord(record); ok {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package dnsserver
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
"github.com/miekg/dns"
10+
)
11+
12+
func TestIterativeDSUsesParentAuthority(t *testing.T) {
13+
t.Parallel()
14+
for _, cached := range []bool{false, true} {
15+
for _, name := range []string{"com.", "example.com."} {
16+
t.Run(fmt.Sprintf("%s/cached=%t", name, cached), func(t *testing.T) {
17+
runtime := recursiveTestRuntime(t)
18+
if cached {
19+
runtime.delegations.set("com", []string{"192.0.2.2:53"}, 300, time.Now())
20+
runtime.delegations.set("example.com", []string{"192.0.2.3:53"}, 300, time.Now())
21+
}
22+
handler := NewHandler(runtime)
23+
parent := "udp://192.0.2.1:53"
24+
if name == "example.com." {
25+
parent = "udp://192.0.2.2:53"
26+
}
27+
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
28+
question := request.Question[0]
29+
if question.Qtype == dns.TypeNS && question.Name == "com." && endpoint == "udp://192.0.2.1:53" {
30+
return referralResponse(request, "com.", "a.gtld-servers.net.", "192.0.2.2"), nil
31+
}
32+
if question.Qtype != dns.TypeDS || question.Name != name || endpoint != parent {
33+
return nil, fmt.Errorf("unexpected %s/%s at %s; DS must use %s", question.Name, dns.TypeToString[question.Qtype], endpoint, parent)
34+
}
35+
response := new(dns.Msg)
36+
response.SetReply(request)
37+
response.Authoritative = true
38+
record, err := dns.NewRR(name + " 300 IN DS 12345 13 2 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF")
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
response.Answer = []dns.RR{record}
43+
return response, nil
44+
}
45+
request := new(dns.Msg)
46+
request.SetQuestion(name, dns.TypeDS)
47+
response, err := handler.resolveNetwork(request, runtime, nil)
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
if len(response.Answer) != 1 || response.Answer[0].Header().Rrtype != dns.TypeDS {
52+
t.Fatalf("DS answer = %v", response)
53+
}
54+
})
55+
}
56+
}
57+
}
58+
59+
func TestIterativeFailoverPreservesQueryBudget(t *testing.T) {
60+
t.Parallel()
61+
runtime := recursiveTestRuntime(t)
62+
runtime.timeout = 2 * time.Second
63+
runtime.retryTimeout = 1500 * time.Millisecond
64+
runtime.retries = 2
65+
handler := NewHandler(runtime)
66+
healthyTried := false
67+
handler.upstreamExchange = func(ctx context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
68+
if endpoint == "udp://192.0.2.1:53" {
69+
<-ctx.Done()
70+
return nil, ctx.Err()
71+
}
72+
healthyTried = true
73+
return addressResponse(request, "192.0.2.44"), nil
74+
}
75+
ctx, cancel := context.WithTimeout(context.Background(), runtime.timeout)
76+
defer cancel()
77+
response, err := handler.exchangeIterative(ctx, iterativeQuery("www.example.com.", dns.TypeA), []string{"192.0.2.1:53", "192.0.2.2:53"}, runtime, &iterativeBudget{remaining: maximumIterativeQueries})
78+
if err != nil || !healthyTried {
79+
t.Fatalf("healthy server tried=%t; resolution error=%v", healthyTried, err)
80+
}
81+
if len(response.Answer) != 1 {
82+
t.Fatalf("answer = %v", response)
83+
}
84+
}
85+
86+
func TestIterativeDNSSECValidatesWithCachedChildDelegations(t *testing.T) {
87+
t.Parallel()
88+
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
89+
root := newValidatorTestKey(t, ".")
90+
parent := newValidatorTestKey(t, "demo.")
91+
child := newValidatorTestKey(t, "secure.demo.")
92+
runtime := recursiveTestRuntime(t)
93+
runtime.dnssec = validatorWithAnchor(t, root, now)
94+
runtime.delegations.set("demo", []string{"192.0.2.2:53"}, 300, time.Now())
95+
runtime.delegations.set("secure.demo", []string{"192.0.2.3:53"}, 300, time.Now())
96+
responses := validatorChainQueries(t, now, root, parent, child)
97+
responses[validatorQueryKey("www.secure.demo.", dns.TypeA)] = validatorSignedResponse(t, now, child, "www.secure.demo.", dns.TypeA, "192.0.2.44")
98+
handler := NewHandler(runtime)
99+
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
100+
question := request.Question[0]
101+
authority := question.Name
102+
if question.Qtype == dns.TypeDS {
103+
authority = parentFQDN(authority)
104+
}
105+
expected := "udp://192.0.2.1:53"
106+
if dns.IsSubDomain("secure.demo.", authority) {
107+
expected = "udp://192.0.2.3:53"
108+
} else if dns.IsSubDomain("demo.", authority) {
109+
expected = "udp://192.0.2.2:53"
110+
}
111+
if endpoint != expected {
112+
return nil, fmt.Errorf("%s/%s sent to %s, want %s", question.Name, dns.TypeToString[question.Qtype], endpoint, expected)
113+
}
114+
response := responses[validatorQueryKey(question.Name, question.Qtype)]
115+
if response == nil {
116+
return nil, fmt.Errorf("unexpected query %v", question)
117+
}
118+
return response.Copy(), nil
119+
}
120+
request := new(dns.Msg)
121+
request.SetQuestion("www.secure.demo.", dns.TypeA)
122+
response, state, err := handler.resolveUpstream(request, runtime, nil)
123+
if err != nil || state != validationSecure {
124+
t.Fatalf("resolution state=%v error=%v", state, err)
125+
}
126+
if len(response.Answer) == 0 {
127+
t.Fatal("missing answer")
128+
}
129+
}

internal/dnsserver/iterative_test.go

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,55 +13,59 @@ import (
1313

1414
func TestIterativeResolverMinimizesQNameAndFollowsReferrals(t *testing.T) {
1515
t.Parallel()
16-
runtime := recursiveTestRuntime(t)
17-
handler := NewHandler(runtime)
18-
var questions []string
19-
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
20-
question := request.Question[0]
21-
questions = append(questions, fmt.Sprintf("%s/%s@%s", question.Name, dns.TypeToString[question.Qtype], endpoint))
22-
switch {
23-
case endpoint == "udp://192.0.2.1:53" && question.Name == "com." && question.Qtype == dns.TypeNS:
24-
return referralResponse(request, "com.", "ns.com.", "192.0.2.2"), nil
25-
case endpoint == "udp://192.0.2.2:53" && question.Name == "example.com." && question.Qtype == dns.TypeNS:
26-
return referralResponse(request, "example.com.", "ns.example.com.", "192.0.2.3"), nil
27-
case endpoint == "udp://192.0.2.3:53" && question.Name == "www.example.com." && question.Qtype == dns.TypeA:
28-
response := new(dns.Msg)
29-
response.SetReply(request)
30-
response.Authoritative = true
31-
response.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, A: []byte{192, 0, 2, 44}}}
32-
return response, nil
33-
case endpoint == "udp://192.0.2.3:53" && question.Name == "mail.example.com." && question.Qtype == dns.TypeA:
34-
return addressResponse(request, "192.0.2.45"), nil
35-
default:
36-
return nil, fmt.Errorf("unexpected iterative query %s/%s to %s", question.Name, dns.TypeToString[question.Qtype], endpoint)
37-
}
38-
}
16+
for _, rootNameServer := range []string{"ns.com.", "a.gtld-servers.net."} {
17+
t.Run(rootNameServer, func(t *testing.T) {
18+
runtime := recursiveTestRuntime(t)
19+
handler := NewHandler(runtime)
20+
var questions []string
21+
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
22+
question := request.Question[0]
23+
questions = append(questions, fmt.Sprintf("%s/%s@%s", question.Name, dns.TypeToString[question.Qtype], endpoint))
24+
switch {
25+
case endpoint == "udp://192.0.2.1:53" && question.Name == "com." && question.Qtype == dns.TypeNS:
26+
return referralResponse(request, "com.", rootNameServer, "192.0.2.2"), nil
27+
case endpoint == "udp://192.0.2.2:53" && question.Name == "example.com." && question.Qtype == dns.TypeNS:
28+
return referralResponse(request, "example.com.", "ns.example.com.", "192.0.2.3"), nil
29+
case endpoint == "udp://192.0.2.3:53" && question.Name == "www.example.com." && question.Qtype == dns.TypeA:
30+
response := new(dns.Msg)
31+
response.SetReply(request)
32+
response.Authoritative = true
33+
response.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, A: []byte{192, 0, 2, 44}}}
34+
return response, nil
35+
case endpoint == "udp://192.0.2.3:53" && question.Name == "mail.example.com." && question.Qtype == dns.TypeA:
36+
return addressResponse(request, "192.0.2.45"), nil
37+
default:
38+
return nil, fmt.Errorf("unexpected iterative query %s/%s to %s", question.Name, dns.TypeToString[question.Qtype], endpoint)
39+
}
40+
}
3941

40-
request := new(dns.Msg)
41-
request.SetQuestion("www.example.com.", dns.TypeA)
42-
request.RecursionDesired = true
43-
response, err := handler.resolveNetwork(request, runtime, nil)
44-
if err != nil {
45-
t.Fatal(err)
46-
}
47-
if len(response.Answer) != 1 || response.Answer[0].String() != "www.example.com.\t300\tIN\tA\t192.0.2.44" || !response.RecursionAvailable {
48-
t.Fatalf("iterative response = %+v", response)
49-
}
50-
want := []string{
51-
"com./NS@udp://192.0.2.1:53",
52-
"example.com./NS@udp://192.0.2.2:53",
53-
"www.example.com./A@udp://192.0.2.3:53",
54-
}
55-
if !slices.Equal(questions, want) {
56-
t.Fatalf("iterative questions = %v, want %v", questions, want)
57-
}
58-
second := new(dns.Msg)
59-
second.SetQuestion("mail.example.com.", dns.TypeA)
60-
if _, err := handler.resolveNetwork(second, runtime, nil); err != nil {
61-
t.Fatal(err)
62-
}
63-
if got := questions[len(questions)-1]; got != "mail.example.com./A@udp://192.0.2.3:53" || len(questions) != len(want)+1 {
64-
t.Fatalf("cached delegation did not bypass parent zones: %v", questions)
42+
request := new(dns.Msg)
43+
request.SetQuestion("www.example.com.", dns.TypeA)
44+
request.RecursionDesired = true
45+
response, err := handler.resolveNetwork(request, runtime, nil)
46+
if err != nil {
47+
t.Fatal(err)
48+
}
49+
if len(response.Answer) != 1 || response.Answer[0].String() != "www.example.com.\t300\tIN\tA\t192.0.2.44" || !response.RecursionAvailable {
50+
t.Fatalf("iterative response = %+v", response)
51+
}
52+
want := []string{
53+
"com./NS@udp://192.0.2.1:53",
54+
"example.com./NS@udp://192.0.2.2:53",
55+
"www.example.com./A@udp://192.0.2.3:53",
56+
}
57+
if !slices.Equal(questions, want) {
58+
t.Fatalf("iterative questions = %v, want %v", questions, want)
59+
}
60+
second := new(dns.Msg)
61+
second.SetQuestion("mail.example.com.", dns.TypeA)
62+
if _, err := handler.resolveNetwork(second, runtime, nil); err != nil {
63+
t.Fatal(err)
64+
}
65+
if got := questions[len(questions)-1]; got != "mail.example.com./A@udp://192.0.2.3:53" || len(questions) != len(want)+1 {
66+
t.Fatalf("cached delegation did not bypass parent zones: %v", questions)
67+
}
68+
})
6569
}
6670
}
6771

0 commit comments

Comments
 (0)