-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathtransform.go
More file actions
307 lines (273 loc) · 9.15 KB
/
Copy pathtransform.go
File metadata and controls
307 lines (273 loc) · 9.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
/*
File: transform.go
Version: 1.5.0
Updated: 30-Jun-2026 08:06 CEST
Description:
Outbound response mutation functions for sdproxy. Applied in ProcessDNS
(process.go) after a response arrives from the cache or upstream, before
it is written to the client.
transformResponse — dispatcher; calls the active transforms in order.
flattenCNAME — collapses a CNAME chain into a single A/AAAA record
under the original query name (flatten_cname: true).
applyAnswerSort — dynamically rearranges contiguous blocks of A/AAAA
response records to simulate load-balancing techniques
like Round-Robin or Random ordering without breaking
message structure dependencies.
responseContainsNullIP — detects 0.0.0.0 / :: sentinel IPs used by
upstream blocklists; used to annotate cache-hit logs.
All functions operate on an existing *dns.Msg — no upstream calls, no I/O.
Changes:
1.5.0 - [SECURITY/FIX] Hardened `flattenCNAME` to dynamically isolate and map the
originating CNAME record regardless of the upstream's Answer array sorting
order. Eradicates flattening bypasses where load-balancers injected the
CNAME organically after the terminal A/AAAA records.
1.4.1 - [PERF] Optimized minimize-answer transforms with a zero-allocation pre-scan.
Bypasses slice allocations and loops for already-conforming sections,
massively improving hot-path latency on cached records.
1.4.0 - [SECURITY/FIX] Resolved a critical negative-caching violation (RFC 2308).
`minimize_answer` previously stripped the entire Authority section if the
client lacked the `DO` (DNSSEC OK) bit. This erroneously stripped `SOA`
records from `NXDOMAIN` responses, causing strict stub resolvers to spam
retry-loops due to missing negative-TTL bounds. `SOA` is now strictly preserved.
1.3.0 - [FEAT] Integrated `applyAnswerSort` to gracefully order A/AAAA answers
prior to packet delivery (Round-Robin, Random, IP-Sort). Employs modern
zero-allocation mapping `slices.SortStableFunc` to ensure identical speeds
to native array traversal.
*/
package main
import (
"math/rand/v2"
"net/netip"
"slices"
"strings"
"github.com/miekg/dns"
)
// transformResponse applies the configured response transforms in order:
// 1. flattenCNAME — when flatten_cname: true and qtype is A or AAAA.
// 2. minimizeAnswer — strips Ns and Extra sections when minimize_answer: true.
//
// inPlace controls whether msg is mutated directly (true) or a copy is made
// first (false). Callers that own the message (e.g. upstream response) pass
// true; callers sharing a pooled pointer pass false.
//
// Returns msg unchanged when neither transform is active — zero overhead on
// the common path.
func transformResponse(msg *dns.Msg, qtype uint16, doBit bool, inPlace bool) *dns.Msg {
if !cfg.Server.FlattenCNAME && !cfg.Server.MinimizeAnswer {
return msg
}
out := msg
if !inPlace {
out = msg.Copy()
}
// Note: Flattening CNAME chains will inherently break DNSSEC validation for the
// specific records altered as it changes the owner name the RRSIG was signed for.
// This remains an opt-in privacy feature.
if cfg.Server.FlattenCNAME && (qtype == dns.TypeA || qtype == dns.TypeAAAA) {
flattenCNAME(out)
}
if cfg.Server.MinimizeAnswer {
// [SECURITY/FIX] Filter Authority Section carefully.
// We MUST perpetually retain SOA records to preserve RFC 2308 negative caching proofs,
// ensuring clients do not retry NXDOMAIN queries aggressively.
// NSEC, NSEC3, and RRSIG are conditionally retained only if the client is DNSSEC-aware (DO=1).
//
// [OPTIMIZATION] Pre-scan the Ns section. If no un-retained records exist, skip slice allocation.
if len(out.Ns) > 0 {
needFilter := false
for _, rr := range out.Ns {
rt := rr.Header().Rrtype
if !(rt == dns.TypeSOA || (doBit && (rt == dns.TypeNSEC || rt == dns.TypeNSEC3 || rt == dns.TypeRRSIG))) {
needFilter = true
break
}
}
if needFilter {
keptNs := make([]dns.RR, 0, len(out.Ns))
for _, rr := range out.Ns {
rt := rr.Header().Rrtype
if rt == dns.TypeSOA || (doBit && (rt == dns.TypeNSEC || rt == dns.TypeNSEC3 || rt == dns.TypeRRSIG)) {
keptNs = append(keptNs, rr)
}
}
out.Ns = keptNs
}
}
// Filter Additional Section: Only retain EDNS0 OPT records to preserve protocol bounds.
//
// [OPTIMIZATION] Pre-scan the Extra section. If only the OPT record is present, skip allocation.
if len(out.Extra) > 0 {
needFilter := false
for _, rr := range out.Extra {
if rr.Header().Rrtype != dns.TypeOPT {
needFilter = true
break
}
}
if needFilter {
keptExtra := make([]dns.RR, 0, len(out.Extra))
for _, rr := range out.Extra {
if rr.Header().Rrtype == dns.TypeOPT {
keptExtra = append(keptExtra, rr)
}
}
out.Extra = keptExtra
}
}
}
return out
}
// flattenCNAME collapses a CNAME chain in out.Answer into a single A or AAAA
// record under the original query name. TTL is set to the chain minimum so
// the result expires no later than the shortest-lived link in the chain.
//
// Dynamically scans for the originating CNAME record natively, ensuring
// out-of-order upstream responses are successfully flattened without evasion.
// No-ops when no terminal addresses are found or no matching CNAME is present.
func flattenCNAME(out *dns.Msg) {
if len(out.Answer) < 2 {
return
}
var cname *dns.CNAME
var cnameIdx int = -1
queryName := ""
if len(out.Question) > 0 {
queryName = out.Question[0].Name
}
// Scan for the CNAME that matches the original query organically
for i, rr := range out.Answer {
if c, ok := rr.(*dns.CNAME); ok {
if queryName == "" || strings.EqualFold(c.Hdr.Name, queryName) {
cname = c
cnameIdx = i
if queryName == "" {
queryName = c.Hdr.Name
}
break
}
}
}
if cname == nil {
return // No matching CNAME found to flatten
}
minTTL := cname.Hdr.Ttl
finals := make([]dns.RR, 0, len(out.Answer)-1)
for i, rr := range out.Answer {
if i == cnameIdx {
continue // Strip the isolated CNAME from the payload natively
}
if rr.Header().Ttl < minTTL {
minTTL = rr.Header().Ttl
}
switch r := rr.(type) {
case *dns.A:
cp := *r
cp.Hdr.Name = queryName
cp.Hdr.Ttl = minTTL
finals = append(finals, &cp)
case *dns.AAAA:
cp := *r
cp.Hdr.Name = queryName
cp.Hdr.Ttl = minTTL
finals = append(finals, &cp)
}
}
if len(finals) == 0 {
return // Chain did not resolve to terminal IPs, leave intact
}
// Apply the chain-minimum TTL to every final record securely
for _, rr := range finals {
rr.Header().Ttl = minTTL
}
out.Answer = finals
}
// applyAnswerSort sorts the A and AAAA records in the Answer section natively.
// Supports "round-robin", "random", and "ip-sort". Returns true if the order was modified.
func applyAnswerSort(msg *dns.Msg, method string) bool {
if method == "" || method == "none" || len(msg.Answer) < 2 {
return false
}
type rrItem struct {
idx int
rr dns.RR
}
var aRecords, aaaaRecords []rrItem
for i, rr := range msg.Answer {
if rr.Header().Rrtype == dns.TypeA {
aRecords = append(aRecords, rrItem{i, rr})
} else if rr.Header().Rrtype == dns.TypeAAAA {
aaaaRecords = append(aaaaRecords, rrItem{i, rr})
}
}
changed := false
processGroup := func(group []rrItem) {
if len(group) < 2 {
return
}
original := make([]dns.RR, len(group))
for i, item := range group {
original[i] = item.rr
}
sorted := make([]dns.RR, len(group))
copy(sorted, original)
switch method {
case "round-robin":
first := sorted[0]
copy(sorted, sorted[1:])
sorted[len(sorted)-1] = first
case "random":
rand.Shuffle(len(sorted), func(i, j int) {
sorted[i], sorted[j] = sorted[j], sorted[i]
})
case "ip-sort":
slices.SortStableFunc(sorted, func(a, b dns.RR) int {
var ipI, ipJ netip.Addr
if rr, ok := a.(*dns.A); ok {
ipI, _ = netip.AddrFromSlice(rr.A)
} else if rr, ok := a.(*dns.AAAA); ok {
ipI, _ = netip.AddrFromSlice(rr.AAAA)
}
if rr, ok := b.(*dns.A); ok {
ipJ, _ = netip.AddrFromSlice(rr.A)
} else if rr, ok := b.(*dns.AAAA); ok {
ipJ, _ = netip.AddrFromSlice(rr.AAAA)
}
return ipI.Compare(ipJ)
})
}
groupChanged := false
for i := range sorted {
if sorted[i] != original[i] {
groupChanged = true
break
}
}
if groupChanged {
changed = true
for i, item := range group {
msg.Answer[item.idx] = sorted[i]
}
}
}
processGroup(aRecords)
processGroup(aaaaRecords)
return changed
}
// responseContainsNullIP reports whether any A or AAAA answer carries an
// unspecified address (0.0.0.0 or ::). Upstream blocklists commonly use this
// as a sentinel instead of returning NXDOMAIN. Used to annotate log lines.
func responseContainsNullIP(msg *dns.Msg) bool {
for _, rr := range msg.Answer {
switch r := rr.(type) {
case *dns.A:
if r.A.IsUnspecified() {
return true
}
case *dns.AAAA:
if r.AAAA.IsUnspecified() {
return true
}
}
}
return false
}