fix gui
This commit is contained in:
+45
-8
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user