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
+252
View File
@@ -0,0 +1,252 @@
package core
import (
"context"
"log/slog"
"net/netip"
"sync"
"testing"
"time"
)
// These tests exist to give `go test -race` something to chew on. The GUI
// reads every one of these structures from its frame loop while background
// goroutines write to them, which is exactly the shape of bug that never
// shows up in a single-threaded test.
func TestLogBufferConcurrentAccess(t *testing.T) {
buf := NewLogBuffer(128) // small, so eviction runs constantly
logger := slog.New(buf.Handler(nil))
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
defer cancel()
var wg sync.WaitGroup
// Writers.
for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
l := logger.With("from", "writer", "id", id)
for ctx.Err() == nil {
l.Info("message", "n", id, "auth_key", "tskey-auth-SECRETVALUE123")
l.Debug("detail", slog.Group("g", slog.String("k", "v")))
}
}(i)
}
// Readers, mimicking the GUI's frame loop.
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
_ = buf.Tail(50)
_ = buf.Filter(LogQuery{MinLevel: slog.LevelInfo, Text: "message", Limit: 20})
_ = buf.Sources()
_ = buf.Counts()
_ = buf.Len()
_ = buf.Dropped()
_ = buf.LastSeq()
}
}()
}
// Subscribers churning in and out.
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
ch, cancelSub := buf.Subscribe()
select {
case <-ch:
case <-time.After(5 * time.Millisecond):
}
cancelSub()
}
}()
// Exporter, which walks the whole ring and redacts.
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
out := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug, Limit: 100}})
if len(out) > 0 && containsSecret(out) {
t.Error("export leaked an auth key")
return
}
time.Sleep(time.Millisecond)
}
}()
wg.Wait()
if buf.Len() > 128 {
t.Fatalf("ring exceeded its capacity: %d", buf.Len())
}
if buf.Dropped() == 0 {
t.Fatal("expected eviction to have occurred")
}
}
func containsSecret(s string) bool {
return len(s) > 0 && (indexOf(s, "SECRETVALUE123") >= 0)
}
func indexOf(hay, needle string) int {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return i
}
}
return -1
}
func TestLogBufferTailOrderAndBounds(t *testing.T) {
buf := NewLogBuffer(4)
logger := slog.New(buf.Handler(nil))
for i := 0; i < 10; i++ {
logger.Info("m", "i", i)
}
got := buf.Tail(3)
if len(got) != 3 {
t.Fatalf("Tail(3) returned %d entries", len(got))
}
// Oldest first, and the newest must be last.
for i := 1; i < len(got); i++ {
if got[i].Seq <= got[i-1].Seq {
t.Fatalf("Tail is not in chronological order: %v", got)
}
}
if got[len(got)-1].Seq != buf.LastSeq() {
t.Fatalf("Tail did not end at the newest record")
}
if n := len(buf.Tail(100)); n != 4 {
t.Fatalf("Tail beyond capacity returned %d, want 4", n)
}
if n := len(buf.Tail(0)); n != 0 {
t.Fatalf("Tail(0) returned %d entries", n)
}
}
func TestLogBufferRedactsOnExport(t *testing.T) {
buf := NewLogBuffer(16)
logger := slog.New(buf.Handler(nil))
logger.Info("joining", "auth_key", "tskey-auth-kSomeRealLookingKey123")
logger.Info("inline", "url", "https://x/?k=tskey-client-abcdefghijkl")
out := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug}})
if indexOf(out, "kSomeRealLookingKey123") >= 0 {
t.Error("attribute-named secret survived redaction")
}
if indexOf(out, "abcdefghijkl") >= 0 {
t.Error("inline tskey survived redaction")
}
raw := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug}, NoRedact: true})
if indexOf(raw, "kSomeRealLookingKey123") < 0 {
t.Error("NoRedact should preserve the original text")
}
}
func TestLanScannerConcurrentAccess(t *testing.T) {
s := NewLanScanner(slog.New(slog.DiscardHandler))
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
s.Start(ctx)
var wg sync.WaitGroup
// Feed announcements the way the read loops do.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ctx.Err() == nil; i++ {
src := netip.AddrPortFrom(netip.MustParseAddr("192.168.1.50"), uint16(40000+i%3))
s.handle(src, "[MOTD]§aTest §bServer[/MOTD][AD]25565[/AD]")
s.handle(src, "malformed packet")
}
}()
// Read like the GUI does.
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
for _, srv := range s.Servers() {
_ = srv.Motd
_ = srv.Addr
}
_ = s.Err()
}
}()
}
// Reconfigure while it runs.
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
s.SetSelfEntries([]LanEntry{{Motd: "Test Server", Port: 25565}})
time.Sleep(time.Millisecond)
s.SetSelfEntries(nil)
}
}()
wg.Wait()
servers := s.Servers()
if len(servers) == 0 {
t.Fatal("expected the synthetic announcements to be recorded")
}
for _, srv := range servers {
if srv.Port != 25565 {
t.Errorf("unexpected port %d", srv.Port)
}
// Colour codes must be stripped.
if indexOf(srv.Motd, "§") >= 0 {
t.Errorf("colour codes survived: %q", srv.Motd)
}
if srv.Motd != "Test Server" {
t.Errorf("motd = %q, want %q", srv.Motd, "Test Server")
}
}
}
func TestPeerMonitorSnapshotIsIsolated(t *testing.T) {
m := NewPeerMonitor(nil, nil, slog.New(slog.DiscardHandler), PeerMonitorOptions{})
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
// A nil server must not panic; the monitor should degrade to an invalid
// snapshot with an error rather than taking the GUI down.
m.Start(ctx)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
snap := m.Snapshot()
for _, p := range snap.Peers {
_ = p.DisplayName
_ = len(p.Samples)
}
_ = m.History("nonexistent")
m.RefreshNow()
}
}()
}
wg.Wait()
snap := m.Snapshot()
if snap.Valid {
t.Error("snapshot from a nil tsnet server should not be valid")
}
}
+7 -2
View File
@@ -75,7 +75,9 @@ func LanDiscoverService(ctx context.Context, entryList []LanEntry, logger *slog.
}
}
func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) {
// LanEntriesFromRules collects the advertisements implied by the connect
// rules. The GUI uses it to tell our own broadcasts apart from other servers'.
func LanEntriesFromRules(rules map[string][]ConnectRule) []LanEntry {
var lanEntries []LanEntry
for tag, rs := range rules {
for _, rule := range rs {
@@ -89,6 +91,9 @@ func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule,
})
}
}
return lanEntries
}
go LanDiscoverService(ctx, lanEntries, logger)
func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) {
go LanDiscoverService(ctx, LanEntriesFromRules(rules), logger)
}
+539
View File
@@ -0,0 +1,539 @@
package core
import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Minecraft's LAN discovery protocol: servers multicast the ASCII payload
// "[MOTD]<motd>[/MOTD][AD]<port>[/AD]" to these groups roughly every 1.5s.
// core/lan.go sends them; this file listens for them.
const (
lanScanGroupV4 = "224.0.2.60:4445"
lanScanGroupV6 = "[ff75:230::60]:4445"
)
const (
// lanScanExpiry drops a server that stopped broadcasting.
lanScanExpiry = 30 * time.Second
// lanScanStale is how long a server may go unheard before the next packet
// from it is treated as a real change worth waking the UI for. Without it
// the GUI would redraw on every duplicate broadcast.
lanScanStale = 10 * time.Second
// lanScanSweep is the expiry tick interval.
lanScanSweep = 5 * time.Second
// lanScanBuf is the per-read buffer size; LAN announcements are tiny.
lanScanBuf = 2048
// lanScanMotdRunes caps a stored MOTD so a hostile peer cannot bloat the UI.
lanScanMotdRunes = 120
)
// LanServer is one Minecraft server seen broadcasting on the local network.
type LanServer struct {
Motd string // MOTD with Minecraft section-sign colour codes stripped
RawMotd string // as received
Port int //
Source netip.AddrPort // who sent the packet
Addr netip.Addr // Source.Addr(), the address to actually connect to
FirstSeen time.Time
LastSeen time.Time
Count int // packets seen
IsSelf bool // matches one of the entries tslink is advertising
}
// lanScanKey deduplicates by sender address and advertised port. The sender's
// ephemeral source port is deliberately excluded: it changes per socket.
type lanScanKey struct {
addr netip.Addr
port int
}
// LanScanner watches for Minecraft LAN broadcasts on every multicast-capable
// interface and keeps a deduplicated, self-expiring view of what it heard.
//
// All methods are safe for concurrent use; the GUI calls [LanScanner.Servers]
// from its frame loop while the read goroutines are writing.
type LanScanner struct {
logger *slog.Logger
mu sync.RWMutex
servers map[lanScanKey]*LanServer
self []LanEntry
lastErr string
subs map[int]chan struct{}
nextSub int
started bool
// live counts read loops still running. A VPN or virtual adapter going
// down kills its socket's loop; when the last one dies the scanner is
// deaf, and Err() has to say so instead of continuing to report health.
live int
}
// NewLanScanner returns a scanner that has not started listening yet. A nil
// logger falls back to slog.Default.
func NewLanScanner(logger *slog.Logger) *LanScanner {
if logger == nil {
logger = slog.Default()
}
return &LanScanner{
logger: logger.With(slog.String("from", "lanscan")),
servers: make(map[lanScanKey]*LanServer),
subs: make(map[int]chan struct{}),
}
}
// SetSelfEntries tells the scanner which advertisements are our own, so the UI
// can distinguish "the tunnel is working" from "someone else is hosting". It
// may be called after Start and re-evaluates already-known servers.
func (s *LanScanner) SetSelfEntries(entries []LanEntry) {
cp := make([]LanEntry, len(entries))
copy(cp, entries)
s.mu.Lock()
s.self = cp
changed := false
for _, srv := range s.servers {
self := matchesSelf(cp, srv.RawMotd, srv.Port)
if self != srv.IsSelf {
srv.IsSelf = self
changed = true
}
}
if changed {
s.notifyLocked()
}
s.mu.Unlock()
}
// Start begins listening; it returns immediately and stops when ctx is done.
// Calling it twice is a no-op.
func (s *LanScanner) Start(ctx context.Context) {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return
}
s.started = true
s.mu.Unlock()
conns := s.listen()
if len(conns) == 0 {
s.mu.Lock()
s.lastErr = "no multicast listener could be created"
// Clear the guard so a caller that notices Err() can retry once the
// network stack is up. Binding can fail simply because Start ran
// before the interfaces existed, and a permanently dead scanner is a
// worse outcome than a redundant retry.
s.started = false
s.mu.Unlock()
s.logger.Warn("lan scan disabled, all multicast binds failed")
return
}
s.logger.With(slog.Int("sockets", len(conns))).Debug("lan scan listening")
// One closer goroutine unblocks every read at once on cancellation.
go func() {
<-ctx.Done()
for _, c := range conns {
_ = c.Close()
}
}()
s.mu.Lock()
s.live = len(conns)
s.mu.Unlock()
var wg sync.WaitGroup
for _, c := range conns {
wg.Add(1)
go func(c *net.UDPConn) {
defer wg.Done()
defer s.readerExited(ctx)
s.readLoop(ctx, c)
}(c)
}
go s.sweepLoop(ctx)
go func() {
wg.Wait()
s.logger.Debug("lan scan stopped")
}()
}
// listen joins the IPv4 group on every up, multicast-capable interface plus a
// nil-interface fallback, then does the same for IPv6. Per-interface failures
// are expected (containers, down VPN adapters) and only logged at debug level.
func (s *LanScanner) listen() []*net.UDPConn {
var conns []*net.UDPConn
v4, err := net.ResolveUDPAddr("udp4", lanScanGroupV4)
if err != nil {
s.logger.With(slog.String("error", err.Error())).Error("failed to resolve ipv4 multicast group")
}
v6, err := net.ResolveUDPAddr("udp6", lanScanGroupV6)
if err != nil {
s.logger.With(slog.String("error", err.Error())).Debug("failed to resolve ipv6 multicast group")
}
ifaces, err := net.Interfaces()
if err != nil {
s.logger.With(slog.String("error", err.Error())).Warn("failed to enumerate interfaces, falling back to default")
ifaces = nil
}
for i := range ifaces {
ifi := ifaces[i]
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 {
continue
}
if v4 != nil {
if c, err := net.ListenMulticastUDP("udp4", &ifi, v4); err == nil {
conns = append(conns, c)
} else {
s.logger.With(
slog.String("iface", ifi.Name),
slog.String("error", err.Error()),
).Debug("ipv4 multicast join failed")
}
}
if v6 != nil {
if c, err := net.ListenMulticastUDP("udp6", &ifi, v6); err == nil {
conns = append(conns, c)
} else {
s.logger.With(
slog.String("iface", ifi.Name),
slog.String("error", err.Error()),
).Debug("ipv6 multicast join failed")
}
}
}
// Fallback: let the OS pick the interface. On some hosts this is the only
// socket that ever receives anything.
if v4 != nil {
if c, err := net.ListenMulticastUDP("udp4", nil, v4); err == nil {
conns = append(conns, c)
} else {
s.logger.With(slog.String("error", err.Error())).Debug("default ipv4 multicast join failed")
}
}
if v6 != nil {
if c, err := net.ListenMulticastUDP("udp6", nil, v6); err == nil {
conns = append(conns, c)
} else {
s.logger.With(slog.String("error", err.Error())).Debug("default ipv6 multicast join failed")
}
}
for _, c := range conns {
_ = c.SetReadBuffer(64 * 1024)
}
return conns
}
// readLoop drains one socket until ctx is done or the socket is closed. A
// malformed packet is logged at debug level and never terminates the loop.
func (s *LanScanner) readLoop(ctx context.Context, c *net.UDPConn) {
buf := make([]byte, lanScanBuf)
for {
if ctx.Err() != nil {
return
}
// A deadline guarantees the loop notices cancellation even if the
// closer goroutine has not run yet.
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
n, src, err := c.ReadFromUDP(buf)
if err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
continue
}
if errors.Is(err, net.ErrClosed) {
return
}
// Anything else (ENETDOWN from an adapter disappearing, for
// instance) means this socket is finished. Release it here rather
// than leaving the fd until the process exits; the ctx closer
// goroutine would otherwise be the only thing that ever closes it.
s.logger.With(slog.String("error", err.Error())).Debug("lan scan read failed")
_ = c.Close()
return
}
if n <= 0 || src == nil {
continue
}
ap, ok := netip.AddrFromSlice(src.IP)
if !ok {
continue
}
s.handle(netip.AddrPortFrom(ap.Unmap(), uint16(src.Port)), string(buf[:n]))
}
}
// readerExited records that one read loop finished. Once every socket is gone
// while the scanner is still meant to be running, Err() must report it — the
// UI otherwise shows a healthy "listening" chip over a scanner that will never
// hear another packet.
func (s *LanScanner) readerExited(ctx context.Context) {
s.mu.Lock()
if s.live > 0 {
s.live--
}
dead := s.live == 0 && ctx.Err() == nil
if dead {
s.lastErr = "all multicast listeners stopped, restart to rescan"
s.started = false
s.notifyLocked()
}
s.mu.Unlock()
if dead {
s.logger.Warn("lan scan has no live listeners left")
}
}
// sweepLoop expires servers that stopped broadcasting.
func (s *LanScanner) sweepLoop(ctx context.Context) {
t := time.NewTicker(lanScanSweep)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.expire(time.Now())
}
}
}
func (s *LanScanner) expire(now time.Time) {
s.mu.Lock()
changed := false
for k, srv := range s.servers {
if now.Sub(srv.LastSeen) > lanScanExpiry {
delete(s.servers, k)
changed = true
s.logger.With(
slog.String("addr", srv.Addr.String()),
slog.Int("port", srv.Port),
).Debug("lan server expired")
}
}
if changed {
s.notifyLocked()
}
s.mu.Unlock()
}
// handle records one parsed announcement.
func (s *LanScanner) handle(src netip.AddrPort, payload string) {
rawMotd, port, ok := parseLanAnnouncement(payload)
if !ok {
s.logger.With(
slog.String("src", src.String()),
slog.Int("len", len(payload)),
).Debug("ignoring malformed lan announcement")
return
}
now := time.Now()
key := lanScanKey{addr: src.Addr(), port: port}
s.mu.Lock()
defer s.mu.Unlock()
self := matchesSelf(s.self, rawMotd, port)
if srv, ok := s.servers[key]; ok {
// A repeat. Only wake the UI when something it renders actually moved.
changed := srv.IsSelf != self || srv.RawMotd != rawMotd ||
now.Sub(srv.LastSeen) > lanScanStale
srv.LastSeen = now
srv.Count++
srv.RawMotd = rawMotd
srv.Motd = cleanLanMotd(rawMotd)
srv.IsSelf = self
srv.Source = src
if changed {
s.notifyLocked()
}
return
}
s.servers[key] = &LanServer{
Motd: cleanLanMotd(rawMotd),
RawMotd: rawMotd,
Port: port,
Source: src,
Addr: src.Addr(),
FirstSeen: now,
LastSeen: now,
Count: 1,
IsSelf: self,
}
s.logger.With(
slog.String("addr", src.Addr().String()),
slog.Int("port", port),
slog.Bool("self", self),
).Debug("new lan server")
s.notifyLocked()
}
// Servers returns the currently-known servers, freshest first, safe to call
// from the UI. The result is a copy: LanServer holds no reference types, so
// the caller may read it without holding any lock.
func (s *LanScanner) Servers() []LanServer {
s.mu.RLock()
out := make([]LanServer, 0, len(s.servers))
for _, srv := range s.servers {
out = append(out, *srv)
}
s.mu.RUnlock()
// Deterministic ordering keeps the GUI from jittering between refreshes:
// our own advertisements sink to the bottom, then freshest first.
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.IsSelf != b.IsSelf {
return !a.IsSelf
}
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
if a.Port != b.Port {
return a.Port < b.Port
}
return a.Source.String() < b.Source.String()
})
return out
}
// Err returns the last listener error, if the scanner could not bind at all.
// It is empty while the scanner is healthy.
func (s *LanScanner) Err() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.lastErr
}
// Subscribe returns a channel that receives a value whenever the server set
// meaningfully changes, plus a function that cancels the subscription. The
// channel is buffered and coalescing: a slow reader sees one wakeup, not a
// backlog of duplicate broadcasts.
func (s *LanScanner) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
s.mu.Lock()
id := s.nextSub
s.nextSub++
s.subs[id] = ch
s.mu.Unlock()
var once sync.Once
cancel := func() {
once.Do(func() {
s.mu.Lock()
delete(s.subs, id)
s.mu.Unlock()
})
}
return ch, cancel
}
// notifyLocked wakes every subscriber. The caller must hold s.mu.
func (s *LanScanner) notifyLocked() {
for _, ch := range s.subs {
select {
case ch <- struct{}{}:
default: // subscriber has a pending wakeup already
}
}
}
// ---------------------------------------------------------------------------
// parsing
// ---------------------------------------------------------------------------
// parseLanAnnouncement extracts the MOTD and port from a Minecraft LAN
// broadcast. It is strict: anything not shaped exactly like
// "[MOTD]…[/MOTD][AD]<1..65535>[/AD]" is rejected.
func parseLanAnnouncement(payload string) (motd string, port int, ok bool) {
motd, ok = between(payload, "[MOTD]", "[/MOTD]")
if !ok {
return "", 0, false
}
ad, ok := between(payload, "[AD]", "[/AD]")
if !ok {
return "", 0, false
}
port, err := strconv.Atoi(strings.TrimSpace(ad))
if err != nil || !validPort(port) {
return "", 0, false
}
return motd, port, true
}
// between returns the text enclosed by the first open tag and the first close
// tag that follows it.
func between(s, openTag, closeTag string) (string, bool) {
i := strings.Index(s, openTag)
if i < 0 {
return "", false
}
rest := s[i+len(openTag):]
j := strings.Index(rest, closeTag)
if j < 0 {
return "", false
}
return rest[:j], true
}
// cleanLanMotd strips Minecraft section-sign colour codes, trims whitespace and
// caps the result so an oversized announcement cannot distort the UI.
func cleanLanMotd(raw string) string {
var b strings.Builder
b.Grow(len(raw))
skip := false
for _, r := range raw {
if skip {
// Drop the single formatting character following the section sign.
skip = false
continue
}
if r == '§' {
skip = true
continue
}
b.WriteRune(r)
}
out := strings.TrimSpace(b.String())
n := 0
for i := range out {
n++
if n > lanScanMotdRunes {
return out[:i]
}
}
return out
}
// matchesSelf reports whether an announcement corresponds to one of our own
// advertised entries. Comparison uses the raw MOTD, which is exactly what
// core/lan.go puts on the wire.
func matchesSelf(self []LanEntry, rawMotd string, port int) bool {
for _, e := range self {
if e.Port == port && e.Motd == rawMotd {
return true
}
}
return false
}
+523
View File
@@ -0,0 +1,523 @@
package core
import (
"context"
"fmt"
"log/slog"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// LogAttr is one flattened structured field. Groups are folded into the key
// with dots so the GUI can render a single flat line per entry.
type LogAttr struct {
Key string
Value string
}
// LogEntry is a single captured log record.
type LogEntry struct {
Seq uint64
Time time.Time
Level slog.Level
Msg string
Attrs []LogAttr
// Source is the value of the conventional "from" attribute, used by the
// GUI to group logs by subsystem.
Source string
}
// Text renders the entry the way the console handler would, minus colour.
func (e LogEntry) Text() string {
var b strings.Builder
b.WriteString(e.Time.Format("2006-01-02 15:04:05.000"))
b.WriteByte(' ')
b.WriteString(levelLabel(e.Level))
b.WriteByte(' ')
b.WriteString(e.Msg)
for _, a := range e.Attrs {
b.WriteByte(' ')
b.WriteString(a.Key)
b.WriteByte('=')
if strings.ContainsAny(a.Value, " \t\"") {
fmt.Fprintf(&b, "%q", a.Value)
} else {
b.WriteString(a.Value)
}
}
return b.String()
}
func levelLabel(l slog.Level) string {
switch {
case l < slog.LevelInfo:
return "DBG"
case l < slog.LevelWarn:
return "INF"
case l < slog.LevelError:
return "WRN"
default:
return "ERR"
}
}
// LevelLabel exposes the three-letter level name used in exports and the GUI.
func LevelLabel(l slog.Level) string { return levelLabel(l) }
// LogQuery filters a buffer snapshot.
type LogQuery struct {
// MinLevel drops anything below it.
MinLevel slog.Level
// Text is a case-insensitive substring matched against the message, the
// attribute values and the source.
Text string
// Source, when set, keeps only entries from that subsystem.
Source string
// Limit keeps only the newest N matches. Zero means unlimited.
Limit int
}
func (q LogQuery) match(e LogEntry) bool {
if e.Level < q.MinLevel {
return false
}
if q.Source != "" && e.Source != q.Source {
return false
}
if q.Text == "" {
return true
}
needle := strings.ToLower(q.Text)
if strings.Contains(strings.ToLower(e.Msg), needle) {
return true
}
if strings.Contains(strings.ToLower(e.Source), needle) {
return true
}
for _, a := range e.Attrs {
if strings.Contains(strings.ToLower(a.Key), needle) ||
strings.Contains(strings.ToLower(a.Value), needle) {
return true
}
}
return false
}
// LogBuffer is a fixed-capacity ring of the most recent log records. It is the
// single source of truth for the GUI's log view and for diagnostic exports.
//
// All methods are safe for concurrent use.
type LogBuffer struct {
mu sync.RWMutex
entries []LogEntry // ring storage, len == cap once full
start int // index of the oldest entry
count int
nextSeq uint64
dropped uint64
subs map[int]chan struct{}
nextSub int
sources map[string]int
levelCnt map[slog.Level]int
}
// DefaultLogCapacity is how many records the GUI keeps in memory. At roughly
// 200 bytes per record this is a few megabytes at most.
const DefaultLogCapacity = 20000
// NewLogBuffer returns a buffer holding at most capacity records.
func NewLogBuffer(capacity int) *LogBuffer {
if capacity <= 0 {
capacity = DefaultLogCapacity
}
return &LogBuffer{
entries: make([]LogEntry, capacity),
subs: make(map[int]chan struct{}),
sources: make(map[string]int),
levelCnt: make(map[slog.Level]int),
}
}
// Add appends an entry, evicting the oldest record when full.
func (b *LogBuffer) Add(e LogEntry) {
b.mu.Lock()
b.nextSeq++
e.Seq = b.nextSeq
capacity := len(b.entries)
if b.count == capacity {
evicted := b.entries[b.start]
b.decStatsLocked(evicted)
b.entries[b.start] = e
b.start = (b.start + 1) % capacity
b.dropped++
} else {
b.entries[(b.start+b.count)%capacity] = e
b.count++
}
b.incStatsLocked(e)
for _, ch := range b.subs {
select {
case ch <- struct{}{}:
default: // subscriber has a pending wakeup already
}
}
b.mu.Unlock()
}
func (b *LogBuffer) incStatsLocked(e LogEntry) {
b.levelCnt[e.Level]++
if e.Source != "" {
b.sources[e.Source]++
}
}
func (b *LogBuffer) decStatsLocked(e LogEntry) {
b.levelCnt[e.Level]--
if b.levelCnt[e.Level] <= 0 {
delete(b.levelCnt, e.Level)
}
if e.Source != "" {
b.sources[e.Source]--
if b.sources[e.Source] <= 0 {
delete(b.sources, e.Source)
}
}
}
// Len returns the number of buffered records.
func (b *LogBuffer) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.count
}
// Dropped returns how many records were evicted because the ring was full.
func (b *LogBuffer) Dropped() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.dropped
}
// LastSeq returns the sequence number of the most recent record.
func (b *LogBuffer) LastSeq() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.nextSeq
}
// Counts returns how many buffered records exist per level.
func (b *LogBuffer) Counts() map[slog.Level]int {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[slog.Level]int, len(b.levelCnt))
for k, v := range b.levelCnt {
out[k] = v
}
return out
}
// Sources returns the distinct subsystem names currently buffered, sorted.
func (b *LogBuffer) Sources() []string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]string, 0, len(b.sources))
for k := range b.sources {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Snapshot returns every buffered record, oldest first.
func (b *LogBuffer) Snapshot() []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
return b.collectLocked(func(LogEntry) bool { return true }, 0)
}
// Tail returns the newest n records, oldest first.
//
// It walks backwards from the newest record so the cost is O(n), not O(ring).
// The GUI's log overlay calls this on every frame; scanning a full 20k-entry
// ring each time was enough on its own to keep a core busy.
func (b *LogBuffer) Tail(n int) []LogEntry {
if n <= 0 {
return nil
}
b.mu.RLock()
defer b.mu.RUnlock()
return b.newestLocked(func(LogEntry) bool { return true }, n)
}
// Filter returns the records matching q, oldest first.
func (b *LogBuffer) Filter(q LogQuery) []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
if q.Limit > 0 {
return b.newestLocked(q.match, q.Limit)
}
return b.collectLocked(q.match, 0)
}
// newestLocked walks the ring newest-first, keeping at most limit matches, and
// returns them oldest-first.
func (b *LogBuffer) newestLocked(keep func(LogEntry) bool, limit int) []LogEntry {
capacity := len(b.entries)
out := make([]LogEntry, 0, min(limit, b.count))
for i := b.count - 1; i >= 0 && len(out) < limit; i-- {
e := b.entries[(b.start+i)%capacity]
if keep(e) {
out = append(out, e)
}
}
// Reverse in place to restore chronological order.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// collectLocked walks the ring oldest-first. When limit > 0 only the newest
// limit matches are kept.
func (b *LogBuffer) collectLocked(keep func(LogEntry) bool, limit int) []LogEntry {
capacity := len(b.entries)
out := make([]LogEntry, 0, min(b.count, 512))
for i := 0; i < b.count; i++ {
e := b.entries[(b.start+i)%capacity]
if keep(e) {
out = append(out, e)
}
}
if limit > 0 && len(out) > limit {
out = out[len(out)-limit:]
}
return out
}
// Subscribe returns a channel that receives a value whenever a record is
// added, plus a function that cancels the subscription. The channel is
// buffered and coalescing: a slow reader sees one wakeup, not a backlog.
func (b *LogBuffer) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
b.mu.Lock()
id := b.nextSub
b.nextSub++
b.subs[id] = ch
b.mu.Unlock()
var once sync.Once
cancel := func() {
once.Do(func() {
b.mu.Lock()
delete(b.subs, id)
b.mu.Unlock()
})
}
return ch, cancel
}
// ---------------------------------------------------------------------------
// slog handler
// ---------------------------------------------------------------------------
// bufHandler tees records into a LogBuffer and on to a wrapped handler.
type bufHandler struct {
buf *LogBuffer
next slog.Handler
attrs []LogAttr
groups []string
}
// Handler returns a slog.Handler that records everything into b and forwards
// to next. next may be nil, in which case records are only buffered.
//
// The buffer always captures at debug level regardless of what next filters,
// so the GUI can show detail the console suppressed.
func (b *LogBuffer) Handler(next slog.Handler) slog.Handler {
return &bufHandler{buf: b, next: next}
}
func (h *bufHandler) Enabled(ctx context.Context, l slog.Level) bool {
// Always capture: the buffer is the diagnostic record of last resort.
return true
}
func (h *bufHandler) Handle(ctx context.Context, r slog.Record) error {
attrs := make([]LogAttr, 0, len(h.attrs)+r.NumAttrs())
attrs = append(attrs, h.attrs...)
r.Attrs(func(a slog.Attr) bool {
attrs = appendAttr(attrs, h.groups, a)
return true
})
source := ""
for _, a := range attrs {
if a.Key == "from" {
source = a.Value
}
}
t := r.Time
if t.IsZero() {
t = time.Now()
}
h.buf.Add(LogEntry{
Time: t,
Level: r.Level,
Msg: r.Message,
Attrs: attrs,
Source: source,
})
if h.next != nil && h.next.Enabled(ctx, r.Level) {
return h.next.Handle(ctx, r)
}
return nil
}
func (h *bufHandler) WithAttrs(as []slog.Attr) slog.Handler {
if len(as) == 0 {
return h
}
clone := *h
clone.attrs = make([]LogAttr, len(h.attrs), len(h.attrs)+len(as))
copy(clone.attrs, h.attrs)
for _, a := range as {
clone.attrs = appendAttr(clone.attrs, h.groups, a)
}
if h.next != nil {
clone.next = h.next.WithAttrs(as)
}
return &clone
}
func (h *bufHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
clone := *h
clone.groups = append(append([]string(nil), h.groups...), name)
if h.next != nil {
clone.next = h.next.WithGroup(name)
}
return &clone
}
// appendAttr flattens a slog.Attr, expanding groups into dotted keys.
func appendAttr(dst []LogAttr, groups []string, a slog.Attr) []LogAttr {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return dst
}
if a.Value.Kind() == slog.KindGroup {
sub := a.Value.Group()
if len(sub) == 0 {
return dst
}
nested := groups
if a.Key != "" {
nested = append(append([]string(nil), groups...), a.Key)
}
for _, s := range sub {
dst = appendAttr(dst, nested, s)
}
return dst
}
key := a.Key
if len(groups) > 0 {
key = strings.Join(groups, ".") + "." + key
}
return append(dst, LogAttr{Key: key, Value: a.Value.String()})
}
// NewLoggerWithBuffer builds the console logger exactly as [NewLogger] does
// and tees every record into buf.
func NewLoggerWithBuffer(level string, useJsonFormat bool, buf *LogBuffer) *slog.Logger {
base := NewLogger(level, useJsonFormat)
logger := slog.New(buf.Handler(base.Handler()))
slog.SetDefault(logger)
return logger
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
// secretPattern matches Tailscale auth keys and OAuth client secrets, which
// are the one thing in these logs that must never reach a paste service.
var secretPattern = regexp.MustCompile(`\b(tskey-[a-zA-Z]+-)[A-Za-z0-9\-_]{6,}`)
// secretKeys are attribute names whose values are replaced wholesale.
var secretKeys = map[string]bool{
"auth_key": true,
"authkey": true,
"auth-key": true,
"token": true,
"secret": true,
"password": true,
"client_secret": true,
}
// Redact removes credentials from a single string.
func Redact(s string) string {
return secretPattern.ReplaceAllString(s, "${1}REDACTED")
}
func redactAttr(a LogAttr) LogAttr {
if secretKeys[strings.ToLower(a.Key)] {
if a.Value == "" {
return a
}
return LogAttr{Key: a.Key, Value: "[REDACTED]"}
}
a.Value = Redact(a.Value)
return a
}
// ExportOptions controls how a log dump is rendered.
type ExportOptions struct {
Query LogQuery
// Redact strips credentials. Callers sharing logs publicly must leave this
// on; it defaults to on because [ExportText] is built for sharing.
NoRedact bool
// Header is prepended verbatim, used for environment metadata.
Header string
}
// ExportText renders matching entries as a plain-text report suitable for
// pasting into an issue tracker or a paste service.
func (b *LogBuffer) ExportText(opt ExportOptions) string {
entries := b.Filter(opt.Query)
var sb strings.Builder
if opt.Header != "" {
sb.WriteString(opt.Header)
if !strings.HasSuffix(opt.Header, "\n") {
sb.WriteByte('\n')
}
sb.WriteString("\n")
}
if dropped := b.Dropped(); dropped > 0 {
fmt.Fprintf(&sb, "# %d earlier record(s) were dropped from the ring buffer\n\n", dropped)
}
for _, e := range entries {
if !opt.NoRedact {
e.Msg = Redact(e.Msg)
redacted := make([]LogAttr, len(e.Attrs))
for i, a := range e.Attrs {
redacted[i] = redactAttr(a)
}
e.Attrs = redacted
}
sb.WriteString(e.Text())
sb.WriteByte('\n')
}
if len(entries) == 0 {
sb.WriteString("(no matching log entries)\n")
}
return sb.String()
}
+858
View File
@@ -0,0 +1,858 @@
package core
import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
"sort"
"strings"
"sync"
"time"
"tailscale.com/client/local"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
)
// PeerRoute is how traffic currently reaches a peer.
type PeerRoute string
const (
RouteDirect PeerRoute = "direct"
RouteDERP PeerRoute = "derp"
RoutePeerRelay PeerRoute = "peer-relay"
RouteOffline PeerRoute = "offline"
RouteUnknown PeerRoute = "unknown"
)
// PeerSample is one latency measurement.
type PeerSample struct {
At time.Time
Latency time.Duration
OK bool
Route PeerRoute
}
// PeerInfo is everything the GUI shows about one node.
type PeerInfo struct {
ID, HostName, DNSName, DisplayName, OS string
TailscaleIPs []netip.Addr
Online, Active, ExitNode bool
CurAddr, Relay string
Route PeerRoute
RxBytes, TxBytes int64
Created, LastSeen, LastWrite, LastHandshake time.Time
// Linked reports that a config rule points at this peer; those are the nodes
// the user actually cares about and the GUI lists them first.
Linked bool
LinkTags []string
LastLatency time.Duration
LatencyOK bool
Samples []PeerSample // chronological, oldest first
AvgLatency time.Duration
MinLatency time.Duration
MaxLatency time.Duration
JitterMs float64 // mean absolute successive difference
LossPct float64
}
// clone returns a deep copy of p so callers cannot reach into monitor state.
func (p PeerInfo) clone() PeerInfo {
out := p
out.TailscaleIPs = append([]netip.Addr(nil), p.TailscaleIPs...)
out.LinkTags = append([]string(nil), p.LinkTags...)
out.Samples = append([]PeerSample(nil), p.Samples...)
return out
}
// PeerSnapshot is a consistent view of the tailnet at one instant.
type PeerSnapshot struct {
At time.Time
Valid bool
Self PeerInfo
Peers []PeerInfo
TailnetName string
BackendState string
MagicDNSSuffix string
Err string
}
// clone returns a deep copy of s, including every peer's slices.
func (s PeerSnapshot) clone() PeerSnapshot {
out := s
out.Self = s.Self.clone()
out.Peers = make([]PeerInfo, len(s.Peers))
for i, p := range s.Peers {
out.Peers[i] = p.clone()
}
return out
}
// PeerMonitorOptions tunes the two polling loops and the history depth.
type PeerMonitorOptions struct {
// StatusInterval defaults to 3s, PingInterval to 10s, HistorySize to 120 samples.
StatusInterval, PingInterval time.Duration
HistorySize int
}
const (
defaultStatusInterval = 3 * time.Second
defaultPingInterval = 10 * time.Second
defaultHistorySize = 120
// pingTimeout bounds a single peer ping. A hung probe must never stall the
// sweep, and the sweep must never outlive its own interval by much.
pingTimeout = 5 * time.Second
// pingConcurrency bounds in-flight pings so a large tailnet cannot spawn
// hundreds of goroutines at once.
pingConcurrency = 4
// linkResolveInterval re-resolves config rules, because MagicDNS answers
// change when a peer's address is reassigned.
linkResolveInterval = 5 * time.Minute
// linkResolveTimeout bounds resolution of a single rule destination.
linkResolveTimeout = 10 * time.Second
// statusTimeout bounds one lc.Status call.
statusTimeout = 10 * time.Second
// maxStatusBackoff caps the retry delay after repeated status failures.
maxStatusBackoff = 30 * time.Second
)
func (o PeerMonitorOptions) withDefaults() PeerMonitorOptions {
if o.StatusInterval <= 0 {
o.StatusInterval = defaultStatusInterval
}
if o.PingInterval <= 0 {
o.PingInterval = defaultPingInterval
}
if o.HistorySize <= 0 {
o.HistorySize = defaultHistorySize
}
return o
}
// pingOutcome is the most recent ping result for one peer, used to refine the
// route derivation that the status fields alone can only guess at.
type pingOutcome struct {
ok bool
latency time.Duration
derpRegion string
at time.Time
}
// PeerMonitor keeps a live view of the tailnet for the GUI: a cheap status
// poll, an independent ping sweep, and a capped latency history per peer.
//
// All methods are safe for concurrent use.
type PeerMonitor struct {
srv *tsnet.Server
rules map[string][]ConnectRule
log *slog.Logger
opt PeerMonitorOptions
refreshStatus chan struct{}
refreshPing chan struct{}
mu sync.RWMutex
raw *ipnstate.Status // last good status, nil until the first poll lands
rawErr string
built PeerSnapshot // rebuilt after every poll and sweep
hist map[string][]PeerSample
last map[string]pingOutcome
links map[netip.Addr][]string
subs map[int]chan struct{}
nextSub int
}
// NewPeerMonitor returns a monitor for srv. rules are the configured connect
// rules, used to mark which peers the user actually links to; it may be nil.
// logger may be nil.
func NewPeerMonitor(srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger, opt PeerMonitorOptions) *PeerMonitor {
if logger == nil {
logger = slog.Default()
}
return &PeerMonitor{
srv: srv,
rules: rules,
log: logger.With("from", "peermon"),
opt: opt.withDefaults(),
refreshStatus: make(chan struct{}, 1),
refreshPing: make(chan struct{}, 1),
hist: make(map[string][]PeerSample),
last: make(map[string]pingOutcome),
links: make(map[netip.Addr][]string),
subs: make(map[int]chan struct{}),
}
}
// Start launches the status loop, the ping loop and the link resolver. All of
// them stop when ctx is cancelled. Start does not block.
func (m *PeerMonitor) Start(ctx context.Context) {
go m.statusLoop(ctx)
go m.pingLoop(ctx)
go m.linkLoop(ctx)
}
// RefreshNow triggers an immediate status+ping cycle without blocking the caller.
func (m *PeerMonitor) RefreshNow() {
kick(m.refreshStatus)
kick(m.refreshPing)
}
// kick delivers a coalescing wakeup: a pending signal is enough.
func kick(ch chan struct{}) {
select {
case ch <- struct{}{}:
default:
}
}
// Snapshot returns a consistent, fully copied view of the tailnet. It performs
// no I/O and is safe to call from the render path.
func (m *PeerMonitor) Snapshot() PeerSnapshot {
m.mu.RLock()
defer m.mu.RUnlock()
return m.built.clone()
}
// Subscribe returns a channel that receives a value after every status poll and
// every completed ping sweep, plus a function that cancels the subscription.
// The channel is buffered and coalescing: a slow reader sees one wakeup, not a
// backlog.
func (m *PeerMonitor) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
m.mu.Lock()
id := m.nextSub
m.nextSub++
m.subs[id] = ch
m.mu.Unlock()
var once sync.Once
cancel := func() {
once.Do(func() {
m.mu.Lock()
delete(m.subs, id)
m.mu.Unlock()
})
}
return ch, cancel
}
// History returns the samples for one peer keyed by stable node ID, oldest
// first. The returned slice is a copy.
func (m *PeerMonitor) History(id string) []PeerSample {
m.mu.RLock()
defer m.mu.RUnlock()
return append([]PeerSample(nil), m.hist[id]...)
}
func (m *PeerMonitor) notify() {
m.mu.RLock()
defer m.mu.RUnlock()
for _, ch := range m.subs {
select {
case ch <- struct{}{}:
default: // subscriber has a pending wakeup already
}
}
}
// ---------------------------------------------------------------------------
// status loop
// ---------------------------------------------------------------------------
// statusLoop polls lc.Status on StatusInterval. It never waits on the ping
// sweep, so a slow tailnet cannot freeze the peer list in the GUI.
func (m *PeerMonitor) statusLoop(ctx context.Context) {
timer := time.NewTimer(0)
defer timer.Stop()
var fails int
first := true
for {
select {
case <-ctx.Done():
return
case <-timer.C:
case <-m.refreshStatus:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
err := m.pollStatus(ctx)
if ctx.Err() != nil {
return
}
delay := m.opt.StatusInterval
if err != nil {
fails++
delay = backoffDelay(m.opt.StatusInterval, fails)
m.log.Debug("status poll failed", "err", err, "retry_in", delay)
} else {
fails = 0
if first {
first = false
kick(m.refreshPing) // ping as soon as we know who is out there
}
}
timer.Reset(delay)
}
}
// backoffDelay grows the retry delay exponentially, capped at maxStatusBackoff.
func backoffDelay(base time.Duration, fails int) time.Duration {
d := base
for i := 1; i < fails && d < maxStatusBackoff; i++ {
d *= 2
}
if d > maxStatusBackoff {
d = maxStatusBackoff
}
return d
}
// pollStatus refreshes the cached status. On failure the previous status is
// kept so the GUI degrades to stale data instead of going blank.
func (m *PeerMonitor) pollStatus(ctx context.Context) error {
lc, err := m.localClient()
if err == nil {
var st *ipnstate.Status
st, err = func() (*ipnstate.Status, error) {
cctx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()
return lc.Status(cctx)
}()
if err == nil {
m.mu.Lock()
m.raw = st
m.rawErr = ""
m.rebuildLocked()
m.mu.Unlock()
m.notify()
return nil
}
}
m.mu.Lock()
m.rawErr = err.Error()
m.rebuildLocked()
m.mu.Unlock()
m.notify()
return err
}
func (m *PeerMonitor) localClient() (*local.Client, error) {
if m.srv == nil {
return nil, errors.New("tsnet server not started")
}
return m.srv.LocalClient()
}
// ---------------------------------------------------------------------------
// ping loop
// ---------------------------------------------------------------------------
// pingLoop sweeps every online peer on PingInterval. A sweep that overruns its
// interval simply delays the next sweep; the status loop is unaffected.
func (m *PeerMonitor) pingLoop(ctx context.Context) {
timer := time.NewTimer(m.opt.PingInterval)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
case <-m.refreshPing:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
m.pingSweep(ctx)
if ctx.Err() != nil {
return
}
timer.Reset(m.opt.PingInterval)
}
}
// pingTarget is one node to probe in a sweep.
type pingTarget struct {
id string
name string
addr netip.Addr
}
// pingTargets lists the online peers worth probing, taken from the last good
// status. Self is skipped: pinging your own address is not a network test.
func (m *PeerMonitor) pingTargets() []pingTarget {
m.mu.RLock()
defer m.mu.RUnlock()
if m.raw == nil {
return nil
}
var out []pingTarget
for _, ps := range m.raw.Peer {
if ps == nil || !ps.Online {
continue
}
addr := pingAddr(ps.TailscaleIPs)
if !addr.IsValid() {
continue
}
out = append(out, pingTarget{id: peerKey(ps), name: displayName(ps), addr: addr})
}
sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
return out
}
// pingSweep probes every online peer, bounded to pingConcurrency in flight.
func (m *PeerMonitor) pingSweep(ctx context.Context) {
targets := m.pingTargets()
if len(targets) == 0 {
return
}
lc, err := m.localClient()
if err != nil {
m.log.Debug("ping sweep skipped", "err", err)
return
}
sem := make(chan struct{}, pingConcurrency)
var wg sync.WaitGroup
for _, t := range targets {
select {
case <-ctx.Done():
wg.Wait()
return
case sem <- struct{}{}:
}
wg.Add(1)
go func(t pingTarget) {
defer wg.Done()
defer func() { <-sem }()
m.pingOne(ctx, lc, t)
}(t)
}
wg.Wait()
if ctx.Err() != nil {
return
}
m.mu.Lock()
m.rebuildLocked()
m.mu.Unlock()
m.notify()
}
// pingOne probes a single peer and records the outcome. A failure is recorded
// as a sample with OK=false: loss is data.
func (m *PeerMonitor) pingOne(ctx context.Context, lc *local.Client, t pingTarget) {
cctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
res, err := lc.Ping(cctx, t.addr, tailcfg.PingDisco)
now := time.Now()
out := pingOutcome{at: now}
switch {
case err != nil:
if !errors.Is(err, context.Canceled) {
m.log.Debug("peer ping failed", "peer", t.name, "addr", t.addr, "err", err)
}
case res == nil:
m.log.Debug("peer ping returned nothing", "peer", t.name, "addr", t.addr)
case res.Err != "":
m.log.Debug("peer ping error", "peer", t.name, "addr", t.addr, "err", res.Err)
default:
out.ok = true
out.latency = time.Duration(res.LatencySeconds * float64(time.Second))
out.derpRegion = res.DERPRegionCode
}
sample := PeerSample{At: now, Latency: out.latency, OK: out.ok}
if out.ok {
if out.derpRegion == "" {
sample.Route = RouteDirect
} else {
sample.Route = RouteDERP
}
} else {
sample.Route = RouteUnknown
}
m.mu.Lock()
m.last[t.id] = out
m.hist[t.id] = appendSample(m.hist[t.id], sample, m.opt.HistorySize)
m.mu.Unlock()
}
// appendSample pushes s onto a capped ring, dropping the oldest entry when
// full. Chronological order is preserved.
func appendSample(ring []PeerSample, s PeerSample, size int) []PeerSample {
if size <= 0 {
size = defaultHistorySize
}
if len(ring) < size {
return append(ring, s)
}
// Shift left by the overflow so a shrunken HistorySize also converges.
drop := len(ring) - size + 1
copy(ring, ring[drop:])
ring = ring[:size-1]
return append(ring, s)
}
// ---------------------------------------------------------------------------
// link resolution
// ---------------------------------------------------------------------------
// linkLoop resolves every connect rule's destination to a tailnet address once
// at start and again every linkResolveInterval. Resolution touches the network,
// so it never happens on the render path.
func (m *PeerMonitor) linkLoop(ctx context.Context) {
if len(m.rules) == 0 || m.srv == nil {
return
}
ticker := time.NewTicker(linkResolveInterval)
defer ticker.Stop()
m.resolveLinks(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.resolveLinks(ctx)
}
}
}
// resolveLinks maps every rule destination to a peer address, remembering which
// config tags referenced it.
func (m *PeerMonitor) resolveLinks(ctx context.Context) {
found := make(map[netip.Addr]map[string]struct{})
for tag, rules := range m.rules {
for _, rule := range rules {
if ctx.Err() != nil {
return
}
host, _, err := net.SplitHostPort(rule.DstAddr)
if err != nil {
m.log.Debug("link: bad dst_addr", "tag", tag, "dst", rule.DstAddr, "err", err)
continue
}
addr, err := func() (*netip.Addr, error) {
cctx, cancel := context.WithTimeout(ctx, linkResolveTimeout)
defer cancel()
return resolveAddr(cctx, m.srv, host)
}()
if err != nil || addr == nil {
m.log.Debug("link: failed to resolve dst_addr", "tag", tag, "dst", rule.DstAddr, "err", err)
continue
}
if found[*addr] == nil {
found[*addr] = make(map[string]struct{})
}
found[*addr][tag] = struct{}{}
}
}
if ctx.Err() != nil {
return
}
links := make(map[netip.Addr][]string, len(found))
for addr, tags := range found {
list := make([]string, 0, len(tags))
for tag := range tags {
list = append(list, tag)
}
sort.Strings(list)
links[addr] = list
}
m.mu.Lock()
m.links = links
m.rebuildLocked()
m.mu.Unlock()
m.log.Debug("link targets resolved", "count", len(links))
m.notify()
}
// ---------------------------------------------------------------------------
// snapshot assembly
// ---------------------------------------------------------------------------
// rebuildLocked recomputes the cached snapshot from the last good status, the
// latency history and the resolved links. m.mu must be held for writing.
func (m *PeerMonitor) rebuildLocked() {
snap := PeerSnapshot{At: time.Now(), Err: m.rawErr}
st := m.raw
if st == nil {
snap.Valid = false
m.built = snap
return
}
// Stale data is still useful data: Valid stays true once a status landed,
// and Err tells the GUI the view may be out of date.
snap.Valid = true
snap.BackendState = st.BackendState
snap.MagicDNSSuffix = st.MagicDNSSuffix
if st.CurrentTailnet != nil {
snap.TailnetName = st.CurrentTailnet.Name
if st.CurrentTailnet.MagicDNSSuffix != "" {
snap.MagicDNSSuffix = st.CurrentTailnet.MagicDNSSuffix
}
}
live := make(map[string]struct{}, len(st.Peer)+1)
if st.Self != nil {
snap.Self = m.peerInfoLocked(st.Self)
live[snap.Self.ID] = struct{}{}
}
snap.Peers = make([]PeerInfo, 0, len(st.Peer))
for _, ps := range st.Peer {
if ps == nil {
continue
}
info := m.peerInfoLocked(ps)
live[info.ID] = struct{}{}
snap.Peers = append(snap.Peers, info)
}
sortPeers(snap.Peers)
// Forget history for nodes that left the netmap, so a long-running GUI
// session does not grow without bound.
for id := range m.hist {
if _, ok := live[id]; !ok {
delete(m.hist, id)
delete(m.last, id)
}
}
m.built = snap
}
// peerInfoLocked converts one PeerStatus into the GUI's view of it. m.mu must
// be held.
func (m *PeerMonitor) peerInfoLocked(ps *ipnstate.PeerStatus) PeerInfo {
id := peerKey(ps)
info := PeerInfo{
ID: id,
HostName: ps.HostName,
DNSName: strings.TrimSuffix(ps.DNSName, "."),
DisplayName: displayName(ps),
OS: ps.OS,
TailscaleIPs: append([]netip.Addr(nil), ps.TailscaleIPs...),
Online: ps.Online,
Active: ps.Active,
ExitNode: ps.ExitNode,
CurAddr: ps.CurAddr,
Relay: ps.Relay,
RxBytes: ps.RxBytes,
TxBytes: ps.TxBytes,
Created: ps.Created,
LastSeen: ps.LastSeen,
LastWrite: ps.LastWrite,
LastHandshake: ps.LastHandshake,
}
for _, ip := range ps.TailscaleIPs {
tags, ok := m.links[ip]
if !ok {
continue
}
info.Linked = true
info.LinkTags = mergeTags(info.LinkTags, tags)
}
last, hasPing := m.last[id]
info.Route = deriveRoute(ps, last, hasPing)
if hasPing {
info.LatencyOK = last.ok
if last.ok {
info.LastLatency = last.latency
}
}
samples := m.hist[id]
info.Samples = append([]PeerSample(nil), samples...)
summariseSamples(&info)
return info
}
// deriveRoute decides how traffic reaches the peer. Status fields give the
// baseline; a successful ping is authoritative because it reports the path the
// packet actually took.
func deriveRoute(ps *ipnstate.PeerStatus, last pingOutcome, hasPing bool) PeerRoute {
if hasPing && last.ok {
if last.derpRegion != "" {
return RouteDERP
}
if ps.PeerRelay != "" {
return RoutePeerRelay
}
return RouteDirect
}
switch {
case ps.PeerRelay != "":
return RoutePeerRelay
case ps.CurAddr != "":
return RouteDirect
case ps.Relay != "":
return RouteDERP
case !ps.Online:
return RouteOffline
default:
return RouteUnknown
}
}
// summariseSamples fills the aggregate latency fields. Averages, minimum,
// maximum and jitter consider successful samples only; loss covers the whole
// window.
func summariseSamples(info *PeerInfo) {
if len(info.Samples) == 0 {
return
}
var (
sum time.Duration
ok int
fails int
lo, hi time.Duration
prev time.Duration
havePrev bool
diffSum float64
diffs int
)
for _, s := range info.Samples {
if !s.OK {
fails++
continue
}
ok++
sum += s.Latency
if ok == 1 || s.Latency < lo {
lo = s.Latency
}
if ok == 1 || s.Latency > hi {
hi = s.Latency
}
if havePrev {
d := float64(s.Latency-prev) / float64(time.Millisecond)
if d < 0 {
d = -d
}
diffSum += d
diffs++
}
prev = s.Latency
havePrev = true
}
info.LossPct = float64(fails) / float64(len(info.Samples)) * 100
if ok == 0 {
return
}
info.AvgLatency = sum / time.Duration(ok)
info.MinLatency = lo
info.MaxLatency = hi
if diffs > 0 {
info.JitterMs = diffSum / float64(diffs)
}
}
// sortPeers orders the list the way the GUI renders it: linked nodes first,
// then online before offline, then by display name. The final tiebreak on ID
// keeps the order stable across refreshes.
func sortPeers(peers []PeerInfo) {
sort.Slice(peers, func(i, j int) bool {
a, b := peers[i], peers[j]
if a.Linked != b.Linked {
return a.Linked
}
if a.Online != b.Online {
return a.Online
}
if an, bn := strings.ToLower(a.DisplayName), strings.ToLower(b.DisplayName); an != bn {
return an < bn
}
return a.ID < b.ID
})
}
// peerKey is the stable identity used to key history. It falls back to the DNS
// name and then the first address for nodes without a stable ID.
func peerKey(ps *ipnstate.PeerStatus) string {
if id := string(ps.ID); id != "" {
return id
}
if dns := strings.TrimSuffix(ps.DNSName, "."); dns != "" {
return dns
}
if len(ps.TailscaleIPs) > 0 {
return ps.TailscaleIPs[0].String()
}
return ps.HostName
}
// displayName prefers the first label of the MagicDNS name, which is what the
// user typed in the config, then the reported hostname, then an address.
func displayName(ps *ipnstate.PeerStatus) string {
if dns := strings.TrimSuffix(ps.DNSName, "."); dns != "" {
if label, _, ok := strings.Cut(dns, "."); ok && label != "" {
return label
}
return dns
}
if ps.HostName != "" {
return ps.HostName
}
if len(ps.TailscaleIPs) > 0 {
return ps.TailscaleIPs[0].String()
}
return string(ps.ID)
}
// pingAddr picks the address to probe, preferring IPv4 because that is what
// MagicDNS hands out for tailnet peers.
func pingAddr(ips []netip.Addr) netip.Addr {
var v6 netip.Addr
for _, ip := range ips {
if ip.Is4() {
return ip
}
if !v6.IsValid() {
v6 = ip
}
}
return v6
}
// mergeTags appends the tags missing from dst, keeping the result sorted and
// free of duplicates.
func mergeTags(dst, extra []string) []string {
for _, t := range extra {
i := sort.SearchStrings(dst, t)
if i < len(dst) && dst[i] == t {
continue
}
dst = append(dst, "")
copy(dst[i+1:], dst[i:])
dst[i] = t
}
return dst
}
+469
View File
@@ -0,0 +1,469 @@
package core
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"tailscale.com/tsnet"
)
// Phase is the coarse lifecycle state of the service, shown as the header
// status pill in the GUI.
type Phase int
const (
PhaseIdle Phase = iota
PhaseStarting
PhaseReady
PhaseRetrying
PhaseError
PhaseStopped
)
func (p Phase) String() string {
switch p {
case PhaseStarting:
return "starting"
case PhaseReady:
return "ready"
case PhaseRetrying:
return "retrying"
case PhaseError:
return "error"
case PhaseStopped:
return "stopped"
default:
return "idle"
}
}
// StepState is the state of one boot step.
type StepState int
const (
StepPending StepState = iota
StepRunning
StepDone
StepFailed
StepSkipped
)
// Boot step keys. The GUI maps these onto localised titles.
const (
StepKeyConfig = "config"
StepKeyTsnet = "tsnet"
StepKeyRules = "rules"
StepKeyServices = "services"
StepKeyMonitors = "monitors"
StepKeyReady = "ready"
)
// BootStep is one entry in the startup checklist.
type BootStep struct {
Key string
State StepState
Err string
Started time.Time
Finished time.Time
}
// Elapsed is how long the step took, or how long it has been running.
func (s BootStep) Elapsed() time.Duration {
if s.Started.IsZero() {
return 0
}
if s.Finished.IsZero() {
return time.Since(s.Started)
}
return s.Finished.Sub(s.Started)
}
// State is an immutable snapshot of the supervisor, safe to read from the UI
// goroutine.
type State struct {
Phase Phase
Steps []BootStep
Err string
StartedAt time.Time
ReadyAt time.Time
Restarts int
// NextRetryAt is set while Phase is PhaseRetrying.
NextRetryAt time.Time
Config *Config
Server *tsnet.Server
Peers *PeerMonitor
Lan *LanScanner
}
// Ready reports whether the service finished booting.
func (s State) Ready() bool { return s.Phase == PhaseReady }
// Progress is the fraction of boot steps completed, for the splash bar.
func (s State) Progress() float32 {
if len(s.Steps) == 0 {
return 0
}
done := 0
for _, st := range s.Steps {
if st.State == StepDone || st.State == StepSkipped {
done++
}
}
return float32(done) / float32(len(s.Steps))
}
// SupervisorOptions configures a Supervisor.
type SupervisorOptions struct {
ConfigPath string
ConfigURL string
TsnetDebug bool
Logger *slog.Logger
// MaxBackoff caps the retry delay. Zero means 30s.
MaxBackoff time.Duration
}
// Supervisor owns the service lifecycle for the GUI. It is the same startup
// sequence the headless binary runs in serviceLogic, split into observable
// steps and wrapped in a restart loop that keeps the window alive when
// tailscale is unreachable — a CLI can exit on failure, a GUI must explain
// itself instead.
type Supervisor struct {
opt SupervisorOptions
logger *slog.Logger
mu sync.RWMutex
state State
subsMu sync.Mutex
subs map[int]chan struct{}
nextSub int
restartCh chan struct{}
stopOnce sync.Once
}
// NewSupervisor creates an unstarted supervisor.
func NewSupervisor(opt SupervisorOptions) *Supervisor {
logger := opt.Logger
if logger == nil {
logger = slog.Default()
}
if opt.MaxBackoff <= 0 {
opt.MaxBackoff = 30 * time.Second
}
return &Supervisor{
opt: opt,
logger: logger.With("from", "supervisor"),
subs: make(map[int]chan struct{}),
restartCh: make(chan struct{}, 1),
state: State{
Phase: PhaseIdle,
Steps: freshSteps(),
},
}
}
func freshSteps() []BootStep {
keys := []string{
StepKeyConfig, StepKeyTsnet, StepKeyRules,
StepKeyServices, StepKeyMonitors, StepKeyReady,
}
steps := make([]BootStep, len(keys))
for i, k := range keys {
steps[i] = BootStep{Key: k}
}
return steps
}
// Snapshot returns the current state.
func (s *Supervisor) Snapshot() State {
s.mu.RLock()
defer s.mu.RUnlock()
st := s.state
st.Steps = append([]BootStep(nil), s.state.Steps...)
return st
}
// Subscribe returns a coalescing wakeup channel and a cancel func.
func (s *Supervisor) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
s.subsMu.Lock()
id := s.nextSub
s.nextSub++
s.subs[id] = ch
s.subsMu.Unlock()
var once sync.Once
return ch, func() {
once.Do(func() {
s.subsMu.Lock()
delete(s.subs, id)
s.subsMu.Unlock()
})
}
}
func (s *Supervisor) notify() {
s.subsMu.Lock()
for _, ch := range s.subs {
select {
case ch <- struct{}{}:
default:
}
}
s.subsMu.Unlock()
}
func (s *Supervisor) update(f func(*State)) {
s.mu.Lock()
f(&s.state)
s.mu.Unlock()
s.notify()
}
func (s *Supervisor) stepStart(key string) {
s.update(func(st *State) {
for i := range st.Steps {
if st.Steps[i].Key == key {
st.Steps[i].State = StepRunning
st.Steps[i].Started = time.Now()
st.Steps[i].Err = ""
return
}
}
})
}
func (s *Supervisor) stepDone(key string, err error) {
s.update(func(st *State) {
for i := range st.Steps {
if st.Steps[i].Key != key {
continue
}
st.Steps[i].Finished = time.Now()
if err != nil {
st.Steps[i].State = StepFailed
st.Steps[i].Err = err.Error()
} else {
st.Steps[i].State = StepDone
}
return
}
})
}
// Restart asks the supervisor to tear down and boot again. It never blocks.
func (s *Supervisor) Restart() {
select {
case s.restartCh <- struct{}{}:
default:
}
}
// Run drives the boot-and-supervise loop until ctx is cancelled. It blocks, so
// callers run it on their own goroutine.
func (s *Supervisor) Run(ctx context.Context) {
backoff := time.Second
for {
if ctx.Err() != nil {
s.update(func(st *State) { st.Phase = PhaseStopped })
return
}
runCtx, cancel := context.WithCancel(ctx)
err := s.boot(runCtx)
if err == nil {
backoff = time.Second
// Supervise until something asks us to restart.
reason := s.supervise(runCtx)
cancel()
s.teardown()
if ctx.Err() != nil {
s.update(func(st *State) { st.Phase = PhaseStopped })
return
}
s.logger.Warn("restarting service", "reason", reason)
s.update(func(st *State) {
st.Phase = PhaseRetrying
st.Restarts++
st.Steps = freshSteps()
st.NextRetryAt = time.Now().Add(time.Second)
})
select {
case <-ctx.Done():
case <-time.After(time.Second):
}
continue
}
cancel()
s.teardown()
if ctx.Err() != nil {
s.update(func(st *State) { st.Phase = PhaseStopped })
return
}
// Configuration errors will not fix themselves; surface them and wait
// for an explicit Restart rather than looping on a broken file.
if errors.Is(err, errFatalConfig) {
// Log it as well as showing it: the on-screen log sheet is the
// thing users screenshot, and a bare error panel with an empty log
// tells whoever is helping them nothing.
s.logger.Error("configuration error, waiting for retry", "err", err)
s.update(func(st *State) {
st.Phase = PhaseError
st.Err = err.Error()
})
select {
case <-ctx.Done():
s.update(func(st *State) { st.Phase = PhaseStopped })
return
case <-s.restartCh:
s.update(func(st *State) {
st.Phase = PhaseStarting
st.Err = ""
st.Steps = freshSteps()
})
continue
}
}
s.logger.Warn("startup failed, retrying", "err", err, "backoff", backoff)
s.update(func(st *State) {
st.Phase = PhaseRetrying
st.Err = err.Error()
st.Restarts++
st.NextRetryAt = time.Now().Add(backoff)
})
select {
case <-ctx.Done():
s.update(func(st *State) { st.Phase = PhaseStopped })
return
case <-s.restartCh:
case <-time.After(backoff):
}
backoff *= 2
if backoff > s.opt.MaxBackoff {
backoff = s.opt.MaxBackoff
}
s.update(func(st *State) { st.Steps = freshSteps() })
}
}
// errFatalConfig marks an error that retrying cannot fix.
var errFatalConfig = errors.New("configuration error")
// boot runs the startup sequence, reporting each step.
func (s *Supervisor) boot(ctx context.Context) error {
s.update(func(st *State) {
st.Phase = PhaseStarting
st.Err = ""
st.StartedAt = time.Now()
st.ReadyAt = time.Time{}
st.NextRetryAt = time.Time{}
})
// --- config -----------------------------------------------------------
s.stepStart(StepKeyConfig)
source := s.opt.ConfigPath
if s.opt.ConfigURL != "" {
source = s.opt.ConfigURL
s.logger.Info("using config url", "url", s.opt.ConfigURL)
}
cfg, err := LoadConfig(source)
if err != nil {
s.logger.Error("failed to load config", "source", source, "err", err)
s.stepDone(StepKeyConfig, err)
return errors.Join(errFatalConfig, err)
}
SetDoHServers(cfg.DNS.DoHServers)
if len(cfg.DNS.DoHServers) > 0 {
s.logger.Info("dns-over-https fallback enabled", "servers", cfg.DNS.DoHServers)
}
s.update(func(st *State) { st.Config = cfg })
s.stepDone(StepKeyConfig, nil)
// --- tsnet ------------------------------------------------------------
s.stepStart(StepKeyTsnet)
srv, err := InitTsNet(ctx, &cfg.Core, s.logger, s.opt.TsnetDebug)
if err != nil {
s.stepDone(StepKeyTsnet, err)
return err
}
s.update(func(st *State) { st.Server = srv })
s.stepDone(StepKeyTsnet, nil)
// --- rules ------------------------------------------------------------
s.stepStart(StepKeyRules)
NormalizeConnectRulesDstAddr(ctx, srv, cfg.Connect, s.logger)
s.stepDone(StepKeyRules, nil)
// --- services ---------------------------------------------------------
s.stepStart(StepKeyServices)
StartForwarders(ctx, srv, cfg.Forward)
StartConnectors(ctx, srv, cfg.Connect)
RunLanDiscoverService(ctx, cfg.Connect, s.logger.With("from", "lan_service"))
s.stepDone(StepKeyServices, nil)
// --- monitors ---------------------------------------------------------
s.stepStart(StepKeyMonitors)
peers := NewPeerMonitor(srv, cfg.Connect, s.logger, PeerMonitorOptions{})
peers.Start(ctx)
lan := NewLanScanner(s.logger.With("from", "lan_scan"))
lan.SetSelfEntries(LanEntriesFromRules(cfg.Connect))
lan.Start(ctx)
s.update(func(st *State) {
st.Peers = peers
st.Lan = lan
})
s.stepDone(StepKeyMonitors, nil)
// --- ready ------------------------------------------------------------
s.stepStart(StepKeyReady)
s.stepDone(StepKeyReady, nil)
s.update(func(st *State) {
st.Phase = PhaseReady
st.ReadyAt = time.Now()
st.Err = ""
})
s.logger.Info("service ready", "took", time.Since(s.Snapshot().StartedAt).Round(time.Millisecond))
return nil
}
// supervise blocks until the service should be restarted, returning why.
func (s *Supervisor) supervise(ctx context.Context) string {
watchdog := StartTimeWatchDog(ctx, s.logger.With("from", "watchdog"))
for {
select {
case <-ctx.Done():
return "context cancelled"
case <-watchdog:
return "system time jump"
case <-s.restartCh:
return "requested by user"
}
}
}
// teardown closes the tsnet server and clears the per-run state.
func (s *Supervisor) teardown() {
s.mu.Lock()
srv := s.state.Server
s.state.Server = nil
s.state.Peers = nil
s.state.Lan = nil
s.mu.Unlock()
if srv != nil {
if err := srv.Close(); err != nil {
s.logger.Debug("closing tsnet server", "err", err)
}
}
s.notify()
}
+225
View File
@@ -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
}