add: gui and tsdiag

This commit is contained in:
iceBear67
2026-07-26 09:39:17 +00:00
parent 5dc1759d80
commit 5b3a7e147c
41 changed files with 16451 additions and 8 deletions
+324
View File
@@ -0,0 +1,324 @@
package netdiag
// This file collects every public address the machine appears to use, from as
// many different exits as possible.
//
// The methods are not redundant. STUN rides raw UDP, so it sees the address a
// peer would see and no HTTP proxy can touch it — that makes it the ground
// truth. The HTTP echo services are queried three ways: forced IPv4 with the
// proxy bypassed, forced IPv6 with the proxy bypassed, and through whatever
// proxy the environment advertises. When those answers disagree, traffic is
// being split across paths, and the address peers will actually connect back
// to is whichever path carries the tunnel — which is exactly the surprise this
// section exists to expose.
import (
"context"
"fmt"
"log/slog"
"net/netip"
"sort"
"strings"
"sync"
"time"
)
const (
// egTimeout bounds one HTTP echo query.
egTimeout = 5 * time.Second
// egMaxInflight bounds concurrent echo queries.
egMaxInflight = 6
// egMaxBody caps the echo response read. The services answer with a bare
// IP; anything larger is a portal or an error page.
egMaxBody = 4 << 10
)
func egLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/egress"))
}
// egTarget is one HTTP echo service, queried over one specific path.
type egTarget struct {
method EgressMethod
url string
region Region
network string // "tcp4", "tcp6" or "" for unforced
useProxy bool
}
// egTargets lists the echo services. All of them return a bare IP address in
// the body. The CN-hosted ones (ipw.cn) are kept because they stay reachable
// when the international ones are not, and their answer is what a domestic
// peer would see.
func egTargets() []egTarget {
return []egTarget{
// Forced IPv4, proxy explicitly bypassed.
{method: MethodHTTPv4, url: "https://api.ipify.org", region: RegionIntl, network: "tcp4"},
{method: MethodHTTPv4, url: "https://icanhazip.com", region: RegionIntl, network: "tcp4"},
{method: MethodHTTPv4, url: "https://4.ipw.cn", region: RegionCN, network: "tcp4"},
{method: MethodHTTPv4, url: "https://ipinfo.io/ip", region: RegionIntl, network: "tcp4"},
// Forced IPv6, proxy explicitly bypassed.
{method: MethodHTTPv6, url: "https://api6.ipify.org", region: RegionIntl, network: "tcp6"},
{method: MethodHTTPv6, url: "https://6.ipw.cn", region: RegionCN, network: "tcp6"},
// Unforced network, honouring HTTP(S)_PROXY.
{method: MethodHTTPProxy, url: "https://api.ipify.org", region: RegionIntl, useProxy: true},
{method: MethodHTTPProxy, url: "https://4.ipw.cn", region: RegionCN, useProxy: true},
}
}
// ProbeEgress reports every public address this machine appears to use.
//
// stunResults are the already-collected STUN observations; STUN is not re-run
// here. Successful ones become [MethodSTUN] observations and serve as the
// proxy-immune reference the HTTP answers are compared against.
//
// The HTTP echo services are queried concurrently with a ~5s budget each.
// Geo and Countries are deliberately left empty; [AnnotateGeo] fills them so
// the caller can skip the third-party lookups entirely.
func ProbeEgress(ctx context.Context, stunResults []STUNResult, logger *slog.Logger) EgressReport {
log := egLog(logger)
var (
mu sync.Mutex
obs []EgressObservation
wg sync.WaitGroup
sem = make(chan struct{}, egMaxInflight)
tgts = egTargets()
)
for _, r := range stunResults {
if !r.OK {
continue
}
ip := r.Mapped.Addr().Unmap().WithZone("")
if !ip.IsValid() {
continue
}
obs = append(obs, EgressObservation{
Method: MethodSTUN,
Source: r.Server,
Region: r.Region,
IP: ip,
RTT: r.RTT,
})
}
for _, t := range tgts {
wg.Add(1)
go func(t egTarget) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
o := egQuery(ctx, t, log)
mu.Lock()
obs = append(obs, o)
mu.Unlock()
}(t)
}
wg.Wait()
rep := EgressReport{Observations: obs}
egSortObservations(rep.Observations)
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
egFinish(&rep)
log.With(
slog.Int("observations", len(rep.Observations)),
slog.Int("unique_ips", len(rep.UniqueIPs)),
slog.Bool("divergent", rep.Divergent),
slog.String("status", rep.Status.String()),
).Debug("finished egress probes")
return rep
}
// egQuery asks one echo service for our address. Failures are recorded in the
// observation's Err field rather than returned, so a dead service still shows
// up as a row instead of vanishing.
func egQuery(ctx context.Context, t egTarget, log *slog.Logger) EgressObservation {
o := EgressObservation{
Method: t.method,
Source: t.url,
Region: t.region,
}
// Label the row by the path actually taken. Reporting a direct request as
// MethodHTTPProxy would make the egress table claim a proxy was exercised
// when none is configured.
usedProxy := t.useProxy && diagProxyConfigured(t.url)
if t.useProxy && !usedProxy {
o.Source = t.url + "(未配置代理,实际直连)"
}
qctx, cancel := context.WithTimeout(ctx, egTimeout)
defer cancel()
client := newDiagClient(t.network, usedProxy, egTimeout)
defer client.CloseIdleConnections()
code, body, rtt, err := diagGet(qctx, client, t.url, egMaxBody, nil)
o.RTT = rtt
switch {
case err != nil:
o.Err = rchErrText(err)
case code < 200 || code > 299:
o.Err = fmt.Sprintf("unexpected status %d", code)
default:
text := strings.TrimSpace(string(body))
ip, perr := netip.ParseAddr(text)
if perr != nil {
o.Err = fmt.Sprintf("unparseable response %q", egEllipsis(text, 48))
break
}
o.IP = ip.Unmap().WithZone("")
}
log.With(
slog.String("method", string(t.method)),
slog.String("source", t.url),
slog.String("ip", o.IP.String()),
slog.Duration("rtt", o.RTT),
slog.String("error", o.Err),
).Debug("egress echo query done")
return o
}
// egEllipsis truncates s for safe inclusion in an error string, so a hijacked
// response cannot dump a whole HTML page into the UI.
func egEllipsis(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// egSortObservations orders by method, then source, then address, so the table
// does not jitter between refreshes.
func egSortObservations(os []EgressObservation) {
sort.Slice(os, func(i, j int) bool {
x, y := os[i], os[j]
if x.Method != y.Method {
return x.Method < y.Method
}
if x.Source != y.Source {
return x.Source < y.Source
}
return x.IP.Compare(y.IP) < 0
})
}
// 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
for _, o := range os {
if !o.IP.IsValid() {
continue
}
if _, dup := seen[o.IP]; dup {
continue
}
seen[o.IP] = struct{}{}
out = append(out, o.IP)
}
sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 })
return out
}
// egSplitFamilies partitions addresses into IPv4 and IPv6 sets.
func egSplitFamilies(ips []netip.Addr) (v4, v6 []netip.Addr) {
for _, ip := range ips {
if ip.Is4() || ip.Is4In6() {
v4 = append(v4, ip)
} else {
v6 = append(v6, ip)
}
}
return v4, v6
}
// egDivergent reports whether the probes disagreed about our public address
// *within* an address family.
//
// A plain dual-stack host answers with one IPv4 and one IPv6 address, which is
// two distinct entries in UniqueIPs and entirely healthy. Treating that as
// disagreement would flag every dual-stack machine as proxied and bury the
// real signal — two different IPv4 addresses — in the noise.
func egDivergent(ips []netip.Addr) bool {
v4, v6 := egSplitFamilies(ips)
return len(v4) > 1 || len(v6) > 1
}
// 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.
func egFinish(rep *EgressReport) {
v4, v6 := egSplitFamilies(rep.UniqueIPs)
switch {
case len(rep.UniqueIPs) == 0:
rep.Status = StatusFail
case rep.Divergent:
rep.Status = StatusWarn
default:
rep.Status = StatusOK
}
var b strings.Builder
switch {
case len(rep.UniqueIPs) == 0:
b.WriteString("未能取得任何出口 IP:所有探测都失败了")
case rep.Divergent:
// Name the family that actually diverged, so a dual-stack host with a
// split IPv4 path does not read as "everything is inconsistent".
var parts []string
if len(v4) > 1 {
parts = append(parts, fmt.Sprintf("IPv4 有 %d 个(%s", len(v4), egJoinAddrs(v4, 4)))
}
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, ""))
default:
var parts []string
if len(v4) == 1 {
parts = append(parts, "IPv4 "+v4[0].String())
}
if len(v6) == 1 {
parts = append(parts, "IPv6 "+v6[0].String())
}
fmt.Fprintf(&b, "出口 IP 唯一:%s", strings.Join(parts, ""))
}
if len(rep.Countries) > 0 {
fmt.Fprintf(&b, ",归属地 %s", strings.Join(rep.Countries, "、"))
}
rep.Summary = b.String()
}
// egJoinAddrs renders at most limit addresses for a summary line.
func egJoinAddrs(as []netip.Addr, limit int) string {
parts := make([]string, 0, limit+1)
for i, a := range as {
if i >= limit {
parts = append(parts, fmt.Sprintf("等 %d 个", len(as)))
break
}
parts = append(parts, a.String())
}
return strings.Join(parts, "、")
}