add: gui and tsdiag
This commit is contained in:
+225
@@ -0,0 +1,225 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user