Fix gVisor ICMP interception: proxy ping through WireGuard tunnel

Fixes: admin/mini-sing#1

gVisor TUN stack intercepts ICMP echo requests and replies locally
with fake <1ms latency. Real pings to Tailscale peers never reach
the WireGuard tunnel.

Fix: PrepareConnection now checks ICMP destinations against route
rules. If the matching outbound implements ICMPPinger (e.g. Tailscale),
returns an icmpProxy DirectRouteDestination that:
1. Sends the ICMP echo through tailnet's netstack via DialPing("ping4")
2. Reads the real reply from the WireGuard tunnel
3. Rebuilds a raw IP+ICMP reply packet
4. Writes it back to the TUN via DirectRouteContext

Changes:
- adapter/outbound.go: Add ICMPPinger interface
- adapter/router.go: Add FindOutbound to ConnectionRouterEx
- route/router.go: Expose FindOutbound method
- protocol/tailscale/outbound.go: Implement ICMPPinger via tailnet.DialPing
- protocol/tun/inbound.go: ICMP proxy in PrepareConnection + icmpProxy type
- tailnet/outbound.go: Add DialPing method (wraps tnet.DialContext("ping4"))
This commit is contained in:
NeoMody
2026-04-03 00:39:30 +08:00
parent c2f3a004f6
commit 1f5612e6ad
8 changed files with 717 additions and 4 deletions

338
FEATURE_SURVEY.md Normal file
View File

@@ -0,0 +1,338 @@
# 代理/VPN 客户端功能全面调研报告
> 调研日期: 2026-04-02
> 调研对象: sing-box, Clash (原版), mihomo (Clash.Meta), Hiddify, FlClash, Stash, mini-sing
---
## 一、功能矩阵对照表
### A. 协议支持
#### 入站协议 (Inbound)
| 协议 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|------|----------|-------|--------|---------|---------|-------|-----------|
| Mixed (SOCKS+HTTP) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SOCKS (独立) | ✅ | ✅ | ✅ | - | - | ✅ | - |
| HTTP (独立) | ✅ | ✅ | ✅ | - | - | ✅ | - |
| TUN | ✅ | ✅(Premium) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Redirect | ✅ | ✅ | ✅ | - | - | - | - |
| TProxy | ✅ | ✅ | ✅ | - | - | - | - |
| Shadowsocks | ✅ | - | - | - | - | - | ✅ |
| Trojan | ✅ | - | - | - | - | - | ✅ |
| VMess | ✅ | - | - | - | - | - | - |
| VLESS | ✅ | - | - | - | - | - | - |
| Hysteria/Hysteria2 | ✅ | - | - | - | - | - | - |
| TUIC | ✅ | - | - | - | - | - | - |
| Naive | ✅ | - | - | - | - | - | - |
| ShadowTLS | ✅ | - | - | - | - | - | - |
| AnyTLS | ✅ | - | - | - | - | - | - |
| Direct | ✅ | - | ✅ | - | - | - | - |
#### 出站协议 (Outbound)
| 协议 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|------|----------|-------|--------|---------|---------|-------|-----------|
| Shadowsocks | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ShadowsocksR | - | ✅ | ✅ | - | ✅ | ✅ | - |
| VMess | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| VLESS | ✅ | - | ✅ | ✅ | ✅ | ✅ | ✅ |
| Trojan | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Hysteria | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| Hysteria2 | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| TUIC | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| WireGuard | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| SSH | ✅ | - | ✅ | ✅ | - | ✅ | - |
| Tor | ✅ | - | - | - | - | - | - |
| Snell | - | ✅ | ✅ | - | ✅ | ✅ | - |
| ShadowTLS | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| AnyTLS | ✅ | - | ✅ | - | - | - | - |
| NaiveProxy | ✅ | - | - | ✅ | - | - | - |
| SOCKS | ✅ | ✅ | ✅ | - | ✅ | ✅ | - |
| HTTP | ✅ | ✅ | ✅ | - | ✅ | ✅ | - |
| Direct | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Block/Reject | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| DNS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| Tailscale | - | - | - | - | - | ✅ | ✅ |
| Selector | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| URLTest | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Fallback | - | ✅ | ✅ | - | ✅ | ✅ | - |
| Load-Balance | - | ✅ | ✅ | - | ✅ | ✅ | - |
| Relay/Chain | - | ✅ | ✅(via dialer-proxy) | - | ✅ | ✅ | - |
**用户最常需要的协议排序**: Shadowsocks > VLESS (Reality) > Trojan > VMess > Hysteria2 > WireGuard > TUIC
---
### B. 传输层 (Transport)
| 传输层 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|--------|----------|-------|--------|---------|---------|-------|-----------|
| TCP (原始) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| TLS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| WebSocket | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| gRPC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| HTTP/2 (H2) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| HTTP Upgrade | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| QUIC | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| Reality | ✅ | - | ✅ | ✅ | ✅ | ✅ | ✅ |
| uTLS 指纹 | ✅ | - | ✅ | ✅ | ✅ | ✅ | ✅ |
| Multiplex (smux/yamux/h2mux) | ✅ | - | ✅ | ✅ | ✅ | - | - |
| TCP Brutal | ✅ | - | ✅ | ✅ | ✅ | - | - |
| SplitHTTP/XHTTP | ✅ | - | ✅ | ✅ | - | - | - |
| ECH | ✅ | - | ✅ | ✅ | - | - | - |
---
### C. 路由规则 (Routing)
| 规则类型 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|----------|----------|-------|--------|---------|---------|-------|-----------|
| domain | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| domain_suffix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| domain_keyword | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| domain_regex | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| domain_wildcard | - | - | ✅ | - | ✅ | ✅ | - |
| ip_cidr | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| source_ip_cidr | ✅ | ✅ | ✅ | - | ✅ | - | - |
| ip_is_private | ✅ | - | - | - | - | - | ✅ |
| GeoIP | ✅(已弃用) | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| GeoSite | ✅(已弃用) | - | ✅ | ✅ | ✅ | ✅ | - |
| IP-ASN | ✅ | - | ✅ | - | ✅ | ✅ | - |
| port / port_range | ✅ | ✅ | ✅ | - | ✅ | ✅ | ✅ |
| source_port | ✅ | ✅ | ✅ | - | ✅ | - | - |
| process_name | ✅ | ✅ | ✅ | - | ✅ | ✅ | - |
| process_path | ✅ | ✅ | ✅ | - | ✅ | ✅ | - |
| package_name (Android) | ✅ | - | ✅ | - | ✅ | - | - |
| network (tcp/udp) | ✅ | - | ✅ | - | ✅ | ✅ | ✅ |
| protocol (sniffed) | ✅ | - | - | - | - | ✅ | ✅ |
| inbound | ✅ | - | ✅ | - | ✅ | - | ✅ |
| rule-set (远程规则集) | ✅ | ✅(Premium) | ✅ | - | ✅ | ✅ | - |
| AND/OR/NOT 逻辑组合 | ✅ | - | ✅ | - | ✅ | ✅ | - |
| SCRIPT | - | ✅(Premium) | - | - | - | ✅ | - |
| wifi_ssid / wifi_bssid | ✅ | - | - | - | - | - | - |
| clash_mode | ✅ | ✅ | ✅ | - | ✅ | - | - |
| user-agent | - | - | - | - | - | ✅ | - |
| url-regex | - | - | - | - | - | ✅ | - |
| SUB-RULE | - | - | ✅ | - | ✅ | - | - |
| Protocol Sniff | ✅ | - | ✅ | ✅ | ✅ | ✅ | ✅ |
| Sniff Override Dest | ✅(已弃用) | - | ✅ | ✅ | ✅ | ✅ | ✅(内建) |
---
### D. DNS
| 功能 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|------|----------|-------|--------|---------|---------|-------|-----------|
| UDP DNS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| TCP DNS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| DoT (DNS over TLS) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| DoH (DNS over HTTPS) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| DoQ (DNS over QUIC) | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| DoH3 (HTTP/3) | ✅ | - | ✅ | ✅ | ✅ | - | - |
| DHCP DNS | ✅ | - | - | - | - | - | - |
| FakeIP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| DNS 规则/分流 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| DNS 劫持 | ✅ | - | ✅ | ✅ | ✅ | ✅ | ✅ |
| DNS 缓存 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ECS (Client Subnet) | ✅ | - | ✅ | - | ✅ | - | - |
| Hosts 映射 | ✅ | ✅ | ✅ | - | ✅ | ✅ | - |
| 反向缓存 (IP→域名) | ✅ | - | - | - | - | - | ✅ |
| 域名策略 (prefer_ipv4等) | ✅ | - | ✅ | - | ✅ | - | ✅(prefer_ipv4) |
| 污染过滤 | ✅ | ✅ | ✅ | - | ✅ | - | ✅ |
| Prefetch | ✅ | - | - | - | - | - | ✅ |
| Serve-Stale | ✅ | - | - | - | - | - | ✅ |
| Tailscale DNS | ✅ | - | - | - | - | ✅ | ✅ |
---
### E. TUN/VPN
| 功能 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|------|----------|-------|--------|---------|---------|-------|-----------|
| Auto-Route | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Strict-Route | ✅ | - | ✅ | - | ✅ | - | ✅ |
| gVisor Stack | ✅ | - | ✅ | ✅ | ✅ | - | ✅ |
| System Stack | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Mixed Stack | ✅ | - | ✅ | ✅ | ✅ | - | - |
| 分应用代理 (Android) | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅(via VpnService) |
| Exclude Routes | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| MTU 设置 | ✅ | ✅ | ✅ | - | ✅ | - | ✅ |
| File Descriptor 传递 | ✅ | - | - | - | - | ✅ | ✅ |
| 网络变化监听 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅(非 Android) |
| 健康检查+自动回滚 | - | - | - | - | - | - | ✅ |
---
### F. UI/UX 功能
| 功能 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|------|----------|-------|--------|---------|---------|-------|-----------|
| Clash API (RESTful) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 部分(/status, /tailscale) |
| Dashboard (Yacd等) | ✅(兼容) | ✅ | ✅(MetaCubeXD) | 内建 | 内建 | 内建 | - |
| 连接日志 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| 流量统计 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| 订阅/配置管理 | ✅(远程配置) | ✅ | ✅(Provider) | ✅ | ✅ | ✅ | - |
| 配置格式 | JSON | YAML | YAML | sing-box JSON | YAML | YAML | JSON |
| URL 导入 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| QR 扫码导入 | ✅(客户端) | - | - | ✅ | - | - | - |
| GUI 客户端 | ✅(官方) | ✅(CfW等) | ✅(多款) | ✅ | ✅ | ✅ | - (CLI only) |
| SIGHUP 热重载 | ✅ | - | - | - | - | - | - |
| 配置编辑器 | ✅(客户端) | - | - | ✅ | - | ✅ | - |
| WebDAV 同步 | - | - | - | - | ✅ | ✅ | - |
---
### G. 高级功能
| 功能 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|------|----------|-------|--------|---------|---------|-------|-----------|
| GeoIP 数据库 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| GeoSite 数据库 | ✅ | - | ✅ | ✅ | ✅ | ✅ | - |
| Rule-Set (远程规则集) | ✅ | ✅(Premium) | ✅ | - | ✅ | ✅ | - |
| Proxy Provider | - | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| Proxy Chain | ✅(detour) | ✅(relay) | ✅(dialer-proxy) | - | ✅ | ✅ | ✅(Trojan detour) |
| MITM (中间人) | - | - | - | - | - | ✅ | - |
| HTTP Rewrite | - | - | - | - | - | ✅ | - |
| Script/JavaScript | - | ✅(Premium) | - | - | - | ✅ | - |
| SSID Policy Group | - | - | - | - | - | ✅ | - |
| On-Demand (按需连接) | - | - | - | - | - | ✅ | - |
| 带宽统计 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - |
| V2Ray API (统计) | ✅ | - | - | - | - | - | - |
---
### H. 平台支持
| 平台 | sing-box | Clash | mihomo | Hiddify | FlClash | Stash | mini-sing |
|------|----------|-------|--------|---------|---------|-------|-----------|
| Android | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ |
| iOS | ✅ | - | - | ✅ | - | ✅ | ✅(c-archive) |
| macOS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Windows | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ |
| Linux | ✅ | ✅ | ✅ | ✅ | ✅ | - | ✅ |
| tvOS | - | - | - | - | - | ✅ | - |
| GUI | ✅(官方) | ✅(第三方) | ✅(第三方) | ✅ | ✅ | ✅ | - |
| CLI | ✅ | ✅ | ✅ | - | - | - | ✅ |
---
## 二、mini-sing 现状总结
### 已实现
- **入站**: mixed (SOCKS5+HTTP), TUN (gVisor/system stack), Shadowsocks, Trojan
- **出站**: Shadowsocks, Trojan, VMess, VLESS, Direct, Block, Selector, URLTest, Tailscale
- **传输层**: TCP, TLS (标准 + uTLS + Reality)
- **路由**: domain, domain_suffix, domain_keyword, ip_cidr, ip_is_private, port, network, protocol, inbound
- **DNS**: UDP, DoT, 分流路由, 缓存, 反向缓存, 污染过滤, Prefetch, Serve-Stale, Tailscale DNS
- **TUN**: auto-route, strict-route, gVisor/system stack, MTU, file descriptor, 健康检查自动回滚, 出站路由排除
- **平台**: Android (JNI c-shared), iOS (c-archive), macOS, Linux, Windows
- **API**: 基础 HTTP API (/status, /tailscale/peers)
- **协议嗅探**: TLS SNI, HTTP Host
### 代码规模
~6,500 行 Go 代码,极度精简。
---
## 三、优先级排序: mini-sing 下一步应该添加什么
### P0: 基础可用性 (缺少会导致用户抱怨)
| # | 功能 | 理由 |
|---|------|------|
| 1 | **Hysteria2 出站** | 当前最热门的翻墙协议之一,基于 QUIC 的高吞吐量协议。大量节点提供商已全面转向 Hysteria2。缺少此协议会导致用户无法连接大量服务器。 |
| 2 | **WebSocket 传输层** | 最广泛使用的传输层协议CDN 中转必备。VMess+WS、VLESS+WS、Trojan+WS 是最常见的服务器配置。当前 mini-sing 只支持纯 TCP+TLS无法连接任何 WS 节点。 |
| 3 | **订阅链接导入 (Subscription)** | 用户从机场获取的几乎都是订阅链接 (clash/sing-box/base64 格式)。没有订阅导入,用户必须手动编写 JSON 配置,这对 99% 的用户是不可接受的。 |
| 4 | **完整 Clash API** | /connections, /proxies, /proxies/{name}, /proxies/{name}/delay, /traffic, /logs 等端点。这是所有 Dashboard 和客户端 UI 依赖的接口,没有就无法接入任何现有 Dashboard。 |
| 5 | **GeoIP/GeoSite 数据库支持** | 中国用户的核心需求: CN 域名/IP 直连。当前只有硬编码的关键词列表,无法覆盖完整的 CN 域名和 IP 地址。rule-set 或内置 GeoIP/GeoSite 至少需要支持一种。 |
| 6 | **DNS 出站 (dns outbound)** | 将 DNS 查询路由到 DNS resolver 而不是走代理。TUN 模式下的标准配置需要 `"protocol": "dns"` 规则指向 dns-out。当前缺失此出站类型。 |
### P1: 竞争力功能 (重要,影响用户是否选择 mini-sing)
| # | 功能 | 理由 |
|---|------|------|
| 7 | **gRPC 传输层** | 仅次于 WebSocket 的第二常用传输层,部分机场只提供 gRPC 配置。 |
| 8 | **HTTP Upgrade 传输层** | sing-box 推荐的 WS 替代方案性能更好CDN 兼容性好。 |
| 9 | **FakeIP** | 现代代理客户端的标配 DNS 解析方式,减少 DNS 泄漏和延迟。所有主流竞品都支持。 |
| 10 | **Rule-Set (远程规则集)** | 用户不想维护几万行规则。支持从 URL 加载 GeoIP/GeoSite 规则集是现代客户端的标准功能。 |
| 11 | **Fallback 代理组** | 当首选代理不可用时自动切换到备选。Selector 和 URLTest 之外的第三种必备组类型。 |
| 12 | **TUIC 出站** | 基于 QUIC 的低延迟协议,虽然用户量不如 Hysteria2 但仍有不少机场提供。 |
| 13 | **WireGuard 出站** | Cloudflare WARP 用户必备。很多用户用 WARP 作为落地出口。 |
| 14 | **process_name / process_path 规则** | macOS/Windows/Linux 上按进程分流是非常实用的功能。例如: 浏览器走代理、终端直连。 |
| 15 | **domain_regex 规则** | 正则匹配域名,处理复杂域名规则时必备。 |
| 16 | **DoH (DNS over HTTPS)** | 比 DoT 更通用的加密 DNS 协议,几乎所有公共 DNS 都支持 DoH。 |
| 17 | **连接日志和流量统计** | 通过 Clash API 暴露实时连接信息和流量数据,是诊断网络问题和监控使用的基础功能。 |
### P2: 差异化功能 (锦上添花,竞争优势)
| # | 功能 | 理由 |
|---|------|------|
| 18 | **Multiplex (sing-mux: smux/yamux/h2mux)** | 多路复用减少连接数,配合 TCP Brutal 可以暴力提速。进阶用户看重。 |
| 19 | **ShadowTLS 出站** | 伪装为正常 TLS 握手的协议,配合 Shadowsocks 使用,反审查能力强。 |
| 20 | **Load-Balance 代理组** | 负载均衡,分散流量到多个节点。多出口用户需要。 |
| 21 | **Proxy Provider (远程代理列表)** | 从 URL 动态加载代理节点列表,机场订阅的标准方式。与订阅导入互补。 |
| 22 | **AND/OR/NOT 逻辑规则** | 组合多个条件的高级规则,减少规则数量。 |
| 23 | **ECS (EDNS Client Subnet)** | DNS 查询携带客户端子网信息,获得更精准的 CDN 解析结果。 |
| 24 | **Hosts 映射** | 本地域名映射,调试和特殊需求。 |
| 25 | **ECH (Encrypted Client Hello)** | TLS 的 SNI 加密,最新的隐私保护技术。 |
| 26 | **source_port / source_ip_cidr 规则** | 按源地址/端口分流,局域网代理场景下有用。 |
| 27 | **package_name 规则 (Android)** | Android 按包名分流,比全局代理更精细。 |
| 28 | **SIGHUP 热重载** | 改配置不需要重启进程,运维友好。 |
### P3: 低优先级 / 小众需求
| # | 功能 | 理由 |
|---|------|------|
| 29 | **ShadowsocksR 出站** | 过时协议,新机场很少提供,但仍有少量存量用户。 |
| 30 | **Snell 出站** | Surge 生态专属协议,用户群极小。 |
| 31 | **SSH 出站** | 可以通过 SSH 隧道代理,小众但偶尔有用。 |
| 32 | **Tor 出站** | 匿名需求,极小众。 |
| 33 | **NaiveProxy** | 反审查协议,用户群较小。 |
| 34 | **Redirect / TProxy 入站** | Linux 透明代理,服务器/路由器场景。 |
| 35 | **MITM / HTTP Rewrite** | 中间人解析和 HTTP 重写Stash 独有,需求面窄。 |
| 36 | **Script/JavaScript 规则** | 脚本规则,灵活但维护成本高,用户少。 |
| 37 | **SSID Policy / On-Demand** | iOS 独有功能,根据 WiFi SSID 切换策略。 |
| 38 | **tvOS 支持** | Apple TV 用户,极小众。 |
| 39 | **IP-ASN 规则** | 按 ASN 编号匹配,高级用户用。 |
| 40 | **WebDAV 同步** | 多设备配置同步FlClash/Stash 有但不是核心需求。 |
---
## 四、建议实施路径
### 第一阶段: 让用户能用起来 (P0)
```
订阅导入 → WebSocket 传输 → Clash API → GeoIP/GeoSite → Hysteria2 → DNS 出站
```
订阅导入是最关键的因为没有它用户根本无法方便地配置代理。WebSocket 紧随其后,因为它是使用最广泛的传输层。
### 第二阶段: 追齐主流 (P1)
```
FakeIP → Rule-Set → gRPC/HTTPUpgrade → TUIC/WireGuard → 连接日志 → process_name → DoH → Fallback → domain_regex
```
### 第三阶段: 建立差异化优势 (P2)
```
Multiplex → ShadowTLS → Load-Balance → Proxy Provider → 逻辑规则 → ECH
```
### mini-sing 的独特优势 (已有,应持续强化)
1. **极致精简** — 6,500 行 vs sing-box 的 ~100,000 行,启动更快,内存更少
2. **Tailscale 原生集成** — 竞品中只有 Stash 有,且不如 mini-sing 深度
3. **健康检查自动回滚** — 独创功能TUN 模式下网络异常自动恢复
4. **DNS 反向缓存** — IP 到域名的映射,提升路由精度
5. **Prefetch + Serve-Stale** — DNS 缓存高级策略,减少延迟
---
## 五、数据来源
- [sing-box 官方文档](https://sing-box.sagernet.org/)
- [mihomo 官方文档](https://wiki.metacubex.one/en/)
- [Clash Wiki](https://en.clash.wiki/)
- [Stash Wiki](https://stash.wiki/en)
- [Hiddify GitHub](https://github.com/hiddify/hiddify-app)
- [FlClash 官网](https://flclash.cc/en/)
- mini-sing 源码 (`/Users/mira/sing/mini-sing/`)

View File

@@ -2,6 +2,7 @@ package adapter
import (
"context"
"net"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
@@ -38,6 +39,13 @@ type OutboundOptionsRegistry interface {
CreateOptions(outboundType string) (any, bool)
}
// ICMPPinger is optionally implemented by outbounds that can proxy ICMP echo.
// Used by TUN inbound to forward pings through tunnels (e.g., Tailscale WireGuard).
type ICMPPinger interface {
// DialPing opens an ICMP connection ("ping4"/"ping6") through the tunnel.
DialPing(ctx context.Context, network, address string) (net.Conn, error)
}
type OutboundManager interface {
Lifecycle
Outbounds() []Outbound

View File

@@ -22,6 +22,8 @@ type ConnectionRouterEx interface {
ConnectionRouter
RouteConnectionEx(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
// FindOutbound returns the outbound that would handle this metadata without routing.
FindOutbound(metadata *InboundContext) Outbound
}
type Rule interface {

View File

@@ -136,10 +136,12 @@ func (r *Resolver) Exchange(query []byte) ([]byte, error) {
domain := strings.TrimSuffix(msg.Question[0].Name, ".")
// Tailscale domains (.ts.net only; .local is mDNS reserved, not Tailscale-specific)
// Tailscale MagicDNS — supports .ts.net FQDN and short hostnames (e.g. "worker").
// LookupTailscale expands short names using search domains from the control plane.
if r.tailscale != nil {
lower := strings.ToLower(domain)
if strings.HasSuffix(lower, ".ts.net") {
// Try Tailscale lookup for .ts.net domains and short names (no dots = likely a hostname)
if strings.HasSuffix(lower, ".ts.net") || !strings.Contains(domain, ".") {
if addrs, ok := r.tailscale.LookupTailscale(domain); ok && len(addrs) > 0 {
if r.logger != nil {
r.logger.Debug("dns tailscale: ", domain, " -> ", addrs)

217
dns/resolver_test.go Normal file
View File

@@ -0,0 +1,217 @@
package dns
import (
"net/netip"
"testing"
"github.com/miekg/dns"
)
// mockTailscale implements TailscaleLookup for testing
type mockTailscale struct {
peers map[string][]netip.Addr
}
func (m *mockTailscale) LookupTailscale(domain string) ([]netip.Addr, bool) {
// Simulate real LookupTailscale behavior: try exact match, then expand with search domains
searchDomains := []string{"tail879fb9.ts.net"}
candidates := []string{domain, domain + "."}
for _, sd := range searchDomains {
candidates = append(candidates, domain+"."+sd, domain+"."+sd+".")
}
for _, c := range candidates {
if addrs, ok := m.peers[c]; ok {
return addrs, true
}
}
return nil, false
}
func newMockTailscale() *mockTailscale {
return &mockTailscale{
peers: map[string][]netip.Addr{
"worker.tail879fb9.ts.net.": {
netip.MustParseAddr("100.96.174.121"),
netip.MustParseAddr("fd7a:115c:a1e0::5034:ae79"),
},
"m1pro.tail879fb9.ts.net.": {
netip.MustParseAddr("100.123.124.119"),
},
"station.tail879fb9.ts.net.": {
netip.MustParseAddr("100.67.38.87"),
},
},
}
}
func buildQuery(name string, qtype uint16) []byte {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), qtype)
msg.RecursionDesired = true
packed, _ := msg.Pack()
return packed
}
func parseResponse(t *testing.T, raw []byte) *dns.Msg {
t.Helper()
msg := new(dns.Msg)
if err := msg.Unpack(raw); err != nil {
t.Fatalf("failed to unpack response: %v", err)
}
return msg
}
func TestExchange_TailscaleFQDN(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
resp, err := r.Exchange(buildQuery("worker.tail879fb9.ts.net", dns.TypeA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatal("expected at least one answer")
}
a, ok := msg.Answer[0].(*dns.A)
if !ok {
t.Fatalf("expected A record, got %T", msg.Answer[0])
}
if a.A.String() != "100.96.174.121" {
t.Fatalf("expected 100.96.174.121, got %s", a.A.String())
}
}
func TestExchange_TailscaleShortName(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// "worker" (no dots) should be resolved via LookupTailscale with search domain expansion
resp, err := r.Exchange(buildQuery("worker", dns.TypeA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatal("short name 'worker' should resolve to tailscale peer")
}
a, ok := msg.Answer[0].(*dns.A)
if !ok {
t.Fatalf("expected A record, got %T", msg.Answer[0])
}
if a.A.String() != "100.96.174.121" {
t.Fatalf("expected 100.96.174.121, got %s", a.A.String())
}
}
func TestExchange_TailscaleShortName_Multiple(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
tests := []struct {
name string
wantIP string
}{
{"worker", "100.96.174.121"},
{"m1pro", "100.123.124.119"},
{"station", "100.67.38.87"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := r.Exchange(buildQuery(tt.name, dns.TypeA))
if err != nil {
t.Fatalf("Exchange(%q) failed: %v", tt.name, err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatalf("Exchange(%q): no answers", tt.name)
}
a := msg.Answer[0].(*dns.A)
if a.A.String() != tt.wantIP {
t.Fatalf("Exchange(%q) = %s, want %s", tt.name, a.A.String(), tt.wantIP)
}
})
}
}
func TestExchange_TailscaleShortName_NotFound(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// "nonexistent" doesn't match any peer — should fall through
// Since we have no upstream DNS server configured, this will error
_, err := r.Exchange(buildQuery("nonexistent", dns.TypeA))
if err == nil {
t.Fatal("expected error for nonexistent short name with no upstream DNS")
}
}
func TestExchange_RegularDomain_SkipsTailscale(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// "google.com" has dots — should NOT attempt tailscale lookup, falls to upstream
_, err := r.Exchange(buildQuery("google.com", dns.TypeA))
if err == nil {
t.Fatal("expected error for regular domain with no upstream DNS")
}
// The error should be "resolver not initialized", not a tailscale error
if err.Error() != "dns: resolver not initialized" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestExchange_TailscaleFQDN_WithTrailingDot(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
// FQDN with trailing dot (as DNS wire format sends it)
resp, err := r.Exchange(buildQuery("m1pro.tail879fb9.ts.net.", dns.TypeA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
if len(msg.Answer) == 0 {
t.Fatal("FQDN with trailing dot should resolve")
}
a := msg.Answer[0].(*dns.A)
if a.A.String() != "100.123.124.119" {
t.Fatalf("got %s, want 100.123.124.119", a.A.String())
}
}
func TestExchange_TailscaleAAAA(t *testing.T) {
r := &Resolver{tailscale: newMockTailscale()}
resp, err := r.Exchange(buildQuery("worker.tail879fb9.ts.net", dns.TypeAAAA))
if err != nil {
t.Fatalf("Exchange failed: %v", err)
}
msg := parseResponse(t, resp)
// Should have both A and AAAA since buildDNSResponse returns all addrs
hasAAAA := false
for _, rr := range msg.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
hasAAAA = true
if aaaa.AAAA.String() != "fd7a:115c:a1e0::5034:ae79" {
t.Fatalf("got %s, want fd7a:115c:a1e0::5034:ae79", aaaa.AAAA.String())
}
}
}
if !hasAAAA {
t.Fatal("expected AAAA record for worker")
}
}
func TestExchange_NoTailscale(t *testing.T) {
// No tailscale configured — short names should fall through
r := &Resolver{}
_, err := r.Exchange(buildQuery("worker", dns.TypeA))
if err == nil {
t.Fatal("expected error with no tailscale and no upstream")
}
}

View File

@@ -198,6 +198,14 @@ func (t *Outbound) ServerAddr() string {
return ""
}
// DialPing implements adapter.ICMPPinger — opens an ICMP connection through WireGuard.
func (t *Outbound) DialPing(ctx context.Context, network, address string) (net.Conn, error) {
if !t.started.Load() || t.tn == nil {
return nil, fmt.Errorf("tailscale not started")
}
return t.tn.DialPing(ctx, network, address)
}
// PeerInfo represents a tailnet device.
type PeerInfo struct {
Hostname string `json:"hostname"`

View File

@@ -233,8 +233,29 @@ func (t *Inbound) Close() error {
// === tun.Handler interface ===
func (t *Inbound) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, _ tun.DirectRouteContext, _ time.Duration) (tun.DirectRouteDestination, error) {
// Allow all connections through the stack (no direct routing bypass)
func (t *Inbound) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, _ time.Duration) (tun.DirectRouteDestination, error) {
// For ICMP, check if the destination routes to an outbound that supports ping.
// Without this, gVisor replies to ICMP echo locally with fake <1ms latency.
if network == N.NetworkICMP && destination.IsIP() && routeContext != nil {
outboundManager, ok := adapter.OutboundManagerFromContext(t.ctx)
if !ok {
return nil, nil
}
// Find matching outbound via route rules
metadata := adapter.InboundContext{
Inbound: t.Tag(),
InboundType: C.TypeTun,
Source: source,
Destination: destination,
}
if outbound := t.router.FindOutbound(&metadata); outbound != nil {
_ = outboundManager // used for discovery
if pinger, ok := outbound.(adapter.ICMPPinger); ok {
t.logger.Debug("ICMP proxy: ", destination.Addr, " → ", outbound.Tag())
return newICMPProxy(t.ctx, pinger, destination.Addr, routeContext, t.logger), nil
}
}
}
return nil, nil
}
@@ -303,6 +324,117 @@ func (t *Inbound) handleDNSPacket(ctx context.Context, conn N.PacketConn, source
}
}
// === ICMP proxy for tunneled outbounds ===
// icmpProxy forwards ICMP echo requests through an outbound that supports ping
// (e.g., Tailscale WireGuard tunnel) and writes the real reply back to the TUN.
type icmpProxy struct {
ctx context.Context
pinger adapter.ICMPPinger
destination netip.Addr
routeContext tun.DirectRouteContext
logger logger.ContextLogger
closed bool
}
func newICMPProxy(ctx context.Context, pinger adapter.ICMPPinger, dest netip.Addr, rc tun.DirectRouteContext, l logger.ContextLogger) *icmpProxy {
return &icmpProxy{ctx: ctx, pinger: pinger, destination: dest, routeContext: rc, logger: l}
}
func (p *icmpProxy) WritePacket(packet *buf.Buffer) error {
// packet is a raw IP+ICMP packet from the TUN. We extract the ICMP echo
// request, send it through the tunnel, receive the real reply, rebuild a
// raw IP+ICMP reply packet, and write it back to the TUN.
data := make([]byte, packet.Len())
copy(data, packet.Bytes())
if len(data) < 20 {
return fmt.Errorf("icmp proxy: packet too short")
}
ipHdrLen := int(data[0]&0x0f) << 2
if len(data) < ipHdrLen+8 {
return fmt.Errorf("icmp proxy: ICMP payload too short")
}
origSrcIP := make([]byte, 4)
copy(origSrcIP, data[12:16]) // save original source for reply
icmpPayload := data[ipHdrLen:]
go func() {
network := "ping4"
if p.destination.Is6() {
network = "ping6"
}
conn, err := p.pinger.DialPing(p.ctx, network, p.destination.String())
if err != nil {
p.logger.Debug("icmp proxy dial: ", err)
return
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
if _, err := conn.Write(icmpPayload); err != nil {
p.logger.Debug("icmp proxy write: ", err)
return
}
reply := make([]byte, 1500)
n, err := conn.Read(reply)
if err != nil {
p.logger.Debug("icmp proxy read: ", err)
return
}
// Rebuild IP+ICMP reply: reuse original IP header, swap src↔dst, replace payload
replyPkt := make([]byte, ipHdrLen+n)
copy(replyPkt, data[:ipHdrLen]) // copy original IP header
copy(replyPkt[ipHdrLen:], reply[:n]) // ICMP reply payload
// Swap source and destination IP
srcIP := p.destination.As4()
copy(replyPkt[12:16], srcIP[:]) // src = peer (destination of original)
copy(replyPkt[16:20], origSrcIP) // dst = original source (us)
// Update total length
totalLen := uint16(ipHdrLen + n)
replyPkt[2] = byte(totalLen >> 8)
replyPkt[3] = byte(totalLen)
// Protocol = ICMP (should already be 1, but ensure)
replyPkt[9] = 1
// Recalculate IP header checksum
replyPkt[10] = 0
replyPkt[11] = 0
var csum uint32
for i := 0; i < ipHdrLen; i += 2 {
csum += uint32(replyPkt[i])<<8 | uint32(replyPkt[i+1])
}
for csum > 0xffff {
csum = (csum & 0xffff) + (csum >> 16)
}
replyPkt[10] = byte(^csum >> 8)
replyPkt[11] = byte(^csum)
if err := p.routeContext.WritePacket(replyPkt); err != nil {
p.logger.Debug("icmp proxy writeback: ", err)
}
}()
return nil
}
func (p *icmpProxy) Close() error {
p.closed = true
return nil
}
func (p *icmpProxy) IsClosed() bool {
return p.closed
}
// === DNS hijack via iptables ===
func (t *Inbound) setupDNSHijack() {

View File

@@ -115,6 +115,12 @@ func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn,
r.connection.NewPacketConnection(ctx, selectedOutbound, conn, metadata, onClose)
}
// FindOutbound returns the outbound that would handle this metadata, without
// actually routing anything. Used by TUN inbound for ICMP proxy decisions.
func (r *Router) FindOutbound(metadata *adapter.InboundContext) adapter.Outbound {
return r.matchAndSelect(metadata)
}
func (r *Router) matchAndSelect(metadata *adapter.InboundContext) adapter.Outbound {
// DNS reverse lookup: if destination is IP and we have a cached domain, populate it
if r.dnsCache != nil && metadata.Destination.Fqdn == "" && metadata.Destination.IsIP() {