226 lines
6.3 KiB
Go
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
|
|
}
|