Files
tslink/core/tsdiag.go
T
2026-07-26 09:39:17 +00:00

226 lines
6.3 KiB
Go

package core
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"time"
"tailscale.com/net/netcheck"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tslink/netdiag"
)
// TsDiagSource adapts a running tsnet server to [netdiag.TailscaleSource].
//
// The diagnostics package probes the network from scratch; this asks tailscale
// what it already believes. The two disagreeing is itself informative — for
// DERP latency and tailscale's own UPnP/PMP/PCP probe, tailscale's answer is
// the one that governs how the tunnel will actually behave.
type TsDiagSource struct {
srv *tsnet.Server
logger *slog.Logger
}
// NewTailscaleSource wraps srv. A nil logger falls back to slog.Default.
func NewTailscaleSource(srv *tsnet.Server, logger *slog.Logger) *TsDiagSource {
if logger == nil {
logger = slog.Default()
}
return &TsDiagSource{srv: srv, logger: logger}
}
// DefaultTailscaleSource returns a source for srv, or a nil interface when srv
// is nil, so callers can pass the result straight into netdiag.Options without
// tripping over a typed-nil interface.
func DefaultTailscaleSource(srv *tsnet.Server, logger *slog.Logger) netdiag.TailscaleSource {
if srv == nil {
return nil
}
return NewTailscaleSource(srv, logger)
}
// netcheckTimeout bounds one report. netcheck's own full run probes every DERP
// region, which takes a while on a slow link.
const netcheckTimeout = 15 * time.Second
// Netcheck runs tailscale's own network check and translates the result.
func (s *TsDiagSource) Netcheck(ctx context.Context) (rep *netdiag.TailscaleReport, err error) {
if s == nil || s.srv == nil {
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Summary: "Tailscale 未运行",
}, errors.New("tsnet server is nil")
}
lc, err := s.srv.LocalClient()
if err != nil {
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Err: err.Error(),
}, err
}
dm, err := lc.CurrentDERPMap(ctx)
if err != nil || dm == nil {
if err == nil {
err = errors.New("no DERP map available")
}
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Err: err.Error(),
Summary: "无法获取 DERP 列表,跳过 Tailscale 内部检查",
}, err
}
// A static monitor takes a one-shot snapshot of the interfaces without
// spawning the change-watching goroutines a long-lived Monitor would. That
// is what we want for a single report, and Close on a static monitor is a
// no-op.
mon := netmon.NewStatic()
client := &netcheck.Client{
NetMon: mon,
Logf: func(format string, args ...any) {
s.logger.With(slog.String("from", "netcheck")).
Debug(fmt.Sprintf(format, args...))
},
}
runCtx, cancel := context.WithTimeout(ctx, netcheckTimeout)
defer cancel()
// GetReport reaches into internal magicsock machinery; a panic there must
// degrade this one panel, not take the window down.
var raw *netcheck.Report
func() {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("netcheck panicked: %v", r)
}
}()
raw, err = client.GetReport(runCtx, dm, &netcheck.GetReportOpts{})
}()
if err != nil || raw == nil {
if err == nil {
err = errors.New("netcheck returned no report")
}
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Err: err.Error(),
}, err
}
return convertNetcheck(raw, dm), nil
}
// convertNetcheck maps tailscale's report onto the diagnostics contract.
func convertNetcheck(raw *netcheck.Report, dm *tailcfg.DERPMap) *netdiag.TailscaleReport {
out := &netdiag.TailscaleReport{
Available: true,
UDP: raw.UDP,
IPv4: raw.IPv4,
IPv6: raw.IPv6,
ICMPv4: raw.ICMPv4,
OSHasIPv6: raw.OSHasIPv6,
MappingVariesByDestIP: optBool(raw.MappingVariesByDestIP.Get()),
UPnP: optBool(raw.UPnP.Get()),
PMP: optBool(raw.PMP.Get()),
PCP: optBool(raw.PCP.Get()),
CaptivePortal: optBool(raw.CaptivePortal.Get()),
}
if raw.GlobalV4.IsValid() {
out.GlobalV4 = raw.GlobalV4.String()
}
if raw.GlobalV6.IsValid() {
out.GlobalV6 = raw.GlobalV6.String()
}
for id, latency := range raw.RegionLatency {
entry := netdiag.DERPLatency{
RegionID: id,
Latency: latency,
Preferred: id == raw.PreferredDERP,
}
if dm != nil {
if region, ok := dm.Regions[id]; ok && region != nil {
entry.RegionCode = region.RegionCode
entry.Name = region.RegionName
}
}
if entry.RegionCode == "" {
entry.RegionCode = strconv.Itoa(id)
}
if entry.Name == "" {
entry.Name = entry.RegionCode
}
if entry.Preferred {
out.PreferredDERP = entry.RegionCode
}
out.DERP = append(out.DERP, entry)
}
sort.Slice(out.DERP, func(i, j int) bool {
if out.DERP[i].Latency != out.DERP[j].Latency {
return out.DERP[i].Latency < out.DERP[j].Latency
}
return out.DERP[i].RegionID < out.DERP[j].RegionID
})
if out.PreferredDERP == "" && raw.PreferredDERP != 0 {
out.PreferredDERP = strconv.Itoa(raw.PreferredDERP)
}
out.Status, out.Summary = netcheckVerdict(out)
return out
}
// netcheckVerdict grades the report from the perspective of whether tailscale
// can carry traffic well, not whether every box is ticked.
func netcheckVerdict(r *netdiag.TailscaleReport) (netdiag.Status, string) {
switch {
case !r.UDP:
return netdiag.StatusFail,
"Tailscale 无法通过 UDP 与 DERP 通信,连接将非常不稳定"
case r.CaptivePortal != nil && *r.CaptivePortal:
return netdiag.StatusWarn,
"检测到门户劫持(Captive Portal),需要先在浏览器完成网络认证"
case len(r.DERP) == 0:
return netdiag.StatusWarn,
"没有任何 DERP 节点响应,中继回退可能不可用"
}
best := r.DERP[0]
summary := fmt.Sprintf("首选 DERP %s,延迟 %dms",
nonEmpty(r.PreferredDERP, best.RegionCode),
best.Latency.Milliseconds())
if r.MappingVariesByDestIP != nil && *r.MappingVariesByDestIP {
return netdiag.StatusWarn,
summary + ";NAT 映射随目标变化(对称型),直连打洞成功率低"
}
return netdiag.StatusOK, summary
}
func nonEmpty(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
// optBool converts tailscale's opt.Bool (value, ok) pair into a tri-state
// pointer: nil means tailscale could not determine the answer, which is
// different from determining "no".
func optBool(v, ok bool) *bool {
if !ok {
return nil
}
out := v
return &out
}