diff --git a/.planning/quick/260618-l83-v4-2-6-sing-box-dns-inbound/260618-l83-PLAN.md b/.planning/quick/260618-l83-v4-2-6-sing-box-dns-inbound/260618-l83-PLAN.md new file mode 100644 index 0000000..0f779ef --- /dev/null +++ b/.planning/quick/260618-l83-v4-2-6-sing-box-dns-inbound/260618-l83-PLAN.md @@ -0,0 +1,28 @@ +--- +status: in_progress +--- + +# Quick Task 260618-l83: 修复 sing-box DNS inbound 回归 + +## 目标 + +修复 `v4.2.6` 中容器 `sing-box` 配置生成错误,避免生成不被当前运行时支持的 `type: "dns"` inbound,恢复主机创建后的出口连通性校验。 + +## 背景 + +线上新建主机失败,控制面报 `net.egress_unreachable`。远端容器日志显示 `sing-box` 解码 `/etc/sing-box/config.json` 失败,原因是 `inbounds[1]` 使用了不支持的 `type: "dns"`。 + +## 任务 + +1. 补回归测试,要求 `127.0.0.1:53` 使用受支持的 `direct` inbound。 +2. 补回归测试,禁止容器配置继续生成 `dns` inbound。 +3. 修复容器 `sing-box` 配置生成逻辑。 +4. 更新 `CHANGELOG.md` 的 `v4.2.7` 条目。 +5. 通过 PR 合并后打 `v4.2.7`,等待镜像发布,再更新远端并验证容器启动。 + +## 验收 + +- `go test ./internal/network -run 'TestBuildContainerSingBoxConfig_(DNSStubInboundUsesSupportedDirect|DirectRouteUsesEth0)' -count=1` 先失败后通过。 +- `go test ./internal/network -count=1` 通过。 +- 发布镜像包含 `org.opencontainers.image.version=4.2.7`。 +- 远端控制面和受管容器启动后不再出现 `unknown inbound type: dns`。 diff --git a/.planning/quick/260618-l83-v4-2-6-sing-box-dns-inbound/260618-l83-SUMMARY.md b/.planning/quick/260618-l83-v4-2-6-sing-box-dns-inbound/260618-l83-SUMMARY.md new file mode 100644 index 0000000..3b04e13 --- /dev/null +++ b/.planning/quick/260618-l83-v4-2-6-sing-box-dns-inbound/260618-l83-SUMMARY.md @@ -0,0 +1,39 @@ +--- +status: complete +--- + +# Quick Task 260618-l83 总结:修复 sing-box DNS inbound 回归 + +## 结果 + +修复 `v4.2.6` 容器 `sing-box` 配置中生成 `type: "dns"` inbound 的回归。新配置改为用受支持的 `direct` inbound 监听 `127.0.0.1:53`,并通过 `hijack-dns` 路由规则接管 DNS 流量。 + +## 修改 + +- `internal/network/container_singbox_config.go` + - `127.0.0.1:53` DNS stub 从 `type: "dns"` 改为 `type: "direct"`。 + - DNS stub tag 固定为 `dns-direct`,并开启 `sniff`。 + - `route.default_interface` 固定为 `eth0`。 + - direct outbound 增加 `bind_interface: "eth0"`。 +- `internal/network/container_singbox_config_test.go` + - 增加回归测试,禁止生成不支持的 `dns` inbound。 + - 增加回归测试,锁定 direct 路由使用 `eth0`。 +- `CHANGELOG.md` + - 新增 `v4.2.7` 修复条目。 + +## 验证 + +- 红灯验证: + - `go test ./internal/network -run 'TestBuildContainerSingBoxConfig_(DNSStubInboundUsesSupportedDirect|DirectRouteUsesEth0)' -count=1` + - 修复前失败,分别命中 `unsupported dns inbound` 和缺少 `route.default_interface`。 +- 绿灯验证: + - `go test ./internal/network -run 'TestBuildContainerSingBoxConfig_(DNSStubInboundUsesSupportedDirect|DirectRouteUsesEth0)' -count=1` + - `go test ./internal/network -count=1` + +## 线上修复路径 + +1. PR 合并到 `main`。 +2. 打 `v4.2.7` 标签触发 release workflow。 +3. 等待 `control-plane` 和 `managed-user` 镜像发布。 +4. 远端拉取最新镜像并重启控制面。 +5. 验证受管容器不再因 `unknown inbound type: dns` 重启,出口连通性校验恢复。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 726557f..8a56920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented in this file. +## v4.2.7 - 2026-06-18 +## What's Changed + +### Runtime & Deployment +- fix(network): 恢复容器 DNS stub 为 sing-box 支持的 direct inbound,避免新建主机因 `unknown inbound type: dns` 进入出口连通性校验失败。 +- fix(network): 为容器直连分支显式绑定 `eth0`,确保代理服务器与 bypass 直连流量不被 tun 默认路由回环。 + +**Full Changelog:** https://github.com/ZaneL1u/cloud-cli-proxy/compare/v4.2.6...v4.2.7 + + ## v4.2.6 - 2026-06-15 ## What's Changed @@ -761,4 +771,3 @@ All notable changes to this project are documented in this file. - fix(ci): source pnpm version from root package manager (6cce3f8) **Full Changelog:** https://github.com/ZaneL1u/cloud-cli-proxy/compare/v1.4.3...v1.4.4 - diff --git a/internal/network/container_singbox_config.go b/internal/network/container_singbox_config.go index 4dacb85..afa6df2 100644 --- a/internal/network/container_singbox_config.go +++ b/internal/network/container_singbox_config.go @@ -31,7 +31,7 @@ func buildContainerSingBoxConfig(outboundRaw json.RawMessage, _ /*dnsServer*/, p if err != nil { return nil, err } - dnsIn, err := buildContainerDNSInbound() + dnsIn, err := buildContainerDNSDirectInbound() if err != nil { return nil, err } @@ -98,6 +98,7 @@ func buildContainerDNS() map[string]any { func buildContainerRoute(proxyServerIP string) map[string]any { return map[string]any{ "default_domain_resolver": map[string]any{"server": "dns-local"}, + "default_interface": "eth0", "rules": []map[string]any{ {"action": "sniff", "sniffer": []string{"tls", "http", "quic", "dns"}}, {"protocol": "dns", "action": "hijack-dns"}, @@ -124,16 +125,19 @@ func buildContainerRoute(proxyServerIP string) map[string]any { } } -// buildContainerDNSInbound 渲染 DNS inbound 监听 127.0.0.1:53。 -func buildContainerDNSInbound() (json.RawMessage, error) { +// buildContainerDNSDirectInbound 渲染 DNS stub 监听 127.0.0.1:53。 +// sing-box 当前运行时不支持 inbound type=dns;这里用 direct inbound 接收 +// resolv.conf 指向本地 DNS stub 的流量,再由 route 的 hijack-dns 规则接管。 +func buildContainerDNSDirectInbound() (json.RawMessage, error) { raw, err := json.Marshal(map[string]any{ - "type": "dns", - "tag": "dns-in", + "type": "direct", + "tag": "dns-direct", "listen": "127.0.0.1", "listen_port": 53, + "sniff": true, }) if err != nil { - return nil, fmt.Errorf("marshal container dns inbound: %w", err) + return nil, fmt.Errorf("marshal container dns direct inbound: %w", err) } return raw, nil } @@ -225,8 +229,9 @@ func isDomain(s string) bool { // buildGatewayDirectOutbound 渲染 direct outbound。 func buildGatewayDirectOutbound() (json.RawMessage, error) { raw, err := json.Marshal(map[string]any{ - "type": "direct", - "tag": "direct", + "type": "direct", + "tag": "direct", + "bind_interface": "eth0", }) if err != nil { return nil, fmt.Errorf("marshal direct outbound: %w", err) diff --git a/internal/network/container_singbox_config_test.go b/internal/network/container_singbox_config_test.go index d510d66..1af982c 100644 --- a/internal/network/container_singbox_config_test.go +++ b/internal/network/container_singbox_config_test.go @@ -77,7 +77,7 @@ func TestBuildContainerSingBoxConfig_DNSStubServers(t *testing.T) { } } -func TestBuildContainerSingBoxConfig_DNSInboundIsNotDirect(t *testing.T) { +func TestBuildContainerSingBoxConfig_DNSStubInboundUsesSupportedDirect(t *testing.T) { outbound := json.RawMessage(`{"type":"socks","server":"1.2.3.4","server_port":1080}`) cfg, err := buildContainerSingBoxConfig(outbound, "1.1.1.1", "1.2.3.4") if err != nil { @@ -96,12 +96,18 @@ func TestBuildContainerSingBoxConfig_DNSInboundIsNotDirect(t *testing.T) { if !ok { continue } + if in["type"] == "dns" { + t.Fatalf("container sing-box config must not use unsupported dns inbound: %#v", in) + } if in["listen"] == "127.0.0.1" && in["listen_port"] == float64(53) { - if in["type"] != "dns" { - t.Fatalf("127.0.0.1:53 inbound type = %v, want dns", in["type"]) + if in["type"] != "direct" { + t.Fatalf("127.0.0.1:53 inbound type = %v, want direct", in["type"]) + } + if in["tag"] != "dns-direct" { + t.Fatalf("127.0.0.1:53 inbound tag = %v, want dns-direct", in["tag"]) } - if in["tag"] == "dns-direct" { - t.Fatalf("dns inbound tag must not collide with dns-direct server tag") + if in["sniff"] != true { + t.Fatalf("127.0.0.1:53 direct inbound must enable sniff, got %#v", in["sniff"]) } return } @@ -109,19 +115,40 @@ func TestBuildContainerSingBoxConfig_DNSInboundIsNotDirect(t *testing.T) { t.Fatalf("missing DNS inbound listening on 127.0.0.1:53:\n%s", string(cfg)) } -func TestBuildContainerSingBoxConfig_NoHardcodedEth0(t *testing.T) { +func TestBuildContainerSingBoxConfig_DirectRouteUsesEth0(t *testing.T) { outbound := json.RawMessage(`{"type":"socks","server":"1.2.3.4","server_port":1080}`) cfg, err := buildContainerSingBoxConfig(outbound, "1.1.1.1", "1.2.3.4") if err != nil { t.Fatal(err) } - s := string(cfg) - if strings.Contains(s, `"default_interface": "eth0"`) { - t.Errorf("route must not pin default_interface to eth0:\n%s", s) + var m map[string]any + if err := json.Unmarshal(cfg, &m); err != nil { + t.Fatal(err) + } + route, ok := m["route"].(map[string]any) + if !ok { + t.Fatalf("route missing or wrong type: %#v", m["route"]) + } + if got := route["default_interface"]; got != "eth0" { + t.Fatalf("route.default_interface = %v, want eth0", got) } - if strings.Contains(s, `"bind_interface": "eth0"`) { - t.Errorf("direct outbound must not bind to eth0:\n%s", s) + outbounds, ok := m["outbounds"].([]any) + if !ok { + t.Fatalf("outbounds missing or wrong type: %#v", m["outbounds"]) + } + for _, outbound := range outbounds { + out, ok := outbound.(map[string]any) + if !ok { + continue + } + if out["tag"] == "direct" { + if got := out["bind_interface"]; got != "eth0" { + t.Fatalf("direct outbound bind_interface = %v, want eth0", got) + } + return + } } + t.Fatalf("missing direct outbound:\n%s", string(cfg)) } // TestBuildContainerSingBoxConfig_NoEndpointIndependentNAT 锁与 v3.5 gateway