Skip to content

Commit fd8ba2d

Browse files
committed
dae DNS 改进代码审计报告
1 parent 82dbd66 commit fd8ba2d

File tree

1 file changed

+259
-0
lines changed

1 file changed

+259
-0
lines changed

.plan/code_audit_report.md

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# dae DNS 改进代码审计报告
2+
3+
**审计日期**: 2026-02-16
4+
**审计分支**: `dns_fix` (82dbd66)
5+
**审计范围**: `control/dns.go`, `control/dns_control.go`, `control/dns_improvement_test.go`
6+
**变更统计**: 3 个源文件,+293/-28 行
7+
8+
---
9+
10+
## Code Review Summary
11+
12+
**Files reviewed**: 3 source files, 293 lines added, 28 lines removed
13+
**Overall assessment**: **REQUEST_CHANGES** (1 P0, 3 P1, 5 P2, 2 P3)
14+
15+
---
16+
17+
## 已实现任务与计划一致性确认
18+
19+
| 计划任务 | 实现状态 | 备注 |
20+
|---------|---------|------|
21+
| T1: DoUDP 连接回收 + context 传播 | ✅ 已实现 | `d.conn = conn`, `Close()` 置 nil, `context.WithTimeout(ctx, ...)` |
22+
| T2: dialSend 超时反馈闭环 | ✅ 已实现 | `isTimeoutError` + `timeoutExceedCallback` 在主/fallback 路径 |
23+
| T3: HTTP/Stream context+deadline | ✅ 已实现 | `NewRequestWithContext`, `sendStreamDNS(ctx, ...)`, `SetDeadline` |
24+
| T4: tcp+udp 同查询 fallback | ✅ 已实现 | `tcpFallbackDialArgument` 条件触发,一次 fallback |
25+
| T5: ipversion_prefer 条件补查 | ✅ 已实现 | 先查首选 → 按需补查 → 条件 reject |
26+
| T6: dnsForwarderCache 淘汰 | ✅ 已实现 | `maxDnsForwarderCacheSize=128`, 按 last-use 淘汰 |
27+
28+
---
29+
30+
## Findings
31+
32+
### P0 - Critical
33+
34+
#### P0-1: DoUDP 并发数据竞争 — goroutine 写 `d.conn` 与主线程读 `d.conn` 无同步
35+
36+
**文件**: `control/dns.go:307-361`
37+
38+
DoUDP.ForwardDNS 在 L316 执行 `d.conn = conn`,然后启动一个 goroutine(L323-347)每秒通过 `d.conn.Write(data)` 重发 DNS 请求,同时主 goroutine 在 L353 调用 `d.conn.Read(respBuf)` 阻塞等待响应。
39+
40+
这里有两个并发风险:
41+
42+
1. **重入竞争**:由于 `DoUDP` 现在被缓存复用(forwarder cache),如果上一次查询的 goroutine 尚未退出(`dnsReqCtx` 未被取消),新一次 `ForwardDNS` 调用会在 L316 覆写 `d.conn`,导致旧 goroutine 向新连接写入旧请求数据。虽然 `dialSend` 在成功后调用 `forwarder.Close()`,但 Close 只关闭 `d.conn` 并置 nil,旧 goroutine 下一次 `d.conn.Write` 会 panic(nil pointer dereference)或产生不可预测行为。
43+
44+
2. **goroutine 泄漏**:如果 `ForwardDNS` 因读取错误提前返回(L354),`cancelDnsReqCtx` 会在 defer 中被调用,goroutine 应该在下一个 `dnsReqCtx.Done()` 检查时退出。但这依赖于 goroutine 当前正好在 `select` 等待而非 `d.conn.Write` 阻塞中。如果 Write 因连接已关闭而阻塞(某些实现),goroutine 会泄漏。
45+
46+
**建议修复**
47+
- 方案 A(推荐):`ForwardDNS` 使用局部变量 `conn` 传递给 goroutine(via closure capture),而非通过 `d.conn` 共享状态。仍在函数开头赋值 `d.conn = conn` 以支持 `Close()`,但 goroutine 内部使用捕获的局部变量:
48+
```go
49+
d.conn = conn
50+
localConn := conn // goroutine 使用 localConn
51+
go func() {
52+
for {
53+
_, _ = localConn.Write(data)
54+
...
55+
}
56+
}()
57+
n, err := localConn.Read(respBuf)
58+
```
59+
- 方案 B:在 `ForwardDNS` 入口处检查并关闭旧连接 + 等待旧 goroutine 退出(通过 done channel)。
60+
61+
---
62+
63+
### P1 - High
64+
65+
#### P1-1: `dialSend` 残留 dead code(L635-637)
66+
67+
**文件**: `control/dns_control.go:635-637`
68+
69+
```go
70+
if err != nil {
71+
return err
72+
}
73+
```
74+
75+
此处 `err` 来自 `newDnsForwarder`(L619),但该错误已在 L620-622 处理并返回。到达 L635 时,`err` 一定为 nil(cache hit 时 `err` 未被重新赋值,cache miss 时错误已在 L620 返回)。这段代码是 dead code,降低了可读性,且增加了维护者误解控制流的风险。
76+
77+
**建议**: 删除 L635-637。
78+
79+
#### P1-2: fallback 失败时返回原始 `err` 而非 `fallbackErr`
80+
81+
**文件**: `control/dns_control.go:646-647`
82+
83+
```go
84+
if fallbackErr != nil {
85+
return err // 这里返回的是原始 UDP 错误,而非 fallback 创建失败的错误
86+
}
87+
```
88+
89+
`newDnsForwarder` 为 TCP fallback 创建失败时,返回的是原始 UDP 超时错误 `err`,而非 fallback 构造错误 `fallbackErr`。这会让调试时误以为是 UDP 超时,实际是 TCP forwarder 创建失败。应返回 `fallbackErr` 或包含两者的 wrapped error。
90+
91+
**建议**: `return fmt.Errorf("tcp fallback forwarder creation failed: %w (original: %v)", fallbackErr, err)`
92+
93+
#### P1-3: `dialSend``ctxDial` 使用 `context.TODO()` 而非调用链 context
94+
95+
**文件**: `control/dns_control.go:610`
96+
97+
```go
98+
ctxDial, cancel := context.WithTimeout(context.TODO(), consts.DefaultDialTimeout)
99+
```
100+
101+
v3 计划要求统一 context 传播,但 `dialSend` 的顶层 context 仍然是 `context.TODO()`。这意味着:
102+
- 即使上层取消(如 client 断开),`dialSend` 不会感知取消,继续等待 8s 超时
103+
- 在高并发场景下,已被客户端放弃的请求仍会占用上游连接资源
104+
105+
**现实影响评估**: 这是既有代码而非本次引入的问题,且 `dialSend` 当前无调用者能传入有意义的 context(来自 eBPF 层的 UDP 请求天然无 context)。优先级为 P1 是因为它与本次改进的 context 统一目标不一致,但不阻塞合并。
106+
107+
**建议**: 在后续迭代中为 `dialSend` 增加 `ctx` 参数,替换 `context.TODO()`
108+
109+
---
110+
111+
### P2 - Medium
112+
113+
#### P2-1: 缓存的 forwarder 在每次 `dialSend` 返回后被 Close(),使缓存失去意义
114+
115+
**文件**: `control/dns_control.go:629-633, 670-671`
116+
117+
```go
118+
defer func() {
119+
if !connClosed {
120+
forwarder.Close()
121+
}
122+
}()
123+
// ...
124+
forwarder.Close()
125+
connClosed = true
126+
```
127+
128+
forwarder 从 cache 取出后,无论成功还是失败,都会在 `dialSend` 返回前被 `Close()` 关闭。这意味着 **缓存中的 forwarder 在下次被取出时已经处于关闭状态**,但对于 DoTCP/DoTLS/DoUDP,`ForwardDNS` 总是重新拨号(不检查旧连接状态),所以不会直接出错。然而这使得 forwarder cache 实质退化为"forwarder 工厂缓存"——缓存的不是连接,而是配置。
129+
130+
对于 DoH(复用 `http.Client`)和 DoQ(复用 `quic.EarlyConnection`),`Close()` 是空操作,所以缓存对它们确实有连接复用价值。但对 TCP/TLS/UDP 类型,每次 dialSend 都是"创建→使用→关闭"的完整生命周期,cache 只是避免了重复创建 `DnsForwarder` struct(开销极低)。
131+
132+
**建议**: 这是一个架构层面的问题,不阻塞当前 PR。但如果要实现真正的 TCP/TLS 连接池复用,需要重新设计 forwarder 的生命周期管理。
133+
134+
#### P2-2: `evictDnsForwarderCacheOneLocked` 的 O(n) 扫描
135+
136+
**文件**: `control/dns_control.go:134-158`
137+
138+
淘汰策略通过遍历整个 `dnsForwarderLastUse` map 找到最旧项,时间复杂度 O(n),其中 n = maxDnsForwarderCacheSize = 128。
139+
140+
**影响**: 在 `dialSend` 的 hot path 上(持有 `dnsForwarderCacheMu` 锁期间),128 次迭代不是性能问题。但如果未来增大缓存容量,应考虑使用 `container/heap` 或链表实现 O(1) 淘汰。
141+
142+
**建议**: 当前 n=128 可接受,添加注释说明复杂度和限制。
143+
144+
#### P2-3: `dnsForwarderKey` 包含指针字段,map key 比较语义依赖指针相等
145+
146+
**文件**: `control/dns_control.go:385-388, 375-383`
147+
148+
`dnsForwarderKey` 嵌入了 `dialArgument`,而 `dialArgument` 包含 `*dialer.Dialer``*outbound.DialerGroup` 指针字段。Go 的 map key 比较指针时使用指针值(地址),不比较指向的内容。
149+
150+
**影响**: 如果同一个逻辑 dialer 由不同指针表示(例如 reload 后),cache 会未命中。这不会导致正确性问题(只是 cache miss),但可能导致 cache 膨胀。考虑到 reload 后整个 `DnsController` 会被重建,这个问题在实践中不太可能触发。
151+
152+
**建议**: 可接受,但如果 cache miss 率高于预期,应排查此原因。
153+
154+
#### P2-4: `ipversion_prefer` 条件补查路径可能产生不一致的响应
155+
156+
**文件**: `control/dns_control.go:453-463`
157+
158+
```go
159+
cache2 := c.LookupDnsRespCache(c.cacheKey(qname, qtype2), true)
160+
if cache2 == nil || !cache2.IncludeAnyIp() {
161+
if err = c.handle_(dnsMessage2, req, false); err != nil {
162+
return err
163+
}
164+
cache2 = c.LookupDnsRespCache(c.cacheKey(qname, qtype2), true)
165+
}
166+
if cache2 != nil && cache2.IncludeAnyIp() {
167+
return c.sendReject_(dnsMessage, req)
168+
}
169+
return sendPkt(c.log, resp, req.realDst, req.realSrc, req.src, req.lConn)
170+
```
171+
172+
逻辑分析:
173+
-`qtype != c.qtypePrefer` 时(即当前请求的类型不是首选类型),代码会查询首选类型的缓存
174+
- 如果首选类型的结果有 IP,则 reject 当前请求(让客户端使用首选类型的结果)
175+
- 否则放行当前请求
176+
177+
`c.handle_(dnsMessage2, req, false)``needResp=false`,这意味着补查的结果只会写入 cache,不会直接回复客户端。如果补查失败(上游超时),`cache2` 仍为 nil,当前请求被放行——这是正确的降级行为。
178+
179+
但有一个边缘场景:`handle_` 内部有 dedup 锁(`handlingState`),如果另一个并发请求正在处理相同的 `qtype2` 查询,`handle_` 会阻塞等待。这可能增加非首选类型查询的延迟,但不影响正确性。
180+
181+
**建议**: 可接受,但应在代码中添加注释解释这个条件补查的意图和边缘行为。
182+
183+
#### P2-5: 测试覆盖不足 — 缺少 DoUDP 连接生命周期测试
184+
185+
**文件**: `control/dns_improvement_test.go`
186+
187+
当前测试覆盖了:
188+
- `isTimeoutError` 各种输入
189+
- `tcpFallbackDialArgument` 条件判断
190+
- `sendStreamDNS` 的 context 取消
191+
- `evictDnsForwarderCacheOneLocked` 淘汰行为
192+
193+
缺少:
194+
- DoUDP 连接生命周期测试(`d.conn` 赋值、Close 幂等性)
195+
- `dialSend` 超时回调触发的集成测试
196+
- forwarder cache hit/miss 路径测试
197+
- ipversion_prefer 条件补查路径测试
198+
199+
**建议**: 补充上述测试用例。鉴于环境限制(依赖 `github.com/daeuniverse/outbound` 无法从 proxy.golang.org 拉取),可考虑使用 mock/interface 抽象降低测试对外部依赖的耦合。
200+
201+
---
202+
203+
### P3 - Low
204+
205+
#### P3-1: 注释格式不一致
206+
207+
**文件**: `control/dns.go:1-4`
208+
209+
```go
210+
/*
211+
* SPDX-License-Identifier: AGPL-3.0-only
212+
* Copyright (c) 2022-2025, daeuniverse Organization <dae@v2raya.org>
213+
*/
214+
```
215+
216+
diff 显示 `*/` 行的格式从 `*/` 改为 ` */`(添加了前导空格),使得闭合注释与其他行不对齐。这是一个纯格式变更,不影响功能。
217+
218+
**建议**: 保持与项目其他文件一致的注释格式。
219+
220+
#### P3-2: `DoUDP.ForwardDNS` 重试间隔硬编码
221+
222+
**文件**: `control/dns.go:344`
223+
224+
```go
225+
case <-time.After(1 * time.Second):
226+
```
227+
228+
UDP 重试间隔固定为 1 秒,超时固定为 5 秒(L318)。在低延迟网络中,1 秒重试间隔过长;在高延迟网络中可能过于激进。这是既有代码,不是本次引入。
229+
230+
**建议**: 后续迭代可考虑指数退避或可配置的重试策略。
231+
232+
---
233+
234+
## Removal/Iteration Plan
235+
236+
### 可立即删除
237+
- `control/dns_control.go:635-637`: dead code(`if err != nil { return err }`),`err` 在此处一定为 nil。
238+
239+
### 后续迭代建议
240+
1. **DoUDP 并发安全修复** (P0-1): 优先修复 goroutine 与 `d.conn` 的竞争问题。
241+
2. **dialSend context 传播** (P1-3): 将 `context.TODO()` 替换为调用链 context。
242+
3. **forwarder 连接池化** (P2-1): 如果需要真正的连接复用,需重新设计 forwarder 生命周期。
243+
4. **测试补充** (P2-5): 补充连接生命周期和集成测试。
244+
245+
---
246+
247+
## Additional Suggestions
248+
249+
1. **Go race detector 验证**: 建议在 CI 中增加 `go test -race ./control/...` 以检测 P0-1 所述的数据竞争。
250+
2. **forwarder cache 指标**: 建议添加 cache hit/miss 计数器,以便观测 cache 效率。
251+
3. **UDP 重发流量放大**: 如 v3 计划所述,DoUDP 每次查询最多发送 5 个 UDP 包(每秒重发,共 5 秒)。压测下这会放大上游流量 5 倍。可考虑减少重试次数或缩短超时。
252+
253+
---
254+
255+
## Next Steps
256+
257+
I found **11 issues** (P0: **1**, P1: **3**, P2: **5**, P3: **2**).
258+
259+
MUST-FIX **Fix P0 + P1** — 修复并发竞争 + dead code + fallback 错误返回

0 commit comments

Comments
 (0)