add: gui and tsdiag
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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