add: gui and tsdiag
This commit is contained in:
@@ -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, "、")
|
||||
}
|
||||
Reference in New Issue
Block a user