This commit is contained in:
iceBear67
2026-07-26 20:44:49 +08:00
parent ae79082481
commit 7eb35f82b1
29 changed files with 2000 additions and 1463 deletions
+45 -8
View File
@@ -130,6 +130,7 @@ func ProbeEgress(ctx context.Context, stunResults []STUNResult, logger *slog.Log
egSortObservations(rep.Observations)
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
log.With(
@@ -221,17 +222,26 @@ func egSortObservations(os []EgressObservation) {
// egUniqueIPs returns the deduplicated, sorted set of valid addresses.
func egUniqueIPs(os []EgressObservation) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(os))
var out []netip.Addr
ips := make([]netip.Addr, 0, len(os))
for _, o := range os {
if !o.IP.IsValid() {
ips = append(ips, o.IP)
}
return egDedupAddrs(ips)
}
// egDedupAddrs drops invalid and repeated addresses and sorts the rest.
func egDedupAddrs(ips []netip.Addr) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(ips))
var out []netip.Addr
for _, ip := range ips {
if !ip.IsValid() {
continue
}
if _, dup := seen[o.IP]; dup {
if _, dup := seen[ip]; dup {
continue
}
seen[o.IP] = struct{}{}
out = append(out, o.IP)
seen[ip] = struct{}{}
out = append(out, ip)
}
sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 })
return out
@@ -261,6 +271,22 @@ func egDivergent(ips []netip.Addr) bool {
return len(v4) > 1 || len(v6) > 1
}
// egDivergentSTUN applies the same test to the STUN observations alone.
//
// Only these travel the UDP path Tailscale actually uses, so a split visible
// here is the one that costs you a direct connection. HTTP-only disagreement
// says something about the browser path, not the tunnel.
func egDivergentSTUN(obs []EgressObservation) bool {
var ips []netip.Addr
for _, o := range obs {
if o.Method != MethodSTUN || o.Err != "" || !o.IP.IsValid() {
continue
}
ips = append(ips, o.IP.Unmap())
}
return egDivergent(egDedupAddrs(ips))
}
// egFinish derives Status and the one-line Chinese Summary from the collected
// addresses. It is called again by [AnnotateGeo] once geolocation is known, so
// it must stay idempotent.
@@ -270,6 +296,10 @@ func egFinish(rep *EgressReport) {
switch {
case len(rep.UniqueIPs) == 0:
rep.Status = StatusFail
case rep.DivergentSTUN:
// The UDP egress itself varies, which is what actually costs a direct
// connection — a stronger claim than "some probe disagreed".
rep.Status = StatusFail
case rep.Divergent:
rep.Status = StatusWarn
default:
@@ -291,8 +321,15 @@ func egFinish(rep *EgressReport) {
if len(v6) > 1 {
parts = append(parts, fmt.Sprintf("IPv6 有 %d 个(%s", len(v6), egJoinAddrs(v6, 4)))
}
fmt.Fprintf(&b, "出口 IP 不一致:%s,代理、VPN 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路",
strings.Join(parts, ""))
if rep.DivergentSTUN {
fmt.Fprintf(&b, "出口 IP 不一致:%s,STUN 探测本身就看到多个地址,代理、VPN 或多线接入正在拆分 UDP 流量,对端看到的地址取决于走哪条链路",
strings.Join(parts, ""))
} else {
// HTTP saw a split that STUN did not: the web path is proxied but
// the UDP path Tailscale uses may well be intact.
fmt.Fprintf(&b, "出口 IP 不一致:%s,仅 HTTP 探测存在差异,STUN(UDP)出口一致,多为浏览器代理或分流规则所致,通常不影响打洞",
strings.Join(parts, ""))
}
default:
var parts []string
+154
View File
@@ -0,0 +1,154 @@
package netdiag
import (
"net/netip"
"testing"
)
func obs(m EgressMethod, ip string) EgressObservation {
o := EgressObservation{Method: m}
if ip != "" {
o.IP = netip.MustParseAddr(ip)
}
return o
}
// TestEgressDivergenceSeverity pins the distinction the verdict depends on:
// STUN disagreeing with itself is a hard failure for hole punching, whereas
// HTTP-only disagreement is a proxy artefact and must stay a warning.
func TestEgressDivergenceSeverity(t *testing.T) {
cases := []struct {
name string
obs []EgressObservation
wantDivergent bool
wantSTUN bool
wantStatus Status
}{
{
name: "single egress",
obs: []EgressObservation{obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv4, "1.2.3.4")},
wantStatus: StatusOK,
},
{
name: "dual stack is not divergence",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv6, "2001:db8::1"),
},
wantStatus: StatusOK,
},
{
name: "http-only split warns",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodHTTPv4, "5.6.7.8"),
},
wantDivergent: true,
wantSTUN: false,
wantStatus: StatusWarn,
},
{
name: "stun split fails",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodSTUN, "5.6.7.8"),
},
wantDivergent: true,
wantSTUN: true,
wantStatus: StatusFail,
},
{
name: "proxy split alone stays a warning",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodHTTPProxy, "9.9.9.9"),
},
wantDivergent: true,
wantSTUN: false,
wantStatus: StatusWarn,
},
{
name: "no observations fails",
obs: []EgressObservation{obs(MethodSTUN, "")},
wantStatus: StatusFail,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := EgressReport{Observations: tc.obs}
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
if rep.Divergent != tc.wantDivergent {
t.Errorf("Divergent = %v, want %v", rep.Divergent, tc.wantDivergent)
}
if rep.DivergentSTUN != tc.wantSTUN {
t.Errorf("DivergentSTUN = %v, want %v", rep.DivergentSTUN, tc.wantSTUN)
}
if rep.Status != tc.wantStatus {
t.Errorf("Status = %v, want %v (summary: %s)", rep.Status, tc.wantStatus, rep.Summary)
}
})
}
}
// A STUN observation that errored carries no address and must not be mistaken
// for a second egress.
func TestEgressDivergentSTUNIgnoresErrors(t *testing.T) {
o := []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
{Method: MethodSTUN, Err: "timeout"},
}
if egDivergentSTUN(o) {
t.Error("a failed STUN probe must not count as a second egress IP")
}
}
// egFinish runs again after geolocation, so it must not drift.
func TestEgFinishIdempotent(t *testing.T) {
rep := EgressReport{Observations: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"), obs(MethodSTUN, "5.6.7.8"),
}}
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
first, status := rep.Summary, rep.Status
egFinish(&rep)
if rep.Summary != first || rep.Status != status {
t.Errorf("egFinish is not idempotent:\n first: %s (%v)\nsecond: %s (%v)",
first, status, rep.Summary, rep.Status)
}
}
// TestHeadlineDivergence checks the two verdict strings the user sees.
//
// The report is otherwise healthy: earlier branches (blocked UDP, symmetric
// NAT, unreachable overseas) all outrank egress and would mask it.
func healthyReport(eg EgressReport) *Report {
return &Report{
UDP: UDPReport{V4OK: true},
NAT: NATReport{Type: NATFullCone},
Overseas: OverseasReport{Status: StatusOK},
Egress: eg,
}
}
func TestHeadlineDivergence(t *testing.T) {
strong := healthyReport(EgressReport{Divergent: true, DivergentSTUN: true})
if got, lvl := headline(strong); got != "STUN 检测到多个出口 IP,代理或分流工具正在影响连接" {
t.Errorf("strong headline = %q", got)
} else if lvl != StatusFail {
t.Errorf("strong headline severity = %v, want fail", lvl)
}
weak := healthyReport(EgressReport{Divergent: true})
if got, lvl := headline(weak); got != "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接" {
t.Errorf("weak headline = %q", got)
} else if lvl != StatusWarn {
t.Errorf("weak headline severity = %v, want warn", lvl)
}
}
+38 -17
View File
@@ -214,7 +214,7 @@ func Run(ctx context.Context, opt Options) *Report {
rep.Egress.Status,
rep.Tailscale.Status,
)
rep.Headline = headline(rep)
rep.Headline, rep.HeadlineStatus = headline(rep)
logger.Info("diagnostics finished",
"took", rep.Duration.Round(time.Millisecond),
"status", rep.Status.String(),
@@ -232,29 +232,40 @@ func stepTitle(key string) string {
return key
}
// headline picks the single most consequential finding. The ordering is by how
// badly each condition breaks the thing this app exists to do — carry game
// traffic between peers — not by section order.
func headline(r *Report) string {
// headline picks the single most consequential finding, together with that
// sentence's own severity. The ordering is by how badly each condition breaks
// the thing this app exists to do — carry game traffic between peers — not by
// section order.
//
// The severity is returned separately because Report.Status is the worst of
// every section: an unrelated port-mapping failure would otherwise render a
// "may be affecting" headline in the same red as "is affecting", which is
// exactly the overstatement this split exists to prevent.
func headline(r *Report) (string, Status) {
switch {
case r.NAT.Type == NATUDPBlocked:
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继"
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继", StatusFail
case !r.UDP.V4OK && !r.UDP.V6OK:
return "UDP 探测全部失败,请检查防火墙或网络策略"
return "UDP 探测全部失败,请检查防火墙或网络策略", StatusFail
case r.NAT.Type == NATSymmetric:
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继"
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继", StatusFail
case r.Overseas.Status == StatusFail:
return "无法访问任何外部网络"
return "无法访问任何外部网络", StatusFail
case r.Overseas.Status == StatusWarn:
return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响"
return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响", StatusWarn
case r.Egress.DivergentSTUN:
// STUN itself saw several egress addresses: the UDP path Tailscale uses
// really does vary per flow.
return "STUN 检测到多个出口 IP,代理或分流工具正在影响连接", StatusFail
case r.Egress.Divergent:
return "检测到多个出口 IP,代理或分流工具正在影响连接"
// Only the web path disagreed; UDP may well be intact.
return "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接", StatusWarn
case r.PortMap.Status == StatusWarn && r.NAT.Type == NATPortRestrict:
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般"
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般", StatusWarn
case r.Status == StatusOK:
return "网络状况良好,具备直连条件"
return "网络状况良好,具备直连条件", StatusOK
default:
return "诊断完成,存在若干需要注意的项目"
return "诊断完成,存在若干需要注意的项目", r.Status
}
}
@@ -319,7 +330,15 @@ func (r *Report) Text() string {
status = "OK"
detail = p.Mapped.String() + " " + p.RTT.Round(time.Millisecond).String()
}
w(" %-4s %-34s %-5s %s\n", status, p.Target, p.Region, detail)
// Name the server, then the address actually probed — a shared bundle
// has to be readable without the reader resolving IPs by hand.
target := p.Host
if target == "" {
target = p.Target
} else if p.Target != "" && p.Target != p.Host {
target += " (" + p.Target + ")"
}
w(" %-4s %-46s %-5s %s\n", status, target, p.Region, detail)
}
b.WriteByte('\n')
@@ -389,8 +408,10 @@ func (r *Report) Text() string {
if r.Egress.Summary != "" {
w("%s\n", r.Egress.Summary)
}
if r.Egress.Divergent {
w("!! 不同探测方式得到了不同的公网 IP,通常说明有代理或分流在生效\n")
if r.Egress.DivergentSTUN {
w("!! STUN(UDP) 本身看到多个公网 IP,直连打洞会受影响\n")
} else if r.Egress.Divergent {
w("!! 仅 HTTP 探测得到了不同的公网 IP,STUN(UDP) 出口一致,通常不影响打洞\n")
}
for _, o := range r.Egress.Observations {
val := o.IP.String()
+3 -1
View File
@@ -647,6 +647,7 @@ func ProbeUDP(ctx context.Context, servers []STUNServer, logger *slog.Logger) UD
mu.Lock()
per[i] = []udpAttempt{{
probe: UDPProbe{
Host: srv.Host,
Target: srv.Host,
Name: srv.Name,
Region: srv.Region,
@@ -734,6 +735,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [
if err != nil {
return []udpAttempt{{
probe: UDPProbe{
Host: srv.Host,
Target: srv.Host,
Name: srv.Name,
Region: srv.Region,
@@ -756,7 +758,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [
doneV4 = true
}
dst := netip.AddrPortFrom(a, port)
p := UDPProbe{Target: dst.String(), Name: srv.Name, Region: srv.Region, Port: int(port)}
p := UDPProbe{Host: srv.Host, Target: dst.String(), Name: srv.Name, Region: srv.Region, Port: int(port)}
pctx, cancel := context.WithTimeout(ctx, stunProbeTimeout)
msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval)
cancel()
+19
View File
@@ -130,6 +130,11 @@ type STUNResult struct {
// UDPProbe is a plain "can I send and receive UDP here" datapoint.
type UDPProbe struct {
// Host is the configured "hostname:port", kept alongside the resolved
// Target so the UI can name the server rather than an anonymous address.
Host string
// Target is the address actually probed, "ip:port". A server reachable over
// both families yields one probe per family, and only this tells them apart.
Target string
Name string
Region Region
@@ -317,6 +322,15 @@ type EgressReport struct {
// intercepting part of the traffic. Having both an IPv4 and an IPv6 egress
// is ordinary dual stack and does not set this.
Divergent bool
// DivergentSTUN narrows Divergent to the case that actually breaks NAT
// traversal: STUN itself — plain UDP, the same path Tailscale punches
// through — saw more than one address in a family. That means the UDP
// egress genuinely varies per flow.
//
// Divergence seen only by the HTTP probes is a weaker signal. An HTTP proxy
// or split-tunnel rule can rewrite web traffic while leaving UDP alone, so
// it warrants a warning, not a verdict.
DivergentSTUN bool
// Countries is the set of distinct countries seen, sorted.
Countries []string
Status Status
@@ -386,6 +400,11 @@ type Report struct {
// Headline is the single most important sentence about this report.
Headline string
// HeadlineStatus is the severity of Headline specifically, which is not
// always Status. Status is the worst of every section, so a report with an
// unrelated failure elsewhere would otherwise paint a merely-cautionary
// headline in alarm red and overstate what was actually found.
HeadlineStatus Status
// Status is the worst status across all sections.
Status Status
}