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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ Create a passphrase-sealed application backup before upgrading and keep
mixed-version cluster windows short. Cross-version restore and downgrade
compatibility are not yet a published contract.

## [1.3.3] - 2026-09-13

Sable 1.3.3 is a hotfix for direct recursive DNS resolution. It fixes `.com`
delegation failures, DNSSEC validation after cached lookups, and failover when
an authoritative nameserver stops responding.

- Accept valid root-supplied addresses for `.com` nameservers under
`gtld-servers.net`, fixing `delegation for com. has no resolvable name servers`.
Referral addresses remain restricted to the named servers within the
referring parent's scope.
- Query the parent authority for DNSSEC DS records even when a previous lookup
cached the child delegation, allowing validation to follow the correct
chain of trust.
- Reserve time for alternative authoritative nameservers so retries against a
silent server cannot consume the entire resolution timeout before failover.

Existing recursive resolver configurations do not need to change. Conditional
forwarding routes and Forwarder zones continue to use their configured upstreams.

## [1.3.2] - 2026-09-13

Sable 1.3.2 lets you migrate Technitium forwarder zones with their local overrides,
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/technitium-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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.

> [!NOTE]
> **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.
> **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.

## Choose a migration path

Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ Start with one Sable server and one test device. Get your first DNS answer, then
- **Configuration and operations references** describe exact behavior when you need to go deeper.

> [!NOTE]
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).
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).

## Migrating from Technitium


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

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

## Make DNS work for your network

Expand Down
4 changes: 2 additions & 2 deletions docs/navigation.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.3.2",
"versionLabel": "1.3.2",
"version": "1.3.3",
"versionLabel": "1.3.3",
"navigationLabels": {
"getting-started": "First server",
"guides/technitium-migration": "From Technitium",
Expand Down
23 changes: 18 additions & 5 deletions internal/dnsserver/iterative.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,13 @@ func (handler *Handler) resolveIterativeQuestion(
}
servers := append([]string(nil), runtime.rootHints...)
closestZone := ""
if zone, cachedServers, found := runtime.delegations.get(question.Name, time.Now()); found {
// DS records belong to the parent side of a delegation, even when the
// child authority was cached by an earlier lookup.
cacheName := question.Name
if question.Qtype == dns.TypeDS {
cacheName = parentFQDN(question.Name)
}
if zone, cachedServers, found := runtime.delegations.get(cacheName, time.Now()); found {
closestZone, servers = zone, cachedServers
}
visited := make(map[string]struct{})
Expand All @@ -222,10 +228,11 @@ func (handler *Handler) resolveIterativeQuestion(
return nil, fmt.Errorf("iterative resolution encountered a referral loop at %s", dns.Fqdn(zone))
}
visited[zone] = struct{}{}
servers, err = handler.referralServers(ctx, zone, names, response.Extra, runtime, budget, depth+1)
servers, err = handler.referralServers(ctx, closestZone, zone, names, response.Extra, runtime, budget, depth+1)
if err != nil {
return nil, err
}
closestZone = zone
runtime.delegations.set(zone, servers, referralTTL(response), time.Now())
}

Expand Down Expand Up @@ -259,10 +266,11 @@ func (handler *Handler) resolveIterativeQuestion(
return nil, fmt.Errorf("iterative resolution encountered a referral loop at %s", dns.Fqdn(zone))
}
visited[zone] = struct{}{}
servers, err = handler.referralServers(ctx, zone, names, response.Extra, runtime, budget, depth+1)
servers, err = handler.referralServers(ctx, closestZone, zone, names, response.Extra, runtime, budget, depth+1)
if err != nil {
return nil, err
}
closestZone = zone
runtime.delegations.set(zone, servers, referralTTL(response), time.Now())
}
return nil, errors.New("iterative resolution exceeded the maximum alias depth")
Expand Down Expand Up @@ -307,7 +315,9 @@ func (handler *Handler) exchangeIterative(
}
budget.remaining--
server := servers[(start+uint64(offset))%uint64(len(servers))]
response, err := handler.exchangeWithRetries(ctx, request, "udp://"+server, runtime.retryTimeout, runtime.retries)
attemptContext, release := forwarderBudget(ctx, len(servers)-offset)
response, err := handler.exchangeWithRetries(attemptContext, request, "udp://"+server, runtime.retryTimeout, runtime.retries)
release()
if err != nil {
failures = append(failures, fmt.Errorf("%s: %w", server, err))
continue
Expand Down Expand Up @@ -365,6 +375,7 @@ func referralTTL(response *dns.Msg) uint32 {

func (handler *Handler) referralServers(
ctx context.Context,
parentZone string,
zone string,
nameServers []string,
additional []dns.RR,
Expand All @@ -380,7 +391,9 @@ func (handler *Handler) referralServers(
resolved := make(map[string]bool, len(nameServers))
for _, record := range additional {
owner := normalizeName(record.Header().Name)
if _, matches := wanted[owner]; !matches || (owner != zone && !strings.HasSuffix(owner, "."+zone)) {
// Glue is scoped to the referring parent, which may supply sibling
// addresses (for example, the root supplies .com servers under .net).
if _, matches := wanted[owner]; !matches || !dns.IsSubDomain(dns.Fqdn(parentZone), dns.Fqdn(owner)) {
continue
}
if address, ok := addressFromRecord(record); ok {
Expand Down
129 changes: 129 additions & 0 deletions internal/dnsserver/iterative_regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package dnsserver

import (
"context"
"fmt"
"testing"
"time"

"github.com/miekg/dns"
)

func TestIterativeDSUsesParentAuthority(t *testing.T) {
t.Parallel()
for _, cached := range []bool{false, true} {
for _, name := range []string{"com.", "example.com."} {
t.Run(fmt.Sprintf("%s/cached=%t", name, cached), func(t *testing.T) {
runtime := recursiveTestRuntime(t)
if cached {
runtime.delegations.set("com", []string{"192.0.2.2:53"}, 300, time.Now())
runtime.delegations.set("example.com", []string{"192.0.2.3:53"}, 300, time.Now())
}
handler := NewHandler(runtime)
parent := "udp://192.0.2.1:53"
if name == "example.com." {
parent = "udp://192.0.2.2:53"
}
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
question := request.Question[0]
if question.Qtype == dns.TypeNS && question.Name == "com." && endpoint == "udp://192.0.2.1:53" {
return referralResponse(request, "com.", "a.gtld-servers.net.", "192.0.2.2"), nil
}
if question.Qtype != dns.TypeDS || question.Name != name || endpoint != parent {
return nil, fmt.Errorf("unexpected %s/%s at %s; DS must use %s", question.Name, dns.TypeToString[question.Qtype], endpoint, parent)
}
response := new(dns.Msg)
response.SetReply(request)
response.Authoritative = true
record, err := dns.NewRR(name + " 300 IN DS 12345 13 2 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF")
if err != nil {
t.Fatal(err)
}
response.Answer = []dns.RR{record}
return response, nil
}
request := new(dns.Msg)
request.SetQuestion(name, dns.TypeDS)
response, err := handler.resolveNetwork(request, runtime, nil)
if err != nil {
t.Fatal(err)
}
if len(response.Answer) != 1 || response.Answer[0].Header().Rrtype != dns.TypeDS {
t.Fatalf("DS answer = %v", response)
}
})
}
}
}

func TestIterativeFailoverPreservesQueryBudget(t *testing.T) {
t.Parallel()
runtime := recursiveTestRuntime(t)
runtime.timeout = 2 * time.Second
runtime.retryTimeout = 1500 * time.Millisecond
runtime.retries = 2
handler := NewHandler(runtime)
healthyTried := false
handler.upstreamExchange = func(ctx context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
if endpoint == "udp://192.0.2.1:53" {
<-ctx.Done()
return nil, ctx.Err()
}
healthyTried = true
return addressResponse(request, "192.0.2.44"), nil
}
ctx, cancel := context.WithTimeout(context.Background(), runtime.timeout)
defer cancel()
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})
if err != nil || !healthyTried {
t.Fatalf("healthy server tried=%t; resolution error=%v", healthyTried, err)
}
if len(response.Answer) != 1 {
t.Fatalf("answer = %v", response)
}
}

func TestIterativeDNSSECValidatesWithCachedChildDelegations(t *testing.T) {
t.Parallel()
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
root := newValidatorTestKey(t, ".")
parent := newValidatorTestKey(t, "demo.")
child := newValidatorTestKey(t, "secure.demo.")
runtime := recursiveTestRuntime(t)
runtime.dnssec = validatorWithAnchor(t, root, now)
runtime.delegations.set("demo", []string{"192.0.2.2:53"}, 300, time.Now())
runtime.delegations.set("secure.demo", []string{"192.0.2.3:53"}, 300, time.Now())
responses := validatorChainQueries(t, now, root, parent, child)
responses[validatorQueryKey("www.secure.demo.", dns.TypeA)] = validatorSignedResponse(t, now, child, "www.secure.demo.", dns.TypeA, "192.0.2.44")
handler := NewHandler(runtime)
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
question := request.Question[0]
authority := question.Name
if question.Qtype == dns.TypeDS {
authority = parentFQDN(authority)
}
expected := "udp://192.0.2.1:53"
if dns.IsSubDomain("secure.demo.", authority) {
expected = "udp://192.0.2.3:53"
} else if dns.IsSubDomain("demo.", authority) {
expected = "udp://192.0.2.2:53"
}
if endpoint != expected {
return nil, fmt.Errorf("%s/%s sent to %s, want %s", question.Name, dns.TypeToString[question.Qtype], endpoint, expected)
}
response := responses[validatorQueryKey(question.Name, question.Qtype)]
if response == nil {
return nil, fmt.Errorf("unexpected query %v", question)
}
return response.Copy(), nil
}
request := new(dns.Msg)
request.SetQuestion("www.secure.demo.", dns.TypeA)
response, state, err := handler.resolveUpstream(request, runtime, nil)
if err != nil || state != validationSecure {
t.Fatalf("resolution state=%v error=%v", state, err)
}
if len(response.Answer) == 0 {
t.Fatal("missing answer")
}
}
100 changes: 52 additions & 48 deletions internal/dnsserver/iterative_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,55 +13,59 @@ import (

func TestIterativeResolverMinimizesQNameAndFollowsReferrals(t *testing.T) {
t.Parallel()
runtime := recursiveTestRuntime(t)
handler := NewHandler(runtime)
var questions []string
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
question := request.Question[0]
questions = append(questions, fmt.Sprintf("%s/%s@%s", question.Name, dns.TypeToString[question.Qtype], endpoint))
switch {
case endpoint == "udp://192.0.2.1:53" && question.Name == "com." && question.Qtype == dns.TypeNS:
return referralResponse(request, "com.", "ns.com.", "192.0.2.2"), nil
case endpoint == "udp://192.0.2.2:53" && question.Name == "example.com." && question.Qtype == dns.TypeNS:
return referralResponse(request, "example.com.", "ns.example.com.", "192.0.2.3"), nil
case endpoint == "udp://192.0.2.3:53" && question.Name == "www.example.com." && question.Qtype == dns.TypeA:
response := new(dns.Msg)
response.SetReply(request)
response.Authoritative = true
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}}}
return response, nil
case endpoint == "udp://192.0.2.3:53" && question.Name == "mail.example.com." && question.Qtype == dns.TypeA:
return addressResponse(request, "192.0.2.45"), nil
default:
return nil, fmt.Errorf("unexpected iterative query %s/%s to %s", question.Name, dns.TypeToString[question.Qtype], endpoint)
}
}
for _, rootNameServer := range []string{"ns.com.", "a.gtld-servers.net."} {
t.Run(rootNameServer, func(t *testing.T) {
runtime := recursiveTestRuntime(t)
handler := NewHandler(runtime)
var questions []string
handler.upstreamExchange = func(_ context.Context, request *dns.Msg, endpoint string, _ time.Duration) (*dns.Msg, error) {
question := request.Question[0]
questions = append(questions, fmt.Sprintf("%s/%s@%s", question.Name, dns.TypeToString[question.Qtype], endpoint))
switch {
case endpoint == "udp://192.0.2.1:53" && question.Name == "com." && question.Qtype == dns.TypeNS:
return referralResponse(request, "com.", rootNameServer, "192.0.2.2"), nil
case endpoint == "udp://192.0.2.2:53" && question.Name == "example.com." && question.Qtype == dns.TypeNS:
return referralResponse(request, "example.com.", "ns.example.com.", "192.0.2.3"), nil
case endpoint == "udp://192.0.2.3:53" && question.Name == "www.example.com." && question.Qtype == dns.TypeA:
response := new(dns.Msg)
response.SetReply(request)
response.Authoritative = true
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}}}
return response, nil
case endpoint == "udp://192.0.2.3:53" && question.Name == "mail.example.com." && question.Qtype == dns.TypeA:
return addressResponse(request, "192.0.2.45"), nil
default:
return nil, fmt.Errorf("unexpected iterative query %s/%s to %s", question.Name, dns.TypeToString[question.Qtype], endpoint)
}
}

request := new(dns.Msg)
request.SetQuestion("www.example.com.", dns.TypeA)
request.RecursionDesired = true
response, err := handler.resolveNetwork(request, runtime, nil)
if err != nil {
t.Fatal(err)
}
if len(response.Answer) != 1 || response.Answer[0].String() != "www.example.com.\t300\tIN\tA\t192.0.2.44" || !response.RecursionAvailable {
t.Fatalf("iterative response = %+v", response)
}
want := []string{
"com./NS@udp://192.0.2.1:53",
"example.com./NS@udp://192.0.2.2:53",
"www.example.com./A@udp://192.0.2.3:53",
}
if !slices.Equal(questions, want) {
t.Fatalf("iterative questions = %v, want %v", questions, want)
}
second := new(dns.Msg)
second.SetQuestion("mail.example.com.", dns.TypeA)
if _, err := handler.resolveNetwork(second, runtime, nil); err != nil {
t.Fatal(err)
}
if got := questions[len(questions)-1]; got != "mail.example.com./A@udp://192.0.2.3:53" || len(questions) != len(want)+1 {
t.Fatalf("cached delegation did not bypass parent zones: %v", questions)
request := new(dns.Msg)
request.SetQuestion("www.example.com.", dns.TypeA)
request.RecursionDesired = true
response, err := handler.resolveNetwork(request, runtime, nil)
if err != nil {
t.Fatal(err)
}
if len(response.Answer) != 1 || response.Answer[0].String() != "www.example.com.\t300\tIN\tA\t192.0.2.44" || !response.RecursionAvailable {
t.Fatalf("iterative response = %+v", response)
}
want := []string{
"com./NS@udp://192.0.2.1:53",
"example.com./NS@udp://192.0.2.2:53",
"www.example.com./A@udp://192.0.2.3:53",
}
if !slices.Equal(questions, want) {
t.Fatalf("iterative questions = %v, want %v", questions, want)
}
second := new(dns.Msg)
second.SetQuestion("mail.example.com.", dns.TypeA)
if _, err := handler.resolveNetwork(second, runtime, nil); err != nil {
t.Fatal(err)
}
if got := questions[len(questions)-1]; got != "mail.example.com./A@udp://192.0.2.3:53" || len(questions) != len(want)+1 {
t.Fatalf("cached delegation did not bypass parent zones: %v", questions)
}
})
}
}

Expand Down
Loading