Skip to content

Commit 801b6a5

Browse files
committed
feat: add SOA records to NXDOMAIN responses for RFC 2308 compliance
Enables proper negative caching by DNS clients and makes blockTTL functional for NXDOMAIN mode. Closes #1874
1 parent d0681ae commit 801b6a5

File tree

8 files changed

+281
-30
lines changed

8 files changed

+281
-30
lines changed

config/blocking.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,7 @@ func (c *Blocking) LogConfig(logger *logrus.Entry) {
6363
}
6464

6565
logger.Infof("blockType = %s", c.BlockType)
66-
67-
if c.BlockType != "NXDOMAIN" {
68-
logger.Infof("blockTTL = %s", c.BlockTTL)
69-
}
66+
logger.Infof("blockTTL = %s", c.BlockTTL)
7067

7168
logger.Info("loading:")
7269
log.WithIndent(logger, " ", c.Loading.LogConfig)

docs/configuration.md

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ All logging options are optional.
7272

7373
## Init Strategy
7474

75-
A couple of features use an "init/loading strategy" which configures behavior at Blocky startup.
75+
A couple of features use an "init/loading strategy" which configures behavior at Blocky startup.
7676
This applies to all of them. The default strategy is blocking.
7777

7878
| strategy | Description |
@@ -180,13 +180,13 @@ Blocky supports different upstream strategies (default `parallel_best`) that det
180180

181181
Currently available strategies:
182182

183-
- `parallel_best`: blocky picks 2 random (weighted) resolvers from the upstream group for each query and returns the answer from the fastest one.
184-
If an upstream failed to answer within the last hour, it is less likely to be chosen for the race.
185-
This improves your network speed and increases your privacy - your DNS traffic will be distributed over multiple providers.
183+
- `parallel_best`: blocky picks 2 random (weighted) resolvers from the upstream group for each query and returns the answer from the fastest one.
184+
If an upstream failed to answer within the last hour, it is less likely to be chosen for the race.
185+
This improves your network speed and increases your privacy - your DNS traffic will be distributed over multiple providers.
186186
(When using 10 upstream servers, each upstream will get on average 20% of the DNS requests)
187-
- `random`: blocky picks one random (weighted) resolver from the upstream group for each query and if successful, returns its response.
188-
If the selected resolver fails to respond, a second one is picked to which the query is sent.
189-
The weighting is identical to the `parallel_best` strategy.
187+
- `random`: blocky picks one random (weighted) resolver from the upstream group for each query and if successful, returns its response.
188+
If the selected resolver fails to respond, a second one is picked to which the query is sent.
189+
The weighting is identical to the `parallel_best` strategy.
190190
Although the `random` strategy might be slower than the `parallel_best` strategy, it offers more privacy since each request is sent to a single upstream.
191191
- `strict`: blocky forwards the request in a strict order. If the first upstream does not respond, the second is asked, and so on.
192192

@@ -578,9 +578,15 @@ queries, NXDOMAIN for other types):
578578
### Block TTL
579579

580580
TTL for answers to blocked domains can be set to customize the time (in **duration format**) clients ask for those
581-
domains again. Default Block TTL is **6hours**. This setting only makes sense when `blockType` is set to `nxDomain` or
582-
`zeroIP`, and will affect how much time it could take for a client to be able to see the real IP address for a domain
583-
after receiving the custom value.
581+
domains again. Default Block TTL is **6 hours**. This setting applies to all blocking modes and will affect how much
582+
time it could take for a client to be able to see the real IP address for a domain after receiving the blocked response.
583+
584+
**For `zeroIP` and custom IP modes:** The TTL is applied to the returned A/AAAA records in the answer section.
585+
586+
**For `nxDomain` mode:** The TTL is applied to the SOA record in the authority section. Per [RFC 2308](https://www.rfc-editor.org/rfc/rfc2308),
587+
Blocky includes an SOA record in NXDOMAIN responses to enable proper negative caching by stub resolvers.
588+
The blockTTL value is used for both the SOA's TTL and its MINIMUM field, ensuring clients cache the
589+
NXDOMAIN response for the configured duration.
584590

585591
!!! example
586592

@@ -843,7 +849,7 @@ EDNS Client Subnet (ECS) configuration parameters:
843849

844850
## Special Use Domain Names
845851

846-
SUDN (Special Use Domain Names) are always enabled by default as they are required by various RFCs.
852+
SUDN (Special Use Domain Names) are always enabled by default as they are required by various RFCs.
847853
Some RFCs have optional recommendations, which are configurable as described below.
848854
However, you can completely deactivate the blocking of SUDN by setting enable to false.
849855
Warning! You should only disable this if your upstream DNS server is local, as it shouldn't be disabled for remote upstreams.
@@ -921,7 +927,7 @@ These settings apply only to the resolver under which they are nested.
921927
#### Refresh / Reload
922928

923929
To keep source contents up-to-date, blocky can periodically refresh and reparse them. Default period is
924-
**4 hours**. You can configure this by setting the `refreshPeriod` parameter to a value in **duration format**.
930+
**4 hours**. You can configure this by setting the `refreshPeriod` parameter to a value in **duration format**.
925931
A value of zero or less will disable this feature.
926932

927933
!!! example
@@ -958,7 +964,7 @@ Configures how HTTP(S) sources are downloaded:
958964

959965
### Strategy
960966

961-
See [Init Strategy](#init-strategy).
967+
See [Init Strategy](#init-strategy).
962968
In this context, "init" is loading and parsing each source, and an error is a single source failing to load/parse.
963969

964970
!!! example
@@ -970,7 +976,7 @@ In this context, "init" is loading and parsing each source, and an error is a si
970976

971977
### Max Errors per Source
972978

973-
Number of errors allowed when parsing a source before it is considered invalid and parsing stops.
979+
Number of errors allowed when parsing a source before it is considered invalid and parsing stops.
974980
A value of -1 disables the limit.
975981

976982
!!! example
@@ -982,8 +988,8 @@ A value of -1 disables the limit.
982988

983989
### Concurrency
984990

985-
Blocky downloads and processes sources concurrently. This allows limiting how many can be processed in the same time.
986-
Larger values can reduce the overall list refresh time at the cost of using more RAM. Please consider reducing this value on systems with limited memory.
991+
Blocky downloads and processes sources concurrently. This allows limiting how many can be processed in the same time.
992+
Larger values can reduce the overall list refresh time at the cost of using more RAM. Please consider reducing this value on systems with limited memory.
987993
Default value is 4.
988994

989995
!!! example

e2e/blocking_test.go

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,12 +409,33 @@ var _ = Describe("Domain blocking functionality", func() {
409409
Expect(err).Should(Succeed())
410410
})
411411

412-
It("returns NXDOMAIN for blocked domains", func(ctx context.Context) {
412+
It("returns NXDOMAIN with SOA record for blocked domains", func(ctx context.Context) {
413413
msg := util.NewMsgWithQuestion("blocked.com.", A)
414414
resp, err := doDNSRequest(ctx, blocky, msg)
415415
Expect(err).Should(Succeed())
416-
Expect(resp.Rcode).Should(Equal(dns.RcodeNameError))
417-
Expect(resp.Answer).Should(BeEmpty())
416+
417+
By("returning NXDOMAIN response code", func() {
418+
Expect(resp.Rcode).Should(Equal(dns.RcodeNameError))
419+
})
420+
421+
By("having no answer section", func() {
422+
Expect(resp.Answer).Should(BeEmpty())
423+
})
424+
425+
By("including SOA record in authority section per RFC 2308", func() {
426+
Expect(resp.Ns).Should(HaveLen(1))
427+
428+
soa, ok := resp.Ns[0].(*dns.SOA)
429+
Expect(ok).Should(BeTrue(), "Authority record should be SOA type")
430+
431+
// Verify SOA record fields
432+
Expect(soa.Header().Name).Should(Equal("blocked.com."))
433+
Expect(soa.Header().Rrtype).Should(Equal(dns.TypeSOA))
434+
Expect(soa.Header().Ttl).Should(Equal(uint32(6 * 60 * 60))) // Default 6 hours
435+
Expect(soa.Ns).Should(Equal("blocky.local."))
436+
Expect(soa.Mbox).Should(Equal("hostmaster.blocky.local."))
437+
Expect(soa.Minttl).Should(Equal(uint32(6 * 60 * 60))) // RFC 2308 negative caching TTL
438+
})
418439
})
419440
})
420441

@@ -459,7 +480,7 @@ var _ = Describe("Domain blocking functionality", func() {
459480
})
460481

461482
Describe("Block TTL", func() {
462-
Context("with custom blockTTL", func() {
483+
Context("with custom blockTTL and zeroIP", func() {
463484
BeforeEach(func(ctx context.Context) {
464485
_, err = createHTTPServerContainer(ctx, "httpserver", e2eNet, "list.txt", "blocked.com")
465486
Expect(err).Should(Succeed())
@@ -493,6 +514,47 @@ var _ = Describe("Domain blocking functionality", func() {
493514
))
494515
})
495516
})
517+
518+
Context("with custom blockTTL and nxDomain", func() {
519+
BeforeEach(func(ctx context.Context) {
520+
_, err = createHTTPServerContainer(ctx, "httpserver", e2eNet, "list.txt", "blocked.com")
521+
Expect(err).Should(Succeed())
522+
523+
blocky, err = createBlockyContainer(ctx, e2eNet,
524+
"log:",
525+
" level: warn",
526+
"upstreams:",
527+
" groups:",
528+
" default:",
529+
" - moka",
530+
"blocking:",
531+
" blockType: nxDomain",
532+
" blockTTL: 2m",
533+
" denylists:",
534+
" ads:",
535+
" - http://httpserver:8080/list.txt",
536+
" clientGroupsBlock:",
537+
" default:",
538+
" - ads",
539+
)
540+
Expect(err).Should(Succeed())
541+
})
542+
543+
It("uses the configured TTL in SOA record for NXDOMAIN responses", func(ctx context.Context) {
544+
msg := util.NewMsgWithQuestion("blocked.com.", A)
545+
resp, err := doDNSRequest(ctx, blocky, msg)
546+
Expect(err).Should(Succeed())
547+
548+
Expect(resp.Rcode).Should(Equal(dns.RcodeNameError))
549+
Expect(resp.Answer).Should(BeEmpty())
550+
Expect(resp.Ns).Should(HaveLen(1))
551+
552+
soa, ok := resp.Ns[0].(*dns.SOA)
553+
Expect(ok).Should(BeTrue(), "Authority record should be SOA type")
554+
Expect(soa.Header().Ttl).Should(Equal(uint32(120))) // 2m = 120s
555+
Expect(soa.Minttl).Should(Equal(uint32(120))) // RFC 2308 negative caching TTL
556+
})
557+
})
496558
})
497559

498560
Describe("IP blocking", func() {

helpertest/helper.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,50 @@ func ToExtra(m *model.Response) []dns.RR {
106106
return m.Res.Extra
107107
}
108108

109+
func ToAuthority(m *model.Response) []dns.RR {
110+
return m.Res.Ns
111+
}
112+
109113
func HaveNoAnswer() types.GomegaMatcher {
110114
return gomega.WithTransform(ToAnswer, gomega.BeEmpty())
111115
}
112116

117+
func HaveAuthority() types.GomegaMatcher {
118+
return gomega.WithTransform(ToAuthority, gomega.Not(gomega.BeEmpty()))
119+
}
120+
121+
func HaveSOARecord(ttl, minTTL uint32) types.GomegaMatcher {
122+
return gcustom.MakeMatcher(func(m *model.Response) (bool, error) {
123+
if len(m.Res.Ns) == 0 {
124+
return false, errors.New("no authority section records")
125+
}
126+
127+
for _, rr := range m.Res.Ns {
128+
if soa, ok := rr.(*dns.SOA); ok {
129+
if soa.Header().Ttl != ttl {
130+
return false, fmt.Errorf("SOA TTL is %d, expected %d", soa.Header().Ttl, ttl)
131+
}
132+
133+
if soa.Minttl != minTTL {
134+
return false, fmt.Errorf("SOA MINTTL is %d, expected %d", soa.Minttl, minTTL)
135+
}
136+
137+
// Verify basic structure
138+
if soa.Ns == "" || soa.Mbox == "" {
139+
return false, errors.New("SOA record has empty nameserver or mailbox")
140+
}
141+
142+
return true, nil
143+
}
144+
}
145+
146+
return false, errors.New("no SOA record found in authority section")
147+
}).WithTemplate(
148+
"Expected:\n{{.Actual}}\n{{.To}} have SOA record with TTL={{index .Data 0}} and MINTTL={{index .Data 1}}",
149+
ttl, minTTL,
150+
)
151+
}
152+
113153
func HaveReason(reason string) types.GomegaMatcher {
114154
return gcustom.MakeMatcher(func(m *model.Response) (bool, error) {
115155
return m.Reason == reason, nil

resolver/blocking_resolver.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,14 @@ const defaultBlockingCleanUpInterval = 5 * time.Second
3535

3636
func createBlockHandler(cfg config.Blocking) (blockHandler, error) {
3737
cfgBlockType := cfg.BlockType
38+
blockTime := cfg.BlockTTL.SecondsU32()
3839

3940
if strings.EqualFold(cfgBlockType, "NXDOMAIN") {
40-
return nxDomainBlockHandler{}, nil
41+
return nxDomainBlockHandler{
42+
BlockTimeSec: blockTime,
43+
}, nil
4144
}
4245

43-
blockTime := cfg.BlockTTL.SecondsU32()
44-
4546
if strings.EqualFold(cfgBlockType, "ZEROIP") {
4647
return zeroIPBlockHandler{
4748
BlockTimeSec: blockTime,
@@ -547,7 +548,9 @@ type zeroIPBlockHandler struct {
547548
BlockTimeSec uint32
548549
}
549550

550-
type nxDomainBlockHandler struct{}
551+
type nxDomainBlockHandler struct {
552+
BlockTimeSec uint32
553+
}
551554

552555
type ipBlockHandler struct {
553556
destinations []net.IP
@@ -574,8 +577,12 @@ func (b zeroIPBlockHandler) handleBlock(question dns.Question, response *dns.Msg
574577
response.Answer = append(response.Answer, rr)
575578
}
576579

577-
func (b nxDomainBlockHandler) handleBlock(_ dns.Question, response *dns.Msg) {
580+
func (b nxDomainBlockHandler) handleBlock(question dns.Question, response *dns.Msg) {
578581
response.Rcode = dns.RcodeNameError
582+
583+
// Add SOA to authority section per RFC 2308
584+
soa := util.CreateSOAForNegativeResponse(question, b.BlockTimeSec)
585+
response.Ns = []dns.RR{soa}
579586
}
580587

581588
func (b ipBlockHandler) handleBlock(question dns.Question, response *dns.Msg) {

resolver/blocking_resolver_test.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,14 +412,44 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
412412
}
413413
})
414414

415-
It("should return NXDOMAIN if query is blocked", func() {
415+
It("should return NXDOMAIN with SOA if query is blocked", func() {
416416
Expect(sut.Resolve(ctx, newRequestWithClient("blocked3.com.", A, "1.2.1.2", "unknown"))).
417417
Should(
418418
SatisfyAll(
419419
HaveNoAnswer(),
420420
HaveResponseType(ResponseTypeBLOCKED),
421421
HaveReturnCode(dns.RcodeNameError),
422422
HaveReason("BLOCKED (defaultGroup)"),
423+
HaveAuthority(),
424+
HaveSOARecord(60, 60), // 1 minute = 60 seconds
425+
))
426+
})
427+
})
428+
429+
When("BlockType is NXDOMAIN with custom BlockTTL", func() {
430+
BeforeEach(func() {
431+
sutConfig = config.Blocking{
432+
BlockType: "NxDomain",
433+
BlockTTL: config.Duration(time.Hour * 2), // 2 hours = 7200 seconds
434+
Denylists: map[string][]config.BytesSource{
435+
"defaultGroup": config.NewBytesSources(defaultGroupFile.Path),
436+
},
437+
ClientGroupsBlock: map[string][]string{
438+
"default": {"defaultGroup"},
439+
},
440+
}
441+
})
442+
443+
It("should return NXDOMAIN with SOA containing custom TTL", func() {
444+
Expect(sut.Resolve(ctx, newRequestWithClient("blocked3.com.", A, "1.2.1.2", "unknown"))).
445+
Should(
446+
SatisfyAll(
447+
HaveNoAnswer(),
448+
HaveResponseType(ResponseTypeBLOCKED),
449+
HaveReturnCode(dns.RcodeNameError),
450+
HaveReason("BLOCKED (defaultGroup)"),
451+
HaveAuthority(),
452+
HaveSOARecord(7200, 7200), // 2 hours in seconds
423453
))
424454
})
425455
})

util/common.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ var (
3030
alphanumeric = regexp.MustCompile("[a-zA-Z0-9]")
3131
)
3232

33+
// SOA record timing defaults for negative responses (RFC 2308)
34+
const (
35+
soaRefresh = 86400 // 24 hours
36+
soaRetry = 7200 // 2 hours
37+
soaExpire = 604800 // 7 days
38+
)
39+
3340
// Obfuscate replaces all alphanumeric characters with * to obfuscate user sensitive data if LogPrivacy is enabled
3441
func Obfuscate(in string) string {
3542
if LogPrivacy.Load() {
@@ -106,6 +113,30 @@ func CreateHeader(question dns.Question, remainingTTL uint32) dns.RR_Header {
106113
return dns.RR_Header{Name: question.Name, Rrtype: question.Qtype, Class: dns.ClassINET, Ttl: remainingTTL}
107114
}
108115

116+
// CreateSOAForNegativeResponse creates an SOA record for NXDOMAIN responses
117+
// per RFC 2308. The TTL and MINTTL are both set to blockTTL to ensure
118+
// proper negative caching behavior.
119+
func CreateSOAForNegativeResponse(question dns.Question, blockTTL uint32) *dns.SOA {
120+
// Use the queried domain as the zone name
121+
zoneName := dns.Fqdn(question.Name)
122+
123+
return &dns.SOA{
124+
Hdr: dns.RR_Header{
125+
Name: zoneName,
126+
Rrtype: dns.TypeSOA,
127+
Class: dns.ClassINET,
128+
Ttl: blockTTL,
129+
},
130+
Ns: "blocky.local.", // Name server
131+
Mbox: "hostmaster.blocky.local.", // Mailbox (admin contact)
132+
Serial: 1, // Serial number
133+
Refresh: soaRefresh, // 24 hours
134+
Retry: soaRetry, // 2 hours
135+
Expire: soaExpire, // 7 days
136+
Minttl: blockTTL, // Negative caching TTL (RFC 2308)
137+
}
138+
}
139+
109140
// ExtractDomain returns domain string from the question
110141
func ExtractDomain(question dns.Question) string {
111142
return ExtractDomainOnly(question.Name)

0 commit comments

Comments
 (0)