|
| 1 | +# DNS 相关修复实施计划 |
| 2 | + |
| 3 | +> 分支: `dns_fix` |
| 4 | +> 来源: `code_audit_trace-back-4th.md` 优先级 1(DNS 相关) |
| 5 | +> 修复原则: 优先在 dns_fix 新引入的代码上改进 |
| 6 | +
|
| 7 | +## 0. 执行策略 |
| 8 | + |
| 9 | +### 执行总则(强制) |
| 10 | + |
| 11 | +1. **严格串行**: `Tn` 未测试通过,不允许开始 `Tn+1` |
| 12 | +2. **三件套**: 每个任务的代码实现 + 任务级测试 + 测试记录,全部记入 `.plan/test-log.md` |
| 13 | +3. **里程碑回归**: 全部任务通过后执行回归测试 |
| 14 | +4. **失败即停**: 任一测试失败立即停止,修复重测后才继续 |
| 15 | + |
| 16 | +### 背景 |
| 17 | + |
| 18 | +dns_fix 分支修复了 Scenario C(EPIPE 后继续写入)的 bug,但引入了 CLOSE-WAIT socket 堆积回归(max 从 7 增长到 111)。根因是每个 DNS 请求创建新 `DnsForwarder` + `defer Close()`,而 `Close()` 对底层 TCP 连接的释放可能不彻底。 |
| 19 | + |
| 20 | +同时,`enqueueDnsIngressTask` 已在 PR#9 中改为非阻塞(有 `default` 分支),F7 的核心修复已完成。但 handlePkt 错误日志无节流问题仍然通过 DNS worker 路径影响 DNS 功能。 |
| 21 | + |
| 22 | +## T1: 调查 forwarder.Close() → 底层 TCP 连接关闭路径 |
| 23 | + |
| 24 | +**目标**: 确定 CLOSE-WAIT 堆积的精确根因 |
| 25 | + |
| 26 | +**调查步骤**: |
| 27 | + |
| 28 | +1. 追踪 `DoTCP.Close()` (`control/dns.go:322-326`): |
| 29 | + ```go |
| 30 | + func (d *DoTCP) Close() error { |
| 31 | + if d.conn != nil { |
| 32 | + return d.conn.Close() // d.conn 是 netproxy.Conn |
| 33 | + } |
| 34 | + return nil |
| 35 | + } |
| 36 | + ``` |
| 37 | + `d.conn` 来自 `d.dialArgument.bestDialer.DialContext()` (L309)。需要确认: |
| 38 | + - 当 `bestDialer` 是 direct dialer 时,`DialContext()` 返回的 `netproxy.Conn` 的 `Close()` 是否调用 `net.Conn.Close()` → `close()` 系统调用? |
| 39 | + - 当 `bestDialer` 是 proxy dialer(vmess/trojan/ss through IEPL)时,`DialContext()` 返回的连接是否来自连接池?`Close()` 是否归还到池而非真正关闭? |
| 40 | + |
| 41 | +2. 检查 `github.com/daeuniverse/outbound` 库中 proxy dialer 的 `DialContext` 实现: |
| 42 | + - 搜索 `go.sum` 或 `go.mod` 中 `outbound` 的版本 |
| 43 | + - 在 Go module cache 中查看 proxy 协议实现的 `Close()` 方法 |
| 44 | + - 特别关注是否有 connection pool / multiplexing |
| 45 | + |
| 46 | +3. 对比 `DoTCP.ForwardDNS()` (L308-319) 中 `d.conn = conn` 的赋值时机: |
| 47 | + - 如果 `ForwardDNS` 返回错误(如 broken pipe),`d.conn` 仍然被赋值,`defer Close()` 仍然执行 |
| 48 | + - 但如果 `DialContext` 失败,`d.conn` 为 nil,`Close()` 是 no-op |
| 49 | + |
| 50 | +**关键文件**: |
| 51 | +- `control/dns.go:308-327` (DoTCP.ForwardDNS + Close) |
| 52 | +- `control/dns_control.go:590-606` (dialSend 中的 forwarder 生命周期) |
| 53 | +- `go.mod` (outbound 库版本) |
| 54 | +- Go module cache: `~/go/pkg/mod/github.com/daeuniverse/outbound@.../` (proxy 实现) |
| 55 | + |
| 56 | +**交付物**: 调查报告,记入 `.plan/test-log.md`,包含: |
| 57 | +- `Close()` 是否真正触发 TCP socket close 的结论 |
| 58 | +- 如果是连接池问题,具体哪个库/哪个函数 |
| 59 | +- CLOSE-WAIT 堆积的精确机制 |
| 60 | + |
| 61 | +## T2: 修复 CLOSE-WAIT 堆积(针对 F6) |
| 62 | + |
| 63 | +**前置**: T1 调查结果确定根因后,选择对应修复方案。 |
| 64 | + |
| 65 | +### 方案 A(如果 Close() 不触发真正关闭 — 连接池问题) |
| 66 | + |
| 67 | +在 `dialSend()` 中,`forwarder.Close()` 之后显式关闭底层 TCP 连接: |
| 68 | + |
| 69 | +**修改文件**: `control/dns_control.go` |
| 70 | + |
| 71 | +```go |
| 72 | +// L590-594 改为: |
| 73 | +forwarder, err := newDnsForwarder(upstream, *dialArgument) |
| 74 | +if err != nil { |
| 75 | + return err |
| 76 | +} |
| 77 | +defer func() { |
| 78 | + if err := forwarder.Close(); err != nil { |
| 79 | + c.log.Debugf("forwarder.Close error: %v", err) |
| 80 | + } |
| 81 | +}() |
| 82 | +``` |
| 83 | + |
| 84 | +如果 `forwarder.Close()` 只是归还到池,需要在 forwarder 的 `Close()` 方法中确保底层 TCP socket 被真正关闭。这可能需要修改 `DoTCP.Close()`: |
| 85 | + |
| 86 | +```go |
| 87 | +func (d *DoTCP) Close() error { |
| 88 | + if d.conn != nil { |
| 89 | + err := d.conn.Close() |
| 90 | + d.conn = nil // 确保不被二次关闭 |
| 91 | + return err |
| 92 | + } |
| 93 | + return nil |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +或者,如果底层是 `netproxy.Conn` 包装了连接池,需要获取裸 `net.Conn` 并调用 `Close()`。 |
| 98 | + |
| 99 | +### 方案 B(如果 Close() 确实关闭但 GC 延迟导致 fd 不及时释放) |
| 100 | + |
| 101 | +在 `dialSend()` 中立即 `Close()` 而非依赖 `defer`: |
| 102 | + |
| 103 | +**修改文件**: `control/dns_control.go` |
| 104 | + |
| 105 | +```go |
| 106 | +// L590-620 重构: 将 forwarder 的生命周期限制在最小范围 |
| 107 | +forwarder, err := newDnsForwarder(upstream, *dialArgument) |
| 108 | +if err != nil { |
| 109 | + return err |
| 110 | +} |
| 111 | +respMsg, err = forwarder.ForwardDNS(ctxDial, data) |
| 112 | +forwarder.Close() // 立即关闭,不等 defer |
| 113 | +if err != nil { |
| 114 | + // ... fallback 逻辑 |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +注意: fallback 路径 (L601-606) 也创建了 `fallbackForwarder`,同样需要立即 Close。 |
| 119 | + |
| 120 | +### 方案 C(如果 CLOSE-WAIT 来自非 DNS 代理路径而非 DNS forwarder) |
| 121 | + |
| 122 | +需要 T1 排除此可能性。triage 数据中 CLOSE-WAIT 的 peer 是 `163.177.58.13:11108`(IEPL 节点),而 DNS upstream 走 direct 到 `192.168.1.8:5553`。**如果 CLOSE-WAIT 连接的 remote 地址是 IEPL 节点,说明 CLOSE-WAIT 来自非 DNS 代理路径**,那么 F6 的修复归属应转移到 broken-pipe 分支。 |
| 123 | + |
| 124 | +**调查方法(T1 中执行)**: |
| 125 | +```bash |
| 126 | +# 在 dae 运行实例上检查 CLOSE-WAIT 连接的 remote 地址 |
| 127 | +ss -tnp state close-wait | grep dae |
| 128 | +# 如果 remote 是 163.177.58.13 → 非 DNS 代理路径 |
| 129 | +# 如果 remote 是 192.168.1.8:5553 → DNS forwarder 路径 |
| 130 | +``` |
| 131 | + |
| 132 | +**关键文件**: |
| 133 | +- `control/dns_control.go:570-620` (dialSend) |
| 134 | +- `control/dns.go:301-327` (DoTCP) |
| 135 | + |
| 136 | +**测试方法**: |
| 137 | +1. 部署修复后运行 `dae_triage_unified_v5.sh --service dae --enable-tcpdump --enable-strace --peer-ip 163.177.58.13` |
| 138 | +2. 持续采集 30 分钟 |
| 139 | +3. **成功标准**: `ss -tnp state close-wait | grep dae | wc -l` 持续 ≤10 |
| 140 | +4. **回归检查**: Scenario C 仍为 0 |
| 141 | + |
| 142 | +## T3: DNS worker 路径 handlePkt 错误日志节流 |
| 143 | + |
| 144 | +**目标**: 减少 DNS worker 路径的日志风暴(F4 的 DNS 部分) |
| 145 | + |
| 146 | +**修改文件**: `control/control_plane.go` |
| 147 | + |
| 148 | +**实现**: |
| 149 | + |
| 150 | +在 `ControlPlane` struct 中新增 atomic 计数器(约 L102 附近): |
| 151 | +```go |
| 152 | +// handlePktDnsErrTotal tracks DNS worker handlePkt errors for log throttling. |
| 153 | +handlePktDnsErrTotal uint64 |
| 154 | +``` |
| 155 | + |
| 156 | +修改 L858-860: |
| 157 | +```go |
| 158 | +// 现有代码: |
| 159 | +if e := c.handlePkt(udpConn, task.data, task.convergeSrc, task.pktDst, task.realDst, task.routingResult, false); e != nil { |
| 160 | + c.log.Warnln("handlePkt(dns):", e) |
| 161 | +} |
| 162 | + |
| 163 | +// 改为: |
| 164 | +if e := c.handlePkt(udpConn, task.data, task.convergeSrc, task.pktDst, task.realDst, task.routingResult, false); e != nil { |
| 165 | + total := atomic.AddUint64(&c.handlePktDnsErrTotal, 1) |
| 166 | + if total == 1 || total%dnsIngressQueueLogEvery == 0 { |
| 167 | + c.log.WithFields(logrus.Fields{ |
| 168 | + "total": total, |
| 169 | + }).Warnln("handlePkt(dns):", e) |
| 170 | + } |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +复用已有常量 `dnsIngressQueueLogEvery = 100` (L51)。 |
| 175 | + |
| 176 | +**测试方法**: |
| 177 | +1. 触发 broken pipe 高峰期 |
| 178 | +2. 观察 `journalctl -u dae | grep "handlePkt(dns)" | wc -l` 一分钟内的行数 |
| 179 | +3. **成功标准**: 从 250 条/分钟降至 ≤5 条/分钟,日志包含 `total` 字段 |
| 180 | + |
| 181 | +**关键文件**: |
| 182 | +- `control/control_plane.go:51` (常量) |
| 183 | +- `control/control_plane.go:102` (struct 字段) |
| 184 | +- `control/control_plane.go:858-860` (日志调用点) |
| 185 | + |
| 186 | +## M1: 本地验证 |
| 187 | + |
| 188 | +```bash |
| 189 | +gofmt -l ./control/ |
| 190 | +GOWORK=off GOOS=linux GOARCH=amd64 go vet ./control/ |
| 191 | +go test -race ./control/ -run TestDnsForwarder # 如果有相关测试 |
| 192 | +``` |
| 193 | + |
| 194 | +## 任务依赖图 |
| 195 | + |
| 196 | +``` |
| 197 | +T1 (调查) → T2 (修复 CLOSE-WAIT) |
| 198 | + → M1 (验证) |
| 199 | +T3 (日志节流,独立) → M1 (验证) |
| 200 | +``` |
| 201 | + |
| 202 | +## 交付清单 |
| 203 | + |
| 204 | +| 文件 | 改动 | |
| 205 | +|---|---| |
| 206 | +| `control/dns_control.go:590-620` | T2: forwarder 关闭方式改进 | |
| 207 | +| `control/dns.go:322-327` | T2: DoTCP.Close() 可能需要增强 | |
| 208 | +| `control/control_plane.go:102` | T3: 新增 `handlePktDnsErrTotal` 字段 | |
| 209 | +| `control/control_plane.go:858-860` | T3: handlePkt(dns) 日志节流 | |
| 210 | + |
| 211 | +## CI 要求 |
| 212 | + |
| 213 | +- `gofmt` 无差异 |
| 214 | +- `go vet` 通过 |
| 215 | +- `go test -race ./control/` 通过 |
| 216 | +- 编译成功 (GOOS=linux GOARCH=amd64) |
0 commit comments