Compare commits

..
4 Commits
Author SHA1 Message Date
iceBear67 b24cfcd560 fix: gui 2026-07-26 14:03:51 +00:00
iceBear67 7eb35f82b1 fix gui 2026-07-26 20:44:49 +08:00
iceBear67 ae79082481 Merge remote-tracking branch 'homelab/sfcraft' into sfcraft 2026-07-26 17:41:37 +08:00
iceBear67 ac27db76f9 fix: connectivity check fails on split-DNS destinations at startup
The diagnostics never got the split-DNS fix from 20097a0. That commit
  taught the dial path to resolve through the tailnet's own resolver
  (resolveDialAddr) but left getPeerFromRules on resolveAddr, so the two
  disagreed about how to look a destination up: connecting through a
  split-DNS name worked while the startup check called it unresolvable.

  Both paths now share resolveHostToIP, which honors MagicDNS, split-DNS
  routes and the DoH fallback.

  The check also resolved once at startup and cached the result for the
  process lifetime. tsnet reports Running before the netmap's DNS config
  reaches its resolver, and accept-routes is only applied after Up()
  returns, so a split-DNS name can fail for the first few seconds and
  resolve fine after. That transient failure dropped the peer permanently
  -- and when every rule failed, the goroutine returned and diagnostics
  never ran at all. Peers are re-resolved every round now, with a warm-up
  retry so the first report waits for DNS rather than racing it.

  Destinations outside the tailnet are no longer reported as failures.
  mc.lxns.net resolves fine but belongs to no peer, which is not an error,
  just not something to ping. errNotTailnetPeer separates "cannot resolve"
  from "resolved, not a peer"; only the former is retried or warned about.
  Unresolved rules now log their tag and dst, which the old message
  omitted entirely.

  Also fixed:

    * NormalizeDstAddrWithSuffix passed "host:port" to resolveAddr, so
      every existence check failed on the stray colon. Fixing that made
      the pass wait on cold-start DNS and delayed the listeners by ~5s,
      so it is now bounded by normalizeDNSBudget.

    * Peer lookup matched any AllowedIPs prefix containing the address.
      An exit node advertises 0.0.0.0/0, which contains everything, so a
      tailnet with an exit node picked the wrong peer at random depending
      on map iteration order. Default routes are skipped, the most
      specific route wins, ties break deterministically.

    * peer.AllowedIPs is a nillable pointer, dereferenced unguarded.
2026-07-26 14:52:14 +08:00
32 changed files with 2353 additions and 1580 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
//
// It runs the same service the headless binary does — see the root main.go —
// but supervises it in-process so the window can show tailnet peer health,
// latency history, Minecraft servers announced on the LAN, and a full network
// latency history, the services tslink is forwarding, and a full network
// diagnostic run, plus a searchable log view that can be shared to a paste
// service for support.
//
-67
View File
@@ -3,7 +3,6 @@ package core
import (
"context"
"log/slog"
"net/netip"
"sync"
"testing"
"time"
@@ -152,72 +151,6 @@ func TestLogBufferRedactsOnExport(t *testing.T) {
}
}
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{})
+98 -67
View File
@@ -46,51 +46,113 @@ func GetMagicDNSSuffixFromStatus(st *ipnstate.Status) (string, error) {
return suffix, nil
}
// addr(ip or domain) to tailscale ip
// check the address is in the tailscale network
// errNotTailnetPeer reports that a destination resolved successfully but the
// resulting IP is not carried by any tailnet peer — an ordinary public address.
// It is distinct from a resolution failure: retrying will not change the answer.
var errNotTailnetPeer = errors.New("address is not reachable through a tailnet peer")
// resolveAddr maps a destination host (an IP literal or a domain) to the
// tailnet address of the peer that carries it, so the peer can be pinged for
// connectivity diagnostics. Names are resolved through the same tailnet-aware
// path the dial code uses (see resolveHostToIP), so MagicDNS and split-DNS
// destinations behave identically in both.
func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Addr, error) {
lc, err := srv.LocalClient()
ip, err := netip.ParseAddr(addr)
if err != nil {
return nil, err
ip, err = resolveHostToIP(ctx, srv, addr)
if err != nil {
return nil, err
}
}
stat, err := lc.Status(ctx)
stat, err := getCachedStatus(ctx, srv)
if err != nil {
return nil, err
}
if ip, err := netip.ParseAddr(addr); err == nil {
for _, peer := range stat.Peer {
for _, ipRange := range peer.AllowedIPs.All() {
if ipRange.Contains(ip) {
return &peer.TailscaleIPs[0], nil
}
peer, ok := peerCarryingIP(stat, ip)
if !ok {
return nil, fmt.Errorf("%w: %s (%s)", errNotTailnetPeer, addr, ip)
}
return &peer, nil
}
// peerCarryingIP returns the tailnet address of the peer that ip belongs to,
// either because it is the peer's own address or because the peer advertises a
// route covering it.
func peerCarryingIP(stat *ipnstate.Status, ip netip.Addr) (netip.Addr, bool) {
for _, peer := range stat.Peer {
for _, peerIP := range peer.TailscaleIPs {
if peerIP == ip {
return peer.TailscaleIPs[0], true
}
}
}
// Otherwise the subnet router advertising the most specific route wins.
// Default routes are skipped: an exit node advertises 0.0.0.0/0, which
// contains every address and would otherwise shadow the real owner at
// random, since Go's map iteration order is unspecified. Ties are broken by
// the lowest tailnet address so repeated calls agree with each other.
bestBits := -1
var best netip.Addr
for _, peer := range stat.Peer {
if peer.AllowedIPs == nil || peer.AllowedIPs.IsNil() || len(peer.TailscaleIPs) == 0 {
continue
}
for _, route := range peer.AllowedIPs.All() {
if route.Bits() == 0 || !route.Contains(ip) {
continue
}
candidate := peer.TailscaleIPs[0]
if route.Bits() > bestBits || (route.Bits() == bestBits && candidate.Compare(best) < 0) {
bestBits, best = route.Bits(), candidate
}
}
}
return best, bestBits >= 0
}
// resolveHostToIP resolves a bare hostname to an address using the tailnet's
// own resolver, falling back to DNS-over-HTTPS. Both the dial path and the
// connectivity diagnostics go through here so they share one view of DNS.
//
// A bare single-label name additionally gets the MagicDNS suffix appended so
// short tailnet hostnames still resolve; a name that already contains a dot (an
// FQDN, including split-DNS suffixes) is queried as-is.
func resolveHostToIP(ctx context.Context, srv *tsnet.Server, host string) (netip.Addr, error) {
candidates := []string{host}
if suffix, ok := GetMagicDNSSuffix(); ok && !strings.Contains(host, ".") {
candidates = append(candidates, host+"."+suffix)
}
var lastErr error
if dnsMgr, ok := srv.Sys().DNSManager.GetOK(); ok {
for _, name := range candidates {
ip, err := resolveHostViaResolver(ctx, dnsMgr, name)
if err != nil {
lastErr = err
continue
}
return ip, nil
}
} else {
suffix, ok := GetMagicDNSSuffix()
if ok {
if !strings.HasSuffix(addr, suffix) {
dnsMgr, ok := srv.Sys().DNSManager.GetOK()
if !ok {
return nil, errors.New("DNS manager not available")
}
ipaddr, err := resolveHostViaResolver(ctx, dnsMgr, addr)
if err != nil {
return nil, err
}
return resolveAddr(ctx, srv, ipaddr.String())
}
}
// addr is tailscale domain, resolve it
for _, peer := range stat.Peer {
dnsName := strings.TrimSuffix(peer.DNSName, ".")
if dnsName == addr {
return &peer.TailscaleIPs[0], nil
}
lastErr = errors.New("DNS manager not available")
}
// Fallback: resolve public names via DNS-over-HTTPS when the tailnet
// resolver couldn't (no working system DNS on the host, or a name outside
// the tailnet's split-DNS routes). Only the original host is queried — DoH
// can't resolve tailnet-internal MagicDNS names.
if dohEnabled() {
if ip, derr := resolveHostViaDoH(ctx, host); derr == nil {
return ip, nil
} else {
lastErr = fmt.Errorf("tailnet dns: %v; doh: %w", lastErr, derr)
}
}
return nil, errors.New(fmt.Sprintf("addr '%s' not found in tsnet", addr))
return netip.Addr{}, fmt.Errorf("resolve %q: %w", host, lastErr)
}
// resolveDialAddr resolves the host portion of a "host:port" destination to a
@@ -117,42 +179,11 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin
return addr, nil // already ip:port, nothing to resolve
}
// Names to try, in order. A bare single-label name additionally gets the
// MagicDNS suffix appended so short tailnet hostnames still resolve; a name
// that already contains a dot (an FQDN, including split-DNS suffixes) is
// queried as-is.
candidates := []string{host}
if suffix, ok := GetMagicDNSSuffix(); ok && !strings.Contains(host, ".") {
candidates = append(candidates, host+"."+suffix)
ip, err := resolveHostToIP(ctx, srv, host)
if err != nil {
return addr, err
}
var lastErr error
if dnsMgr, ok := srv.Sys().DNSManager.GetOK(); ok {
for _, name := range candidates {
ip, err := resolveHostViaResolver(ctx, dnsMgr, name)
if err != nil {
lastErr = err
continue
}
return net.JoinHostPort(ip.String(), port), nil
}
} else {
lastErr = errors.New("DNS manager not available")
}
// Fallback: resolve public names via DNS-over-HTTPS when the tailnet
// resolver couldn't (no working system DNS on the host, or a name outside
// the tailnet's split-DNS routes). Only the original host is queried — DoH
// can't resolve tailnet-internal MagicDNS names.
if dohEnabled() {
if ip, derr := resolveHostViaDoH(ctx, host); derr == nil {
return net.JoinHostPort(ip.String(), port), nil
} else {
lastErr = fmt.Errorf("tailnet dns: %v; doh: %w", lastErr, derr)
}
}
return addr, fmt.Errorf("resolve %q: %w", host, lastErr)
return net.JoinHostPort(ip.String(), port), nil
}
// dnsExchange sends a single DNS question and returns the first address answer,
+135
View File
@@ -0,0 +1,135 @@
package core
import (
"net/netip"
"testing"
"tailscale.com/ipn/ipnstate"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
// peerStatus builds a PeerStatus with the given tailnet address and advertised
// routes. Passing no routes leaves AllowedIPs nil, as it is for peers that
// advertise nothing.
func peerStatus(tailIP string, routes ...string) *ipnstate.PeerStatus {
ps := &ipnstate.PeerStatus{
TailscaleIPs: []netip.Addr{netip.MustParseAddr(tailIP)},
}
if len(routes) > 0 {
prefixes := make([]netip.Prefix, 0, len(routes))
for _, r := range routes {
prefixes = append(prefixes, netip.MustParsePrefix(r))
}
s := views.SliceOf(prefixes)
ps.AllowedIPs = &s
}
return ps
}
func statusWithPeers(peers ...*ipnstate.PeerStatus) *ipnstate.Status {
st := &ipnstate.Status{Peer: make(map[key.NodePublic]*ipnstate.PeerStatus, len(peers))}
for _, p := range peers {
st.Peer[key.NewNode().Public()] = p
}
return st
}
func TestPeerCarryingIP(t *testing.T) {
t.Parallel()
tests := []struct {
name string
peers []*ipnstate.PeerStatus
ip string
want string // "" means no peer expected
}{
{
name: "peer's own address",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1", "100.64.0.1/32")},
ip: "100.64.0.1",
want: "100.64.0.1",
},
{
name: "subnet router carries a LAN address",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.2", "100.64.0.2/32", "10.0.0.0/24"),
peerStatus("100.64.0.3", "100.64.0.3/32"),
},
ip: "10.0.0.7",
want: "100.64.0.2",
},
{
// An exit node advertises 0.0.0.0/0, which Contains every address.
// Matching it would pick a peer at random out of map iteration order.
name: "exit node does not shadow the real subnet router",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.9", "0.0.0.0/0", "::/0"),
peerStatus("100.64.0.2", "10.0.0.0/24"),
},
ip: "10.0.0.7",
want: "100.64.0.2",
},
{
name: "most specific route wins",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.4", "10.0.0.0/8"),
peerStatus("100.64.0.5", "10.0.0.0/24"),
},
ip: "10.0.0.7",
want: "100.64.0.5",
},
{
name: "equal routes break the tie deterministically",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.8", "10.0.0.0/24"),
peerStatus("100.64.0.6", "10.0.0.0/24"),
},
ip: "10.0.0.7",
want: "100.64.0.6",
},
{
name: "public address belongs to no peer",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1", "10.0.0.0/24")},
ip: "1.1.1.1",
want: "",
},
{
name: "peer without AllowedIPs is skipped, not dereferenced",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1")},
ip: "10.0.0.7",
want: "",
},
{
name: "exit node alone still does not match",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.9", "0.0.0.0/0")},
ip: "1.1.1.1",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
st := statusWithPeers(tt.peers...)
// Run repeatedly: map iteration order is unspecified, so a result
// that depends on it shows up as a flake here.
for range 20 {
got, ok := peerCarryingIP(st, netip.MustParseAddr(tt.ip))
if tt.want == "" {
if ok {
t.Fatalf("peerCarryingIP() = %v, true; want no match", got)
}
continue
}
if !ok {
t.Fatalf("peerCarryingIP() = _, false; want %s", tt.want)
}
if got.String() != tt.want {
t.Fatalf("peerCarryingIP() = %s, want %s", got, tt.want)
}
}
})
}
}
-539
View File
@@ -1,539 +0,0 @@
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
}
+9
View File
@@ -154,6 +154,7 @@ type PeerMonitor struct {
refreshStatus chan struct{}
refreshPing chan struct{}
refreshLinks chan struct{}
mu sync.RWMutex
raw *ipnstate.Status // last good status, nil until the first poll lands
@@ -180,6 +181,7 @@ func NewPeerMonitor(srv *tsnet.Server, rules map[string][]ConnectRule, logger *s
opt: opt.withDefaults(),
refreshStatus: make(chan struct{}, 1),
refreshPing: make(chan struct{}, 1),
refreshLinks: make(chan struct{}, 1),
hist: make(map[string][]PeerSample),
last: make(map[string]pingOutcome),
links: make(map[netip.Addr][]string),
@@ -196,9 +198,14 @@ func (m *PeerMonitor) Start(ctx context.Context) {
}
// RefreshNow triggers an immediate status+ping cycle without blocking the caller.
//
// Link resolution is kicked too. It normally runs every linkResolveInterval,
// but the GUI now lists only linked peers, so a user staring at an empty page
// after a DNS hiccup has no other way to ask for a retry.
func (m *PeerMonitor) RefreshNow() {
kick(m.refreshStatus)
kick(m.refreshPing)
kick(m.refreshLinks)
}
// kick delivers a coalescing wakeup: a pending signal is enough.
@@ -534,6 +541,8 @@ func (m *PeerMonitor) linkLoop(ctx context.Context) {
return
case <-ticker.C:
m.resolveLinks(ctx)
case <-m.refreshLinks:
m.resolveLinks(ctx)
}
}
}
-7
View File
@@ -96,7 +96,6 @@ type State struct {
Config *Config
Server *tsnet.Server
Peers *PeerMonitor
Lan *LanScanner
}
// Ready reports whether the service finished booting.
@@ -414,13 +413,8 @@ func (s *Supervisor) boot(ctx context.Context) error {
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)
@@ -457,7 +451,6 @@ func (s *Supervisor) teardown() {
srv := s.state.Server
s.state.Server = nil
s.state.Peers = nil
s.state.Lan = nil
s.mu.Unlock()
if srv != nil {
+78 -19
View File
@@ -45,8 +45,16 @@ func StartTimeWatchDog(ctx context.Context, logger *slog.Logger) <-chan struct{}
return ch
}
func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger) ([]netip.Addr, error) {
// getPeerFromRules maps every connect rule's destination onto the tailnet peer
// that carries it. Alongside the peers it reports how many rules could not be
// resolved at all; those are retryable, unlike destinations that resolve to an
// address outside the tailnet (an ordinary public host), which are skipped for
// good. warn selects whether unresolved rules are logged as warnings — during
// startup the tailnet resolver may not have its split-DNS routes yet, so the
// first few rounds stay quiet.
func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger, warn bool) ([]netip.Addr, int) {
peerSet := make(map[netip.Addr]struct{})
unresolved := 0
for tag, rrs := range rules {
for _, rule := range rrs {
@@ -61,7 +69,18 @@ func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][
addr, err := resolveAddr(ctx, srv, ap)
if err != nil {
logger.Warn("failed to resolve address", "err", err)
if errors.Is(err, errNotTailnetPeer) {
logger.Debug("destination is outside the tailnet, skipping diagnostics",
"tag", tag, "dst", rule.DstAddr, "err", err)
continue
}
unresolved++
if warn {
logger.Warn("failed to resolve address", "tag", tag, "dst", rule.DstAddr, "err", err)
} else {
logger.Debug("failed to resolve address (tailnet DNS may still be settling)",
"tag", tag, "dst", rule.DstAddr, "err", err)
}
continue
}
logger.Debug("address found", "dst_addr", rule.DstAddr, "tag", tag, "address", addr)
@@ -73,7 +92,7 @@ func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][
for peer := range peerSet {
result = append(result, peer)
}
return result, nil
return result, unresolved
}
func peerConnectivityLogic(ctx context.Context, lc *local.Client, relativePeers []netip.Addr, logger *slog.Logger) {
@@ -117,16 +136,19 @@ func peerConnectivityLogic(ctx context.Context, lc *local.Client, relativePeers
}
}
func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger, srv *tsnet.Server, rules map[string][]ConnectRule) {
relativePeers, err := getPeerFromRules(ctx, srv, rules, logger)
if err != nil {
return
}
logger.Debug("Peers loaded", "count", len(relativePeers))
const (
// peerDiagInterval is how often connectivity to each peer is re-checked.
peerDiagInterval = 120 * time.Second
// A tsnet server reports Running before the netmap's DNS configuration has
// been programmed into its resolver, and accept-routes is only applied once
// the server is up — so at startup a split-DNS destination can briefly fail
// to resolve even though it resolves fine moments later. Retry a handful of
// times before reporting anything as broken.
peerDiagWarmupTries = 6
peerDiagWarmupDelay = 2 * time.Second
)
if len(relativePeers) == 0 {
return
}
func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger, srv *tsnet.Server, rules map[string][]ConnectRule) {
go func() {
lc, err := srv.LocalClient()
if err != nil {
@@ -134,18 +156,42 @@ func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger,
return
}
ticker := time.NewTicker(120 * time.Second)
// Warm-up: keep retrying while destinations are still unresolvable, and
// only escalate to a warning on the final attempt.
var peers []netip.Addr
for try := 1; ; try++ {
last := try >= peerDiagWarmupTries
var unresolved int
peers, unresolved = getPeerFromRules(ctx, srv, rules, logger, last)
if unresolved == 0 || last {
break
}
logger.Debug("waiting for tailnet DNS before diagnosing peers",
"unresolved", unresolved, "attempt", try)
select {
case <-ctx.Done():
return
case <-time.After(peerDiagWarmupDelay):
}
}
logger.Debug("Peers loaded", "count", len(peers))
ticker := time.NewTicker(peerDiagInterval)
defer ticker.Stop()
peerConnectivityLogic(ctx, lc, relativePeers, logger) // execute now
for {
peerConnectivityLogic(ctx, lc, peers, logger)
select {
case <-ctx.Done():
return
case <-ticker.C:
peerConnectivityLogic(ctx, lc, relativePeers, logger)
}
// Re-resolve every round: destinations that failed at startup
// recover on their own, and split-DNS records may point elsewhere
// than they did two minutes ago.
peers, _ = getPeerFromRules(ctx, srv, rules, logger, true)
}
}()
}
@@ -174,11 +220,13 @@ func NormalizeDstAddrWithSuffix(ctx context.Context, srv *tsnet.Server, dst stri
return dst, false, nil
}
normalized := net.JoinHostPort(host+"."+suffix, port)
qualified := host + "." + suffix
normalized := net.JoinHostPort(qualified, port)
// check domain exists before use
// check domain exists before use. resolveAddr takes a bare host — passing
// the "host:port" form made every lookup here fail on the stray colon.
if strings.Contains(host, ".") {
_, err = resolveAddr(ctx, srv, normalized)
_, err = resolveAddr(ctx, srv, qualified)
if err != nil {
return dst, false, nil
}
@@ -187,7 +235,18 @@ func NormalizeDstAddrWithSuffix(ctx context.Context, srv *tsnet.Server, dst stri
return normalized, true, nil
}
// normalizeDNSBudget caps how long the whole normalization pass may spend
// waiting on DNS. It runs before the connectors start listening, and on a cold
// start the tailnet resolver needs a few seconds before it answers — without a
// bound the listeners would not come up until then. A name that cannot be
// checked in time simply keeps its configured form, which is the same
// conclusion the check reaches for anything that is not a MagicDNS name.
const normalizeDNSBudget = 2 * time.Second
func NormalizeConnectRulesDstAddr(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger) {
ctx, cancel := context.WithTimeout(ctx, normalizeDNSBudget)
defer cancel()
for tag, rrs := range rules {
for i := range rrs {
rule := &rrs[i]
+141 -51
View File
@@ -12,12 +12,12 @@ import (
"gioui.org/app"
"gioui.org/font"
"gioui.org/io/clipboard"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"tslink/core"
@@ -43,7 +43,6 @@ type pageID int
const (
pageOverview pageID = iota
pagePeers
pageLan
pageDiag
pageLogs
pageSettings
@@ -64,23 +63,23 @@ type App struct {
th *Theme
fonts *FontSet
win *app.Window
// win is the window currently on screen. It is replaced when the splash
// hands off to the shell, so background goroutines load it through
// [App.invalidate] rather than capturing a single window.
win atomic.Pointer[app.Window]
nav []navEntry
current pageID
overview *overviewPage
peers *peersPage
lan *lanPage
diag *diagPage
logs *logsPage
settings *settingsPage
splash *splashView
overlay *logOverlay
splash *splashView
overlayBtn widget.Clickable
themeBtn widget.Clickable
themeBtn widget.Clickable
toastMsg string
toastLevel StatusLevel
@@ -121,50 +120,112 @@ func New(opt Options) *App {
a.nav = []navEntry{
{id: pageOverview, label: KNavOverview, icon: IconGrid},
{id: pagePeers, label: KNavPeers, icon: IconNodes},
{id: pageLan, label: KNavLan, icon: IconBroadcast},
{id: pageDiag, label: KNavDiag, icon: IconPulse},
{id: pageLogs, label: KNavLogs, icon: IconList},
{id: pageSettings, label: KNavSettings, icon: IconSliders},
}
a.overview = newOverviewPage()
a.peers = newPeersPage()
a.lan = newLanPage()
a.diag = newDiagPage(a)
a.logs = newLogsPage(a)
a.settings = newSettingsPage(a)
a.splash = newSplashView()
a.overlay = newLogOverlay()
return a
}
// Run opens the window and drives the event loop. It returns when the window
// closes.
// Run shows the GUI and returns when it closes.
//
// It opens two windows in sequence: a compact splash sized to its progress
// checklist during boot, then a full-size shell once the service is ready.
// Each window is created at its final size. Growing a window at runtime — which
// is what an in-place splash-to-shell transition would need — is unreliable
// across compositors (Wayland in particular refuses client-driven resizes on
// some of them), so opening a correctly sized window is the dependable path.
func (a *App) Run(ctx context.Context) error {
w := new(app.Window)
w.Option(
app.Title("tslink"),
app.Size(unit.Dp(1120), unit.Dp(740)),
app.MinSize(unit.Dp(880), unit.Dp(560)),
)
a.win = w
go a.watch(ctx, w)
go a.watch(ctx)
go a.upgradeFonts()
// The splash runs until the service is ready, then closes itself and asks
// the caller to open the shell. Any other exit — the user closing the
// window, or ctx being cancelled — quits.
proceed, err := a.runWindow(ctx, false)
if err != nil || !proceed || ctx.Err() != nil {
return err
}
_, err = a.runWindow(ctx, true)
return err
}
// runWindow creates one window and drives its event loop: the compact splash
// (shell=false) or the full-size shell (shell=true).
//
// It reports proceed=true only for the splash's ready handoff — the service
// came up, so the splash closed itself and the caller should open the shell.
// A window closed by the user or by ctx cancellation returns proceed=false,
// which quits the app.
func (a *App) runWindow(ctx context.Context, shell bool) (proceed bool, err error) {
w := new(app.Window)
if shell {
w.Option(
app.Title("tslink"),
app.Size(shellWindowW, shellWindowH),
app.MinSize(shellMinW, shellMinH),
)
} else {
w.Option(
app.Title("tslink"),
app.Size(splashWindowW, splashWindowH),
app.MinSize(splashMinW, splashMinH),
)
}
a.win.Store(w)
// Ctrl+C at the terminal cancels ctx. Without this the supervisor tears
// down but the window survives — the GUI is the process, so cancelling it
// has to close the window too. Scoped to this window and stopped when the
// loop returns, so it never reaches across the handoff to the next one.
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-ctx.Done():
w.Perform(system.ActionClose)
case <-stop:
}
}()
// handoff records that we closed the splash because the service came up, so
// the resulting DestroyEvent means "open the shell" rather than "quit".
handoff := false
var ops op.Ops
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
return e.Err
return handoff, e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
a.applyFontUpgrade()
a.layout(gtx)
// The splash window always draws the splash, even on the frame
// where the service first reports ready: otherwise the shell would
// flash cramped in the compact window for one frame before handoff.
a.layout(gtx, !shell)
e.Frame(gtx.Ops)
if !shell && a.state().Ready() {
handoff = true
w.Perform(system.ActionClose)
}
}
}
}
// invalidate schedules a repaint of whichever window is currently shown. It is
// a no-op before the first window exists and is safe from any goroutine.
func (a *App) invalidate() {
if w := a.win.Load(); w != nil {
w.Invalidate()
}
}
// upgradeFonts parses the system CJK font off the UI goroutine. The splash
// screen exists partly to cover this: a 20 MB font collection takes long
// enough to parse that doing it inline would stall the first frame.
@@ -183,9 +244,7 @@ func (a *App) upgradeFonts() {
}
select {
case a.fontUpgrade <- faces:
if a.win != nil {
a.win.Invalidate()
}
a.invalidate()
default:
}
}
@@ -203,7 +262,7 @@ func (a *App) applyFontUpgrade() {
// watch coalesces change notifications from every data source into window
// invalidations, capped so a burst of log lines cannot drive the render loop.
func (a *App) watch(ctx context.Context, w *app.Window) {
func (a *App) watch(ctx context.Context) {
var chans []<-chan struct{}
var cancels []func()
defer func() {
@@ -265,7 +324,7 @@ func (a *App) watch(ctx context.Context, w *app.Window) {
case <-throttle.C:
if dirty {
dirty = false
w.Invalidate()
a.invalidate()
}
}
}
@@ -295,21 +354,34 @@ func (a *App) copyToClipboard(gtx C, s string, msg string) {
a.notify(msg, LevelOK)
}
// reveal shows path in the platform file manager, off the UI goroutine so a
// slow or missing file manager cannot stall a frame. Failure is logged rather
// than surfaced: the file is already written and its path is already on screen,
// so there is nothing for the user to act on.
func (a *App) reveal(path string) {
go func() {
if err := RevealInFileManager(path, a.logger); err != nil {
a.logger.Warn("could not open the file manager", "path", path, "err", err)
}
}()
}
// notify shows a transient message at the bottom of the window.
func (a *App) notify(msg string, level StatusLevel) {
a.toastMsg = msg
a.toastLevel = level
a.toastUntil = time.Now().Add(3200 * time.Millisecond)
if a.win != nil {
a.win.Invalidate()
}
a.invalidate()
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
func (a *App) layout(gtx C) D {
// layout draws one frame. forceSplash keeps the splash on screen even once the
// service is ready, which the compact splash window uses so the shell never
// flashes cramped in it before the handoff to the full-size window.
func (a *App) layout(gtx C, forceSplash bool) D {
th := a.th
paint.Fill(gtx.Ops, th.P.Bg)
@@ -325,9 +397,6 @@ func (a *App) layout(gtx C) D {
a.current = a.nav[i].id
}
}
if a.overlayBtn.Clicked(gtx) {
a.overlay.visible = !a.overlay.visible
}
if a.themeBtn.Clicked(gtx) {
th.SetDark(!th.Dark)
}
@@ -335,16 +404,12 @@ func (a *App) layout(gtx C) D {
return layout.Stack{}.Layout(gtx,
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
if !st.Ready() {
if forceSplash || !st.Ready() {
// The splash owns the whole window until the service is up.
return a.splash.Layout(a, gtx, st)
}
return a.shell(gtx, st)
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
return a.overlay.Layout(a, gtx, !st.Ready())
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
return a.layoutToast(gtx)
@@ -379,8 +444,6 @@ func (a *App) page(gtx C, st core.State) D {
switch a.current {
case pagePeers:
return a.peers.Layout(a, gtx, st)
case pageLan:
return a.lan.Layout(a, gtx, st)
case pageDiag:
return a.diag.Layout(a, gtx, st)
case pageLogs:
@@ -536,14 +599,6 @@ func (a *App) header(gtx C, st core.State) D {
}),
layout.Rigid(func(gtx C) D { return a.statusPill(gtx, st) }),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
icon := IconList
level := LevelNeutral
if a.overlay.visible {
level = LevelInfo
}
return th.IconButton(gtx, &a.overlayBtn, icon, level)
}),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, &a.themeBtn, IconGlobe, LevelNeutral)
}),
@@ -675,3 +730,38 @@ func (a *App) sectionTitle(gtx C, title, subtitle string, trailing layout.Widget
)
})
}
// diagnosticHeader is the metadata block prepended to any exported log bundle,
// so a paste is self-describing without the reporter having to explain their
// setup.
func (a *App) diagnosticHeader() string {
st := a.state()
var b strings.Builder
b.WriteString("# tslink diagnostic bundle\n")
b.WriteString("# version: " + a.opt.Version + "\n")
b.WriteString("# os/arch: " + runtimeInfo() + "\n")
if a.opt.ConfigURL != "" {
b.WriteString("# config: (url)\n")
} else if a.opt.ConfigPath != "" {
b.WriteString("# config: " + a.opt.ConfigPath + "\n")
}
b.WriteString("# phase: " + st.Phase.String() + "\n")
b.WriteString("# restarts: " + itoa(st.Restarts) + "\n")
if !st.ReadyAt.IsZero() {
b.WriteString("# uptime: " + FormatDuration(timeSince(st.ReadyAt)) + "\n")
}
if st.Peers != nil {
snap := st.Peers.Snapshot()
b.WriteString("# tailnet: " + snap.TailnetName + "\n")
b.WriteString("# peers: " + itoa(len(snap.Peers)) + "\n")
}
// reportText takes the diag page's lock; reading a.diag.report directly
// would race the background diagnostic goroutine.
if a.diag != nil {
if txt := a.diag.reportText(); txt != "" {
b.WriteString("#\n")
b.WriteString(txt)
}
}
return b.String()
}
+80 -23
View File
@@ -40,9 +40,12 @@ type ChartSeries struct {
// ChartStyle configures the plot.
type ChartStyle struct {
Height unit.Dp
// Window is how far back the x axis reaches.
Window time.Duration
// Now anchors the right edge.
// MaxWindow caps how far back the x axis reaches. The axis is scaled to the
// data's own extent and only clamped by this, so the plot fills its width
// from the second sample onward instead of leaving the first N minutes of
// the window blank while history accumulates.
MaxWindow time.Duration
// Now is the wall clock, used only as a fallback when there is no data.
Now time.Time
// Unit labels the y axis.
Unit string
@@ -57,18 +60,68 @@ type Chart struct {
hovering bool
// plot is the last plotted rectangle, used to map hover x back to a time.
plot image.Rectangle
// tMin/tMax are the x domain resolved by the last Layout. HoverIndex maps
// the pointer through these rather than recomputing from ChartStyle, so the
// crosshair cannot disagree with the drawn line.
tMin, tMax time.Time
}
// minPlotSpan keeps the axis sane when every visible sample shares a timestamp,
// which happens on the very first frame after a refresh.
const minPlotSpan = 10 * time.Second
// domain resolves the x axis from the visible data, clamped to st.MaxWindow.
func domain(series []ChartSeries, st ChartStyle) (tMin, tMax time.Time) {
now := st.Now
if now.IsZero() {
now = time.Now()
}
window := st.MaxWindow
if window <= 0 {
window = 3 * time.Minute
}
var first, last time.Time
for _, s := range series {
if s.Hidden {
continue
}
for _, p := range s.Points {
if first.IsZero() || p.At.Before(first) {
first = p.At
}
if last.IsZero() || p.At.After(last) {
last = p.At
}
}
}
if first.IsZero() {
return now.Add(-window), now
}
// Never show more than the window, however much history is retained.
if last.Sub(first) > window {
first = last.Add(-window)
}
if last.Sub(first) < minPlotSpan {
first = last.Add(-minPlotSpan)
}
return first, last
}
// HoverIndex returns the sample index the pointer is nearest within s, or -1.
func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
func (c *Chart) HoverIndex(series ChartSeries) int {
if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 0 {
return -1
}
span := c.tMax.Sub(c.tMin)
if span <= 0 {
return -1
}
frac := float64(c.hover.X-float32(c.plot.Min.X)) / float64(c.plot.Dx())
if frac < 0 || frac > 1 {
return -1
}
target := st.Now.Add(-st.Window).Add(time.Duration(frac * float64(st.Window)))
target := c.tMin.Add(time.Duration(frac * float64(span)))
best, bestDelta := -1, time.Duration(math.MaxInt64)
for i, p := range series.Points {
d := p.At.Sub(target)
@@ -81,7 +134,7 @@ func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
}
// Only report a match when the nearest sample is genuinely close, so the
// crosshair does not snap to a distant point in a sparse series.
if bestDelta > st.Window/20 {
if bestDelta > span/20 {
return -1
}
return best
@@ -89,9 +142,6 @@ func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
// Layout draws the chart.
func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D {
if st.Window <= 0 {
st.Window = 20 * time.Minute
}
if st.Now.IsZero() {
st.Now = gtx.Now
}
@@ -114,16 +164,16 @@ func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D {
c.update(gtx, size)
yMax := niceMax(maxVisible(series))
tMin := st.Now.Add(-st.Window)
c.tMin, c.tMax = domain(series, st)
c.drawGrid(t, gtx, plot, yMax, st)
c.drawGrid(t, gtx, plot, yMax, c.tMax.Sub(c.tMin))
for _, s := range series {
if s.Hidden || len(s.Points) == 0 {
continue
}
c.drawSeries(t, gtx, plot, s, tMin, st.Now, yMax, st.FillSingle && visibleCount(series) == 1)
c.drawSeries(t, gtx, plot, s, c.tMin, c.tMax, yMax, st.FillSingle && visibleCount(series) == 1)
}
c.drawCrosshair(t, gtx, plot, series, st, tMin, yMax)
c.drawCrosshair(t, gtx, plot, series, yMax)
return D{Size: size}
}
@@ -199,7 +249,7 @@ func niceMax(v float64) float64 {
}
}
func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st ChartStyle) {
func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, span time.Duration) {
const rows = 4
lineCol := WithAlpha(t.P.Border, 0.9)
for i := 0; i <= rows; i++ {
@@ -226,8 +276,8 @@ func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st
frac float64
txt string
}{
{0, "-" + FormatDuration(st.Window)},
{0.5, "-" + FormatDuration(st.Window/2)},
{0, "-" + FormatDuration(span)},
{0.5, "-" + FormatDuration(span/2)},
{1, "now"},
}
if t.Lang == LangZH {
@@ -354,7 +404,7 @@ func (c *Chart) drawSeries(t *Theme, gtx C, plot image.Rectangle, s ChartSeries,
}
}
func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, st ChartStyle, tMin time.Time, yMax float64) {
func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, yMax float64) {
if !c.hovering {
return
}
@@ -371,11 +421,11 @@ func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []Ch
if s.Hidden {
continue
}
i := c.HoverIndex(s, st)
i := c.HoverIndex(s)
if i < 0 || !s.Points[i].OK {
continue
}
pt := pos(plot, tMin, st.Now, yMax, s.Points[i])
pt := pos(plot, c.tMin, c.tMax, yMax, s.Points[i])
d := gtx.Dp(7)
off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops)
Circle(gtx, d, s.Color)
@@ -402,15 +452,20 @@ type LegendEntry struct {
// Legend renders the chart legend as a wrapping row of toggles. The caller
// supplies a clickable per entry so hiding a noisy peer is one click away.
//
// Wrapping matters here: with the eight series the chart allows, the chips are
// far wider than the card, and a plain Flex would silently clip the trailing
// ones — the peers you could no longer toggle were exactly the ones you could
// no longer identify.
func (t *Theme) Legend(gtx C, entries []LegendEntry, click func(i int) layout.Widget) D {
if len(entries) == 0 {
return D{}
}
children := make([]layout.FlexChild, 0, len(entries))
children := make([]layout.Widget, 0, len(entries))
for i := range entries {
children = append(children, layout.Rigid(click(i)))
children = append(children, click(i))
}
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...)
return WrapRow(gtx, 0, children)
}
// LegendChip draws one legend entry.
@@ -434,7 +489,9 @@ func (t *Theme) LegendChip(gtx C, e LegendEntry, hovered bool) D {
return D{Size: image.Pt(w, h)}
})
}),
layout.Rigid(OneLine(t.Text(SizeCaption, fg, e.Name)).Layout),
// Bounded: peer names can be long, and one runaway chip would push
// every following one onto its own line.
layout.Rigid(OneLine(t.Text(SizeCaption, fg, Truncate(e.Name, 22))).Layout),
layout.Rigid(func(gtx C) D {
if e.Value == "" {
return D{}
+149
View File
@@ -0,0 +1,149 @@
package gui
import (
"image"
"testing"
"time"
"gioui.org/layout"
"tslink/netdiag"
)
// TestChartDomainFillsWithSparseData is the regression for the blank-chart bug:
// a handful of samples used to occupy the left 3% of a fixed 20-minute axis.
// The domain must track the data, not the clock.
func TestChartDomainFillsWithSparseData(t *testing.T) {
now := time.Now()
// 30 seconds of uptime at the 10s ping interval.
pts := []ChartPoint{
{At: now.Add(-20 * time.Second), Value: 10, OK: true},
{At: now.Add(-10 * time.Second), Value: 12, OK: true},
{At: now, Value: 11, OK: true},
}
series := []ChartSeries{{Points: pts}}
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
tMin, tMax := domain(series, st)
if got := tMax.Sub(tMin); got != 20*time.Second {
t.Fatalf("span = %v, want the data's own 20s extent", got)
}
if !tMin.Equal(pts[0].At) || !tMax.Equal(pts[2].At) {
t.Errorf("domain = [%v, %v], want the first and last sample", tMin, tMax)
}
}
func TestChartDomainClampsToWindow(t *testing.T) {
now := time.Now()
series := []ChartSeries{{Points: []ChartPoint{
{At: now.Add(-30 * time.Minute), Value: 10, OK: true},
{At: now, Value: 11, OK: true},
}}}
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
tMin, tMax := domain(series, st)
if got := tMax.Sub(tMin); got != 3*time.Minute {
t.Fatalf("span = %v, want it clamped to MaxWindow", got)
}
}
func TestChartDomainEdgeCases(t *testing.T) {
now := time.Now()
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
// No data at all: fall back to the full window so the grid still renders.
tMin, tMax := domain(nil, st)
if got := tMax.Sub(tMin); got != 3*time.Minute {
t.Errorf("empty span = %v, want the full window", got)
}
// One sample would otherwise give a zero-width axis and divide by zero.
one := []ChartSeries{{Points: []ChartPoint{{At: now, Value: 5, OK: true}}}}
tMin, tMax = domain(one, st)
if got := tMax.Sub(tMin); got != minPlotSpan {
t.Errorf("single-point span = %v, want minPlotSpan", got)
}
// Hidden series must not widen the axis.
mixed := []ChartSeries{
{Hidden: true, Points: []ChartPoint{{At: now.Add(-2 * time.Minute), Value: 1, OK: true}}},
{Points: []ChartPoint{
{At: now.Add(-30 * time.Second), Value: 1, OK: true},
{At: now, Value: 2, OK: true},
}},
}
tMin, tMax = domain(mixed, st)
if got := tMax.Sub(tMin); got != 30*time.Second {
t.Errorf("span = %v, want only the visible series to count", got)
}
}
// TestWrapRowWraps checks that children exceeding the width land on new lines
// instead of being clipped, which is what a plain Flex did.
func TestWrapRowWraps(t *testing.T) {
const (
childW = 100
childH = 20
rowW = 250 // fits 2 children per line
n = 5
)
child := func(gtx C) D { return D{Size: image.Pt(childW, childH)} }
children := make([]layout.Widget, n)
for i := range children {
children[i] = child
}
gtx, _ := newTestContext(image.Pt(rowW, 500))
dims := WrapRow(gtx, 0, children)
// 5 children, 2 per line => 3 lines.
if want := 3 * childH; dims.Size.Y != want {
t.Errorf("height = %d, want %d (3 wrapped lines)", dims.Size.Y, want)
}
if dims.Size.X != rowW {
t.Errorf("width = %d, want the full %d", dims.Size.X, rowW)
}
}
func TestWrapRowSingleLine(t *testing.T) {
child := func(gtx C) D { return D{Size: image.Pt(50, 20)} }
gtx, _ := newTestContext(image.Pt(500, 500))
dims := WrapRow(gtx, 0, []layout.Widget{child, child, child})
if dims.Size.Y != 20 {
t.Errorf("height = %d, want a single 20px line", dims.Size.Y)
}
}
func TestWrapRowEmpty(t *testing.T) {
gtx, _ := newTestContext(image.Pt(100, 100))
if dims := WrapRow(gtx, 0, nil); dims.Size != (image.Point{}) {
t.Errorf("want zero dims for no children, got %v", dims.Size)
}
}
// TestUDPProbeLabel covers the naming rules for the UDP table: prefer the
// configured hostname over the resolved address, and keep the two rows of a
// dual-stack server distinguishable.
func TestUDPProbeLabel(t *testing.T) {
cases := []struct {
name string
in netdiag.UDPProbe
want string
}{
{"resolved v4", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米"},
"小米 stun.miwifi.com:3478 · IPv4"},
{"resolved v6", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米"},
"小米 stun.miwifi.com:3478 · IPv6"},
{"dns failure keeps the hostname", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478", Name: "X"},
"X a.example:3478"},
{"no name", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478"}, "a.example:3478"},
{"no host falls back to target", netdiag.UDPProbe{Target: "1.2.3.4:3478"}, "1.2.3.4:3478 · IPv4"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := udpProbeLabel(tc.in); got != tc.want {
t.Errorf("udpProbeLabel() = %q, want %q", got, tc.want)
}
})
}
}
+62 -7
View File
@@ -221,19 +221,74 @@ func LoadCJKFaces(path string, logger *slog.Logger) ([]font.FontFace, error) {
if err != nil {
return nil, err
}
// A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping
// only the first regular-weight face avoids paying for five near-identical
// fallbacks on every glyph miss.
if len(faces) > 1 {
faces = faces[:1]
if len(faces) == 0 {
return nil, nil
}
// A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping
// one avoids paying for five near-identical fallbacks on every glyph miss;
// pickCJKFace decides which one.
base := faces[pickCJKFace(faces)]
out := cjkWeightVariants(base)
logger.Debug("cjk font loaded",
"path", path,
"faces", len(faces),
"typeface", string(base.Font.Typeface),
"faces", len(out),
"bytes", st.Size(),
"took", time.Since(start).Round(time.Millisecond),
)
return faces, nil
return out, nil
}
// scWeights are the family-name markers of the Simplified Chinese cut, in
// preference order. Pan-CJK collections order their faces JP first, so taking
// faces[0] blindly renders Han characters with Japanese glyph variants — legible,
// but visibly wrong to a Chinese reader.
var scMarkers = []string{"sc", "simplified", "cn", "hans"}
// pickCJKFace returns the index of the face to use, preferring the Simplified
// Chinese cut and falling back to the first face.
func pickCJKFace(faces []font.FontFace) int {
for _, marker := range scMarkers {
for i, f := range faces {
name := strings.ToLower(string(f.Font.Typeface))
// Match on a word/suffix boundary so "sc" does not hit "Sans".
for _, field := range strings.FieldsFunc(name, func(r rune) bool {
return r == ' ' || r == '-' || r == '_'
}) {
if field == marker || strings.HasSuffix(field, marker) {
return i
}
}
}
}
return 0
}
// cjkWeightVariants registers one parsed face under every weight the UI asks
// for.
//
// This exists because of how Gio resolves fonts. The theme pins every label's
// Typeface to "Go" (see NewTheme), and Gio never tells go-text which script it
// is shaping, so our explicitly-loaded CJK font is only reachable through
// fontscan's user-provided tier — which prunes candidates by weight before
// checking coverage. A face registered only at Normal is therefore invisible to
// any label that sets Font.Weight, and every section title, card header and
// button does exactly that. The result was Chinese body text rendering fine
// while every heading turned into tofu boxes.
//
// The variants share the same underlying Face, so CJK headings are not visually
// bolder than body text. That is a deliberate trade: identical weight beats
// missing glyphs, and synthetic emboldening is not available here.
func cjkWeightVariants(base font.FontFace) []font.FontFace {
weights := []font.Weight{font.Normal, font.Medium, font.SemiBold, font.Bold}
out := make([]font.FontFace, 0, len(weights))
for _, w := range weights {
f := base.Font
f.Weight = w
f.Style = font.Regular
out = append(out, font.FontFace{Font: f, Face: base.Face})
}
return out
}
// goCollection returns the built-in Go font faces. It exists so tests can
+47 -74
View File
@@ -28,7 +28,6 @@ const (
// Navigation.
KNavOverview
KNavPeers
KNavLan
KNavDiag
KNavLogs
KNavSettings
@@ -51,7 +50,8 @@ const (
KStepMonitors
KStepReady
KSplashHint
KSplashLogHint
KSplashStuckHint
KSplashExportLog
KSplashRetry
// Shared vocabulary.
@@ -84,7 +84,6 @@ const (
KOvTailnet
KOvSelf
KOvPeersOnline
KOvLanServers
KOvForwardRules
KOvConnectRules
KOvUptime
@@ -95,8 +94,8 @@ const (
// Peers page.
KPeersTitle
KPeersLinked
KPeersOther
KPeersEmpty
KPeersResolving
KPeerLatency
KPeerRoute
KPeerRouteDirect
@@ -123,19 +122,11 @@ const (
KGraphWindow
KGraphLegendHint
// LAN page.
KLanTitle
KLanSubtitle
KLanEmpty
KLanListening
KLanMotd
KLanPort
KLanAddress
KLanSeen
KLanSelf
KLanSelfHint
KLanPackets
KLanBindError
// Local services (overview).
KSvcTitle
KSvcSubtitle
KSvcEmpty
KSvcBroadcast
// Diagnostics page.
KDiagTitle
@@ -174,6 +165,7 @@ const (
KDiagEgressGeo
KDiagEgressDivergent
KDiagEgressDivergentHint
KDiagEgressDivergentHTTP
KDiagGeoSkipped
KDiagPreferredDERP
KDiagDerpLatency
@@ -211,7 +203,6 @@ const (
KLogsShown
KLogsDropped
KLogsIncludeDiag
KLogsOpenOverlay
// Settings.
KSetTheme
@@ -233,7 +224,6 @@ var zhStrings = [kCount]string{
KNavOverview: "概览",
KNavPeers: "节点",
KNavLan: "局域网",
KNavDiag: "网络诊断",
KNavLogs: "日志",
KNavSettings: "设置",
@@ -246,16 +236,17 @@ var zhStrings = [kCount]string{
KStateError: "出错",
KStateRetrying: "正在重试",
KStepConfig: "读取配置",
KStepFonts: "加载字体",
KStepTsnet: "接入 Tailscale 网络",
KStepRules: "解析转发规则",
KStepDiscovery: "启动局域网发现",
KStepMonitors: "启动状态监控",
KStepReady: "准备就绪",
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
KSplashLogHint: "实时日志(截图时可一并保留)",
KSplashRetry: "启动失败,正在重试",
KStepConfig: "读取配置",
KStepFonts: "加载字体",
KStepTsnet: "接入 Tailscale 网络",
KStepRules: "解析转发规则",
KStepDiscovery: "启动局域网发现",
KStepMonitors: "启动状态监控",
KStepReady: "准备就绪",
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
KSplashStuckHint: "当前步骤耗时异常,可导出日志以便排查",
KSplashExportLog: "导出日志",
KSplashRetry: "启动失败,正在重试",
KYes: "是",
KNo: "否",
@@ -285,7 +276,6 @@ var zhStrings = [kCount]string{
KOvTailnet: "Tailnet",
KOvSelf: "本机",
KOvPeersOnline: "在线节点",
KOvLanServers: "局域网服务器",
KOvForwardRules: "转发规则",
KOvConnectRules: "连接规则",
KOvUptime: "运行时长",
@@ -295,8 +285,8 @@ var zhStrings = [kCount]string{
KPeersTitle: "Tailscale 节点",
KPeersLinked: "已关联",
KPeersOther: "其他节点",
KPeersEmpty: "暂无节点",
KPeersResolving: "正在解析配置中的节点",
KPeerLatency: "延迟",
KPeerRoute: "链路",
KPeerRouteDirect: "直连",
@@ -320,21 +310,13 @@ var zhStrings = [kCount]string{
KPeerTags: "标签",
KGraphTitle: "延迟图谱",
KGraphEmpty: "正在采集延迟数据",
KGraphWindow: "最近 20 分钟",
KGraphWindow: "最近",
KGraphLegendHint: "点击图例可隐藏对应节点",
KLanTitle: "局域网 Minecraft 服务",
KLanSubtitle: "监听 224.0.2.60:4445 的广播",
KLanEmpty: "未发现局域网服务器",
KLanListening: "监听中",
KLanMotd: "服务器名称",
KLanPort: "端口",
KLanAddress: "地址",
KLanSeen: "最后广播",
KLanSelf: "本机广播",
KLanSelfHint: "由 tslink 转发并广播,说明隧道已生效",
KLanPackets: "收包",
KLanBindError: "无法监听组播",
KSvcTitle: "本机服务",
KSvcSubtitle: "tslink 在本机监听并转发到对应服务器",
KSvcEmpty: "配置中没有连接规则",
KSvcBroadcast: "已广播",
KDiagTitle: "网络诊断",
KDiagRun: "开始诊断",
@@ -371,7 +353,8 @@ var zhStrings = [kCount]string{
KDiagEgressIP: "出口 IP",
KDiagEgressGeo: "归属地",
KDiagEgressDivergent: "出口不一致",
KDiagEgressDivergentHint: "不同探测方式得到了不同的公网 IP,通常说明有代理或分流工具在生效",
KDiagEgressDivergentHint: "STUN(UDP)本身就看到多个公网 IP,直连打洞会受影响",
KDiagEgressDivergentHTTP: "仅 HTTP 探测看到不同的公网 IP,STUN(UDP)出口一致,通常不影响打洞",
KDiagGeoSkipped: "已跳过归属地查询",
KDiagPreferredDERP: "首选 DERP",
KDiagDerpLatency: "DERP 延迟",
@@ -407,7 +390,6 @@ var zhStrings = [kCount]string{
KLogsShown: "已显示",
KLogsDropped: "条早期日志已被丢弃",
KLogsIncludeDiag: "附带诊断报告",
KLogsOpenOverlay: "浮层日志",
KSetTheme: "主题",
KSetThemeDark: "深色",
@@ -426,7 +408,6 @@ var enStrings = [kCount]string{
KNavOverview: "Overview",
KNavPeers: "Peers",
KNavLan: "LAN",
KNavDiag: "Diagnostics",
KNavLogs: "Logs",
KNavSettings: "Settings",
@@ -439,16 +420,17 @@ var enStrings = [kCount]string{
KStateError: "Error",
KStateRetrying: "Retrying",
KStepConfig: "Loading configuration",
KStepFonts: "Loading fonts",
KStepTsnet: "Joining the tailnet",
KStepRules: "Resolving forward rules",
KStepDiscovery: "Starting LAN discovery",
KStepMonitors: "Starting monitors",
KStepReady: "Ready",
KSplashHint: "The first tailnet join can take a dozen seconds",
KSplashLogHint: "Live log (stays visible in screenshots)",
KSplashRetry: "Startup failed, retrying",
KStepConfig: "Loading configuration",
KStepFonts: "Loading fonts",
KStepTsnet: "Joining the tailnet",
KStepRules: "Resolving forward rules",
KStepDiscovery: "Starting LAN discovery",
KStepMonitors: "Starting monitors",
KStepReady: "Ready",
KSplashHint: "The first tailnet join can take a dozen seconds",
KSplashStuckHint: "This step is taking unusually long — export the log to investigate",
KSplashExportLog: "Export log",
KSplashRetry: "Startup failed, retrying",
KYes: "Yes",
KNo: "No",
@@ -478,7 +460,6 @@ var enStrings = [kCount]string{
KOvTailnet: "Tailnet",
KOvSelf: "This node",
KOvPeersOnline: "Peers online",
KOvLanServers: "LAN servers",
KOvForwardRules: "Forward rules",
KOvConnectRules: "Connect rules",
KOvUptime: "Uptime",
@@ -488,8 +469,8 @@ var enStrings = [kCount]string{
KPeersTitle: "Tailscale peers",
KPeersLinked: "Linked",
KPeersOther: "Other peers",
KPeersEmpty: "No peers yet",
KPeersResolving: "Resolving the peers named in the config",
KPeerLatency: "Latency",
KPeerRoute: "Route",
KPeerRouteDirect: "Direct",
@@ -513,21 +494,13 @@ var enStrings = [kCount]string{
KPeerTags: "Tags",
KGraphTitle: "Latency graph",
KGraphEmpty: "Collecting latency samples",
KGraphWindow: "last 20 minutes",
KGraphWindow: "last",
KGraphLegendHint: "Click a legend entry to hide that peer",
KLanTitle: "Minecraft servers on the LAN",
KLanSubtitle: "Listening for broadcasts on 224.0.2.60:4445",
KLanEmpty: "No LAN servers discovered",
KLanListening: "Listening",
KLanMotd: "Name",
KLanPort: "Port",
KLanAddress: "Address",
KLanSeen: "Last broadcast",
KLanSelf: "Ours",
KLanSelfHint: "Advertised by tslink, so the tunnel is working",
KLanPackets: "packets",
KLanBindError: "Cannot join multicast group",
KSvcTitle: "Local services",
KSvcSubtitle: "Listening on this machine, forwarded to each server",
KSvcEmpty: "No connect rules configured",
KSvcBroadcast: "Broadcast",
KDiagTitle: "Network diagnostics",
KDiagRun: "Run diagnostics",
@@ -564,7 +537,8 @@ var enStrings = [kCount]string{
KDiagEgressIP: "Egress IP",
KDiagEgressGeo: "Location",
KDiagEgressDivergent: "Egress mismatch",
KDiagEgressDivergentHint: "Different probes saw different public IPs, which usually means a proxy or split tunnel is active",
KDiagEgressDivergentHint: "STUN (UDP) itself saw more than one public IP, so direct connections will suffer",
KDiagEgressDivergentHTTP: "Only the HTTP probes disagreed; the STUN (UDP) egress is consistent, so hole punching is usually unaffected",
KDiagGeoSkipped: "Geolocation skipped",
KDiagPreferredDERP: "Preferred DERP",
KDiagDerpLatency: "DERP latency",
@@ -600,7 +574,6 @@ var enStrings = [kCount]string{
KLogsShown: "shown",
KLogsDropped: "earlier entries were dropped",
KLogsIncludeDiag: "Include diagnostics",
KLogsOpenOverlay: "Log overlay",
KSetTheme: "Theme",
KSetThemeDark: "Dark",
-8
View File
@@ -257,14 +257,6 @@ func IconWarn(gtx C, size int, col color.NRGBA) D {
return d
}
// IconClose dismisses an overlay.
func IconClose(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
line(p, pt, 0.24, 0.24, 0.76, 0.76)
line(p, pt, 0.76, 0.24, 0.24, 0.76)
})
}
// IconChevronRight indicates an expandable row.
func IconChevronRight(gtx C, size int, col color.NRGBA) D {
return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) {
-269
View File
@@ -1,269 +0,0 @@
package gui
import (
"log/slog"
"strings"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/unit"
"gioui.org/widget"
"tslink/core"
)
// logOverlay is the translucent live-log panel.
//
// It exists for one specific situation: someone is looking at a stuck loading
// screen and takes a screenshot to ask for help. If the logs are on another
// page, that screenshot is useless. Rendering them as a translucent sheet over
// the loading screen means the interesting information is in the picture
// without hiding what the app is doing.
type logOverlay struct {
visible bool
list layout.List
copyBtn widget.Clickable
closeBtn widget.Clickable
// docked is set while the splash is up: the panel then spans the window
// bottom instead of floating in the corner.
docked bool
// Cached tail. The overlay redraws at the animation rate because of its
// live status dot, but the log only changes when a record is appended, so
// the slice is rebuilt on sequence change rather than every frame.
cached []core.LogEntry
cachedSeq uint64
cachedLen int
}
// tail returns the newest records, rebuilding only when the buffer advanced.
func (o *logOverlay) tail(buf *core.LogBuffer) []core.LogEntry {
seq, n := buf.LastSeq(), buf.Len()
if o.cached != nil && seq == o.cachedSeq && n == o.cachedLen {
return o.cached
}
o.cached = buf.Tail(overlayTailSize)
o.cachedSeq, o.cachedLen = seq, n
return o.cached
}
func newLogOverlay() *logOverlay {
return &logOverlay{
list: layout.List{Axis: layout.Vertical, ScrollToEnd: true},
}
}
// overlayTailSize is how many recent records the overlay renders. The full
// history lives on the logs page; this is a live tail, not an archive.
const overlayTailSize = 400
// Docked geometry. The splash reserves exactly this much room at the bottom of
// the window so the checklist is never hidden behind the log sheet — the point
// of the overlay is that both are legible in one screenshot.
const (
dockedLogHeight unit.Dp = 176
dockedHeaderHeight unit.Dp = 28
)
// dockedReserve is the total vertical space the docked overlay occupies,
// including its insets and the margin below it.
func dockedReserve(gtx C) int {
return gtx.Dp(dockedLogHeight + dockedHeaderHeight + SpaceSM + SpaceMD*2 + SpaceXL)
}
// Layout draws the overlay. duringSplash forces it visible and docked.
func (o *logOverlay) Layout(a *App, gtx C, duringSplash bool) D {
o.docked = duringSplash
if !duringSplash && !o.visible {
return D{}
}
if a.opt.Logs == nil {
return D{}
}
entries := o.tail(a.opt.Logs)
if o.copyBtn.Clicked(gtx) {
a.copyToClipboard(gtx, a.opt.Logs.ExportText(core.ExportOptions{
Header: a.diagnosticHeader(),
Query: core.LogQuery{MinLevel: slog.LevelDebug},
}), a.th.T(KCopied))
}
if o.closeBtn.Clicked(gtx) {
o.visible = false
}
if duringSplash {
return layout.S.Layout(gtx, func(gtx C) D {
return layout.Inset{
Left: SpaceXL, Right: SpaceXL, Bottom: SpaceXL,
}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return o.panel(a, gtx, entries, dockedLogHeight)
})
})
}
return layout.SE.Layout(gtx, func(gtx C) D {
return layout.Inset{Right: SpaceXL, Bottom: SpaceXL}.Layout(gtx, func(gtx C) D {
w := min(gtx.Constraints.Max.X, gtx.Dp(520))
gtx.Constraints.Max.X = w
gtx.Constraints.Min.X = w
return o.panel(a, gtx, entries, unit.Dp(300))
})
})
}
func (o *logOverlay) panel(a *App, gtx C, entries []core.LogEntry, height unit.Dp) D {
th := a.th
h := gtx.Dp(height)
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
glassPanel(th, gtx, gtx.Constraints.Min, float32(gtx.Dp(RadiusMD)))
return D{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{
Top: SpaceMD, Bottom: SpaceMD, Left: SpaceLG, Right: SpaceMD,
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D { return o.header(a, gtx, len(entries)) }),
VGap(SpaceSM),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.Y = h
gtx.Constraints.Max.Y = h
return o.body(a, gtx, entries)
}),
)
})
}),
)
}
func (o *logOverlay) header(a *App, gtx C, n int) D {
th := a.th
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.StatusDot(gtx, LevelInfo, false)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
l := th.Text(SizeCaption, th.P.TextSec, th.T(KSplashLogHint))
l.Font.Weight = font.Medium
return OneLine(l).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, &o.copyBtn, IconCopy, LevelNeutral)
}),
layout.Rigid(func(gtx C) D {
if o.docked {
return D{}
}
return th.IconButton(gtx, &o.closeBtn, IconClose, LevelNeutral)
}),
)
}
func (o *logOverlay) body(a *App, gtx C, entries []core.LogEntry) D {
th := a.th
if len(entries) == 0 {
return layout.Center.Layout(gtx, th.Caption(th.T(KLoading)).Layout)
}
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
return o.list.Layout(gtx, len(entries), func(gtx C, i int) D {
return o.line(th, gtx, entries[i])
})
}
// line renders one compact log record: time, level, message, and the most
// useful attributes folded into a single trailing run so the column stays
// narrow.
func (o *logOverlay) line(th *Theme, gtx C, e core.LogEntry) D {
lvlCol := th.P.TextDim
switch {
case e.Level >= slog.LevelError:
lvlCol = th.P.Fail
case e.Level >= slog.LevelWarn:
lvlCol = th.P.Warn
case e.Level >= slog.LevelInfo:
lvlCol = th.P.Info
}
msgCol := th.P.TextSec
if e.Level >= slog.LevelWarn {
msgCol = th.P.TextPri
}
return layout.Inset{Top: 1, Bottom: 1}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.MonoLabel(SizeCaption, WithAlpha(th.P.TextDim, 0.85),
e.Time.Format("15:04:05")).Layout(gtx)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(26)
return th.MonoLabel(SizeCaption, lvlCol, core.LevelLabel(e.Level)).Layout(gtx)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
l := th.MonoLabel(SizeCaption, msgCol, overlayLineText(e))
l.MaxLines = 2
return l.Layout(gtx)
}),
)
})
}
// overlayLineText folds a record's attributes onto one line, dropping the
// "from" attribute because the subsystem is already implied by the message.
func overlayLineText(e core.LogEntry) string {
var b strings.Builder
b.WriteString(e.Msg)
for _, a := range e.Attrs {
if a.Key == "from" {
continue
}
b.WriteByte(' ')
b.WriteString(a.Key)
b.WriteByte('=')
b.WriteString(Truncate(core.Redact(a.Value), 64))
}
return b.String()
}
// diagnosticHeader is the metadata block prepended to any exported log bundle,
// so a paste is self-describing without the reporter having to explain their
// setup.
func (a *App) diagnosticHeader() string {
st := a.state()
var b strings.Builder
b.WriteString("# tslink diagnostic bundle\n")
b.WriteString("# version: " + a.opt.Version + "\n")
b.WriteString("# os/arch: " + runtimeInfo() + "\n")
if a.opt.ConfigURL != "" {
b.WriteString("# config: (url)\n")
} else if a.opt.ConfigPath != "" {
b.WriteString("# config: " + a.opt.ConfigPath + "\n")
}
b.WriteString("# phase: " + st.Phase.String() + "\n")
b.WriteString("# restarts: " + itoa(st.Restarts) + "\n")
if !st.ReadyAt.IsZero() {
b.WriteString("# uptime: " + FormatDuration(timeSince(st.ReadyAt)) + "\n")
}
if st.Peers != nil {
snap := st.Peers.Snapshot()
b.WriteString("# tailnet: " + snap.TailnetName + "\n")
b.WriteString("# peers: " + itoa(len(snap.Peers)) + "\n")
}
// reportText takes the diag page's lock; reading a.diag.report directly
// would race the background diagnostic goroutine.
if a.diag != nil {
if txt := a.diag.reportText(); txt != "" {
b.WriteString("#\n")
b.WriteString(txt)
}
}
return b.String()
}
+179 -16
View File
@@ -2,6 +2,7 @@ package gui
import (
"context"
"net/netip"
"sort"
"strings"
"sync"
@@ -105,9 +106,7 @@ func (p *diagPage) run() {
}
p.progress[pr.Key] = pr
p.mu.Unlock()
if a.win != nil {
a.win.Invalidate()
}
a.invalidate()
},
})
p.mu.Lock()
@@ -116,9 +115,7 @@ func (p *diagPage) run() {
p.lastRun = time.Now()
p.cancel = nil
p.mu.Unlock()
if a.win != nil {
a.win.Invalidate()
}
a.invalidate()
}()
}
@@ -188,7 +185,10 @@ func (p *diagPage) controlCard(a *App, gtx C, running bool, rep *netdiag.Report,
col = th.P.TextPri
} else if rep != nil {
headline = rep.Headline
col = th.StatusColor(diagLevel(rep.Status))
// The headline's own severity, not the report's: an
// unrelated failure elsewhere must not paint a merely
// cautionary sentence in alarm red.
col = th.StatusColor(diagLevel(rep.HeadlineStatus))
}
l := th.Text(SizeSubtitle, col, headline)
l.Font.Weight = font.SemiBold
@@ -393,6 +393,132 @@ func (p *diagPage) triLabel(th *Theme, v *bool) (string, StatusLevel) {
return th.T(KNo), LevelWarn
}
// A bare "unknown" or "no" in the results tells the user what was measured but
// not what it costs them. These helpers add the one-line consequence, which is
// the part that actually answers "should I care".
//
// They follow behaviorLabel's inline bilingual switch rather than i18n keys:
// the strings are explanatory prose, only ever used here.
// behaviorHint explains an RFC 5780 mapping/filtering behaviour.
func behaviorHint(th *Theme, b netdiag.Behavior) string {
zh := th.Lang == LangZH
switch b {
case netdiag.BehaviorEndpointIndependent:
if zh {
return "对所有目标复用同一个外部端口,最利于打洞"
}
return "one external port for every destination — best case for hole punching"
case netdiag.BehaviorAddressDependent:
if zh {
return "换一个目标地址就换一个映射"
}
return "the mapping changes with the destination address"
case netdiag.BehaviorAddressAndPortDependent:
if zh {
return "目标地址或端口一变映射就变,等同对称型"
}
return "the mapping changes with address or port — effectively symmetric"
default:
if zh {
return "没有服务器支持 CHANGE-REQUEST,无法判定"
}
return "no server supported CHANGE-REQUEST, so this could not be determined"
}
}
// hairpinHint explains whether the NAT loops traffic sent to its own external
// address back inside.
func hairpinHint(th *Theme, v *bool) string {
zh := th.Lang == LangZH
switch {
case v == nil:
if zh {
return "未测试"
}
return "not tested"
case *v:
if zh {
return "同一内网的两台机器可经外网地址互连"
}
return "two machines behind this NAT can reach each other via the external address"
default:
if zh {
return "同一内网内无法经外网地址回环,需走内网地址"
}
return "traffic to the external address does not loop back; use the LAN address instead"
}
}
// preserveHint explains whether the external port matches the local one.
func preserveHint(th *Theme, v *bool) string {
zh := th.Lang == LangZH
switch {
case v == nil:
if zh {
return "未测试"
}
return "not tested"
case *v:
if zh {
return "外部端口与本地端口一致,对端更容易预测"
}
return "the external port matches the local one, so peers can predict it"
default:
if zh {
return "外部端口被改写,端口预测不可靠"
}
return "the external port is rewritten, so port prediction is unreliable"
}
}
// reachHint labels the CN/international pair, which is otherwise four bare
// numbers with no indication of what they count.
func reachHint(th *Theme) string {
if th.Lang == LangZH {
return "各自可达 / 探测总数"
}
return "reachable / probed, per region"
}
// udpFamilyStats counts responding and probed servers per address family.
// Probes whose DNS lookup failed carry no address and belong to neither.
func udpFamilyStats(r netdiag.UDPReport) (v4ok, v4n, v6ok, v6n int) {
for _, p := range r.Probes {
ap, err := netip.ParseAddrPort(p.Target)
if err != nil {
continue
}
if ap.Addr().Is4() || ap.Addr().Is4In6() {
v4n++
if p.OK {
v4ok++
}
continue
}
v6n++
if p.OK {
v6ok++
}
}
return
}
// udpFamilyHint reports how many servers answered on one address family.
func udpFamilyHint(th *Theme, ok, total int) string {
zh := th.Lang == LangZH
if total == 0 {
if zh {
return "没有可探测的地址"
}
return "no address to probe"
}
if zh {
return itoa(ok) + "/" + itoa(total) + " 台服务器响应"
}
return itoa(ok) + "/" + itoa(total) + " servers responded"
}
func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D {
th := a.th
hairpin, hairpinLvl := p.triLabel(th, r.Hairpin)
@@ -411,10 +537,10 @@ func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D {
}),
layout.Rigid(func(gtx C) D {
return th.KVList(gtx, []KV{
{Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping)},
{Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering)},
{Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl},
{Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl},
{Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping), Hint: behaviorHint(th, r.Mapping)},
{Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering), Hint: behaviorHint(th, r.Filtering)},
{Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl, Hint: hairpinHint(th, r.Hairpin)},
{Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl, Hint: preserveHint(th, r.PortPreserving)},
})
}),
layout.Rigid(func(gtx C) D {
@@ -493,6 +619,35 @@ func (p *diagPage) stunTable(a *App, gtx C, results []netdiag.STUNResult) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
// udpProbeLabel names a UDP probe the way the STUN table names its rows: the
// operator, then the hostname the user configured.
//
// The raw resolved address is not a useful label — nobody recognises
// 111.206.174.2:3478 as 小米 — but it is the only thing distinguishing the two
// rows a dual-stack server produces, so the family is appended instead.
func udpProbeLabel(pr netdiag.UDPProbe) string {
host := pr.Host
if host == "" {
host = pr.Target
}
label := host
if pr.Name != "" {
label = pr.Name + " " + host
}
// Only meaningful when Target is a resolved address rather than a copy of
// Host, which is what the DNS-failure path stores.
if pr.Target != "" && pr.Target != pr.Host {
if ap, err := netip.ParseAddrPort(pr.Target); err == nil {
if ap.Addr().Is4() || ap.Addr().Is4In6() {
label += " · IPv4"
} else {
label += " · IPv6"
}
}
}
return label
}
// regionTag prefixes a probe target so the CN/international split — the whole
// reason both are probed — is visible at a glance.
func regionTag(th *Theme, r netdiag.Region) string {
@@ -560,15 +715,18 @@ func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D {
v6lvl = LevelNeutral
}
v4ok, v4n, v6ok, v6n := udpFamilyStats(r)
return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecUDP), r.Status, r.Summary, func(gtx C) D {
rows := []KV{
{Key: th.T(KDiagUdpV4), Value: v4, Level: v4lvl},
{Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl},
{Key: th.T(KDiagUdpV4), Value: v4, Level: v4lvl, Hint: udpFamilyHint(th, v4ok, v4n)},
{Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl, Hint: udpFamilyHint(th, v6ok, v6n)},
{
Key: "国内 / 境外",
Value: itoa(r.CNReachable) + "/" + itoa(r.CNTotal) + " " +
itoa(r.IntlReachabl) + "/" + itoa(r.IntlTotal),
Mono: true,
Hint: reachHint(th),
},
}
if th.Lang != LangZH {
@@ -600,7 +758,7 @@ func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D {
if !pr.OK {
val, level, rtt = orDash(pr.Err), LevelFail, ""
}
return p.tableRow(a, gtx, regionTag(th, pr.Region)+pr.Target, val, rtt, level)
return p.tableRow(a, gtx, regionTag(th, pr.Region)+udpProbeLabel(pr), val, rtt, level)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
@@ -704,9 +862,14 @@ func (p *diagPage) egressCard(a *App, gtx C, r netdiag.EgressReport) D {
if !r.Divergent {
return D{}
}
// Only a split seen by STUN itself threatens the UDP path, so
// only that one gets the red treatment.
level, hint := LevelWarn, th.T(KDiagEgressDivergentHTTP)
if r.DivergentSTUN {
level, hint = LevelFail, th.T(KDiagEgressDivergentHint)
}
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
return p.callout(a, gtx, LevelWarn,
th.T(KDiagEgressDivergent), th.T(KDiagEgressDivergentHint))
return p.callout(a, gtx, level, th.T(KDiagEgressDivergent), hint)
})
}),
// Geolocation first: "where do I appear to be" is the question, the
-190
View File
@@ -1,190 +0,0 @@
package gui
import (
"time"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
)
type lanPage struct {
list widget.List
copy map[string]*widget.Clickable
}
func newLanPage() *lanPage {
p := &lanPage{copy: make(map[string]*widget.Clickable)}
p.list.Axis = layout.Vertical
return p
}
func (p *lanPage) copyBtn(key string) *widget.Clickable {
c, ok := p.copy[key]
if !ok {
c = &widget.Clickable{}
p.copy[key] = c
}
return c
}
func (p *lanPage) Layout(a *App, gtx C, st core.State) D {
th := a.th
if st.Lan == nil {
return th.EmptyState(gtx, IconBroadcast, th.T(KLanEmpty), th.T(KLoading))
}
servers := st.Lan.Servers()
scanErr := st.Lan.Err()
var advertised []core.LanEntry
if st.Config != nil {
advertised = core.LanEntriesFromRules(st.Config.Connect)
}
items := []layout.Widget{
func(gtx C) D { return p.summaryCard(a, gtx, servers, advertised, scanErr) },
}
if len(servers) == 0 {
items = append(items, func(gtx C) D {
hint := th.T(KLanSubtitle)
if scanErr != "" {
hint = scanErr
}
return th.EmptyState(gtx, IconServer, th.T(KLanEmpty), hint)
})
}
for _, s := range servers {
items = append(items, func(gtx C) D { return p.serverCard(a, gtx, s) })
}
return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D {
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, items[i])
})
}
// summaryCard states what the scanner is doing and, crucially, whether the
// advertisements tslink itself emits are being heard back. A rule that is
// configured but not audible means the tunnel or the multicast path is broken,
// and that is the single most useful thing this page can tell someone.
func (p *lanPage) summaryCard(a *App, gtx C, servers []core.LanServer, advertised []core.LanEntry, scanErr string) D {
th := a.th
selfHeard := 0
for _, s := range servers {
if s.IsSelf {
selfHeard++
}
}
missing := len(advertised) - selfHeard
if missing < 0 {
missing = 0
}
card := th.Card()
card.Title = th.T(KLanTitle)
card.Subtitle = th.T(KLanSubtitle)
card.Trailing = func(gtx C) D {
level, label := LevelOK, th.T(KLanListening)
if scanErr != "" {
level, label = LevelFail, th.T(KLanBindError)
}
return th.Chip(gtx, ChipStyle{Text: label, Level: level, Dot: true})
}
rows := []KV{
{Key: th.T(KOvLanServers), Value: itoa(len(servers))},
{Key: th.T(KLanSelf), Value: itoa(selfHeard) + " / " + itoa(len(advertised)),
Hint: th.T(KLanSelfHint),
Level: selfLevel(len(advertised), selfHeard)},
}
if scanErr != "" {
rows = append(rows, KV{Key: th.T(KError), Value: scanErr, Level: LevelFail})
}
return card.Layout(th, gtx, func(gtx C) D {
return th.KVList(gtx, rows)
})
}
func selfLevel(advertised, heard int) StatusLevel {
switch {
case advertised == 0:
return LevelNeutral
case heard >= advertised:
return LevelOK
case heard == 0:
return LevelFail
default:
return LevelWarn
}
}
func (p *lanPage) serverCard(a *App, gtx C, s core.LanServer) D {
th := a.th
addr := s.Addr.String() + ":" + itoa(s.Port)
btn := p.copyBtn(addr)
if btn.Clicked(gtx) {
a.copyToClipboard(gtx, addr, "")
}
stale := time.Since(s.LastSeen) > 8*time.Second
level := LevelOK
if stale {
level = LevelWarn
}
card := th.Card()
card.Pad = SpaceMD
if s.IsSelf {
accent := th.P.Accent
card.Accent = &accent
}
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return IconServer(gtx, gtx.Dp(18), th.StatusColor(level))
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(OneLine(th.Body(orDash(s.Motd))).Layout),
layout.Rigid(func(gtx C) D {
if !s.IsSelf {
return D{}
}
return layout.Inset{Left: SpaceSM}.Layout(gtx, func(gtx C) D {
return th.Chip(gtx, ChipStyle{
Text: th.T(KLanSelf),
Level: LevelInfo,
})
})
}),
)
}),
layout.Rigid(func(gtx C) D {
return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, addr)).Layout(gtx)
}),
)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.Text(SizeCaption, th.StatusColor(level),
RelTime(th, s.LastSeen, time.Now())).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
return th.Caption(itoa(s.Count) + " " + th.T(KLanPackets)).Layout(gtx)
}),
)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, btn, IconCopy, LevelNeutral)
}),
)
})
}
+2 -3
View File
@@ -153,6 +153,7 @@ func (p *logsPage) handleActions(a *App, gtx C, buf *core.LogBuffer) {
a.notify(th.T(KError)+": "+err.Error(), LevelFail)
} else {
a.notify(path, LevelOK)
a.reveal(path)
}
}
if p.uploadBtn.Clicked(gtx) {
@@ -204,9 +205,7 @@ func (p *logsPage) startUpload(a *App, text string) {
} else {
a.notify(a.th.T(KLogsUploaded)+" "+res.URL, LevelOK)
}
if a.win != nil {
a.win.Invalidate()
}
a.invalidate()
}()
}
+33 -18
View File
@@ -1,11 +1,13 @@
package gui
import (
"image"
"strings"
"time"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/widget"
"gioui.org/widget/material"
@@ -18,6 +20,8 @@ type overviewPage struct {
diagBtn widget.Clickable
peersBtn widget.Clickable
copySelf widget.Clickable
// svcCopy holds one clickable per service address, allocated on demand.
svcCopy map[string]*widget.Clickable
}
func newOverviewPage() *overviewPage {
@@ -41,19 +45,17 @@ func (p *overviewPage) Layout(a *App, gtx C, st core.State) D {
if st.Peers != nil {
snap = st.Peers.Snapshot()
}
var lanServers []core.LanServer
if st.Lan != nil {
lanServers = st.Lan.Servers()
}
servers := buildServices(st.Config, snap)
if p.copySelf.Clicked(gtx) {
a.copyToClipboard(gtx, selfAddrText(snap.Self), "")
}
items := []layout.Widget{
func(gtx C) D { return p.statRow(a, gtx, st, snap, lanServers) },
func(gtx C) D { return p.statRow(a, gtx, st, snap, servers) },
func(gtx C) D { return p.healthCard(a, gtx) },
func(gtx C) D { return p.selfCard(a, gtx, st, snap) },
func(gtx C) D { return p.servicesCard(a, gtx, servers) },
func(gtx C) D { return p.linkedCard(a, gtx, snap) },
}
return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D {
@@ -88,19 +90,29 @@ func (p *overviewPage) statTile(a *App, gtx C, value, label, hint string, level
if level != LevelNeutral {
l.Color = th.StatusColor(level)
}
return l.Layout(gtx)
// Single line, always. A value like "19 / 25" wraps at narrow
// tile widths where "8" does not, and one tile a whole line
// taller than its neighbours is what makes the row look broken.
return OneLine(l).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if hint == "" {
return D{}
if hint != "" {
return OneLine(th.Caption(hint)).Layout(gtx)
}
return OneLine(th.Caption(hint)).Layout(gtx)
// Reserve the hint line even when there is no hint. These tiles
// sit in a row, and Flex does not equalise child heights, so a
// tile that skipped this line came out shorter than its
// neighbours and the row looked misaligned.
macro := op.Record(gtx.Ops)
d := OneLine(th.Caption("X")).Layout(gtx)
macro.Stop()
return D{Size: image.Pt(0, d.Size.Y)}
}),
)
})
}
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, lan []core.LanServer) D {
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, servers []serviceServer) D {
th := a.th
online, linked := 0, 0
@@ -121,10 +133,13 @@ func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnaps
connectRules += len(rs)
}
}
selfLan := 0
for _, s := range lan {
if s.IsSelf {
selfLan++
services, broadcast := 0, 0
for _, srv := range servers {
services += len(srv.Services)
for _, svc := range srv.Services {
if svc.Broadcast {
broadcast++
}
}
}
@@ -148,9 +163,9 @@ func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnaps
},
func(gtx C) D {
return p.statTile(a, gtx,
itoa(len(lan)),
th.T(KOvLanServers),
itoa(selfLan)+" "+th.T(KLanSelf),
itoa(services),
th.T(KSvcTitle),
itoa(broadcast)+" "+th.T(KSvcBroadcast),
LevelNeutral, IconServer)
},
func(gtx C) D {
@@ -202,7 +217,7 @@ func (p *overviewPage) healthCard(a *App, gtx C) D {
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
l := th.Text(SizeBody, th.StatusColor(diagLevel(rep.Status)), rep.Headline)
l := th.Text(SizeBody, th.StatusColor(diagLevel(rep.HeadlineStatus)), rep.Headline)
l.Font.Weight = font.Medium
l.MaxLines = 2
return l.Layout(gtx)
+62 -41
View File
@@ -12,9 +12,12 @@ import (
"tslink/core"
)
// chartWindow is how much latency history the graph shows. It matches the
// monitor's default 120-sample ring at a 10s ping interval.
const chartWindow = 20 * time.Minute
// chartWindow is the most latency history the graph shows. The monitor retains
// 20 minutes, but a spike that old tells you nothing about the session you are
// in right now, and stretching the axis over it flattens everything recent into
// noise. The axis scales to whatever data exists within this bound, so the plot
// is full from the second sample rather than after 20 minutes of uptime.
const chartWindow = 3 * time.Minute
// maxChartSeries caps how many peers are plotted at once. Beyond about eight
// lines a latency graph stops being readable, so linked peers win and the rest
@@ -75,8 +78,10 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
a.notify(th.T(KRefresh), LevelInfo)
}
linked, other := splitPeers(snap.Peers)
series := p.buildSeries(th, snap.Peers)
// Only nodes a config rule points at. The netmap contains every machine on
// the tailnet, most of which the user has no rule for and no interest in.
linked, _ := splitPeers(snap.Peers)
series := p.buildSeries(th, linked)
// Legend clicks toggle series visibility.
for i := range series {
@@ -87,7 +92,7 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
series[i].s.Hidden = p.hidden[id]
}
items := make([]layout.Widget, 0, len(snap.Peers)+4)
items := make([]layout.Widget, 0, len(linked)+4)
items = append(items, func(gtx C) D { return p.chartCard(a, gtx, series) })
if len(linked) > 0 {
@@ -97,22 +102,21 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
for _, pr := range linked {
items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) })
}
}
if len(other) > 0 {
items = append(items, func(gtx C) D {
return a.sectionTitle(gtx, th.T(KPeersOther), "", nil)
})
for _, pr := range other {
items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) })
}
}
if len(snap.Peers) == 0 {
} else {
items = append(items, func(gtx C) D {
// Link resolution is periodic and needs DNS, so on a fresh boot
// every peer is briefly unlinked. Saying "no peers" there would be
// wrong; the netmap may be full of machines we simply have no rule
// for yet.
hint := snap.Err
if hint == "" {
hint = snap.BackendState
}
return th.EmptyState(gtx, IconNodes, th.T(KPeersEmpty), hint)
title := th.T(KPeersEmpty)
if len(snap.Peers) > 0 {
title = th.T(KPeersResolving)
}
return th.EmptyState(gtx, IconNodes, title, hint)
})
}
@@ -156,14 +160,24 @@ func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries
if len(pr.Samples) == 0 {
continue
}
// Drop samples outside the window by age rather than by count: the
// monitor's ring is not evenly spaced, because a manual refresh injects
// an off-cycle sweep.
cutoff := time.Now().Add(-chartWindow)
pts := make([]ChartPoint, 0, len(pr.Samples))
for _, s := range pr.Samples {
if s.At.Before(cutoff) {
continue
}
pts = append(pts, ChartPoint{
At: s.At,
Value: float64(s.Latency) / float64(time.Millisecond),
OK: s.OK,
})
}
if len(pts) == 0 {
continue
}
out = append(out, namedSeries{
id: pr.ID,
s: ChartSeries{
@@ -179,9 +193,26 @@ func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries
func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D {
th := a.th
plot := make([]ChartSeries, len(series))
for i, s := range series {
plot[i] = s.s
}
style := ChartStyle{
Height: 200,
MaxWindow: chartWindow,
Now: time.Now(),
Unit: "ms",
FillSingle: true,
}
card := th.Card()
card.Title = th.T(KGraphTitle)
card.Subtitle = th.T(KGraphWindow)
// The axis follows the data, so the subtitle has to as well — a fixed
// "last 20 minutes" was a lie for the first 20 minutes of every run.
if len(plot) > 0 {
tMin, tMax := domain(plot, style)
card.Subtitle = th.T(KGraphWindow) + " " + FormatDuration(tMax.Sub(tMin))
}
card.Trailing = func(gtx C) D {
return th.IconButton(gtx, &p.refresh, IconRefresh, LevelNeutral)
}
@@ -189,19 +220,9 @@ func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D {
if len(series) == 0 {
return th.EmptyState(gtx, IconPulse, th.T(KGraphEmpty), "")
}
plot := make([]ChartSeries, len(series))
for i, s := range series {
plot[i] = s.s
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return p.chart.Layout(th, gtx, ChartStyle{
Height: 200,
Window: chartWindow,
Now: time.Now(),
Unit: "ms",
FillSingle: true,
}, plot)
return p.chart.Layout(th, gtx, style, plot)
}),
VGap(SpaceMD),
layout.Rigid(func(gtx C) D {
@@ -213,23 +234,23 @@ func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D {
func (p *peersPage) legendRow(a *App, gtx C, series []namedSeries) D {
th := a.th
children := make([]layout.FlexChild, 0, len(series))
entries := make([]LegendEntry, 0, len(series))
for _, s := range series {
id := s.id
entry := LegendEntry{
entries = append(entries, LegendEntry{
Name: s.s.Name,
Color: s.s.Color,
Hidden: p.hidden[id],
Hidden: p.hidden[s.id],
Value: lastValue(s.s.Points),
}
click := p.legendClick(id)
children = append(children, layout.Rigid(func(gtx C) D {
return click.Layout(gtx, func(gtx C) D {
return th.LegendChip(gtx, entry, click.Hovered())
})
}))
})
}
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...)
return th.Legend(gtx, entries, func(i int) layout.Widget {
click := p.legendClick(series[i].id)
return func(gtx C) D {
return click.Layout(gtx, func(gtx C) D {
return th.LegendChip(gtx, entries[i], click.Hovered())
})
}
})
}
func lastValue(points []ChartPoint) string {
+73 -16
View File
@@ -95,7 +95,6 @@ func readyState(t *testing.T) core.State {
ReadyAt: time.Now().Add(-time.Hour),
Config: cfg,
Peers: core.NewPeerMonitor(nil, cfg.Connect, logger, core.PeerMonitorOptions{}),
Lan: core.NewLanScanner(logger),
}
}
@@ -105,7 +104,7 @@ func TestPagesLayout(t *testing.T) {
{X: 880, Y: 560}, // the declared minimum window
{X: 640, Y: 400}, // below minimum: compact rail, everything must still fit
}
pages := []pageID{pageOverview, pagePeers, pageLan, pageDiag, pageLogs, pageSettings}
pages := []pageID{pageOverview, pagePeers, pageDiag, pageLogs, pageSettings}
for _, size := range sizes {
for _, page := range pages {
@@ -138,13 +137,32 @@ func TestSplashLayout(t *testing.T) {
}
gtx, _ := newTestContext(size)
a.splash.Layout(a, gtx, st)
// The overlay is forced visible during the splash; it must lay out
// on top without depending on the splash having run.
a.overlay.Layout(a, gtx, true)
}
}
}
// TestSplashStuck covers the >20s branch, which swaps the footer hint and
// promotes the export button.
func TestSplashStuck(t *testing.T) {
a := testApp(t)
steps := splashTestSteps()
for i := range steps {
if steps[i].State == core.StepRunning {
steps[i].Started = time.Now().Add(-45 * time.Second)
}
}
st := core.State{
Phase: core.PhaseStarting,
Steps: steps,
StartedAt: time.Now().Add(-45 * time.Second),
}
if !stalled(st) {
t.Fatal("stalled() should report a step running past stuckAfter")
}
gtx, _ := newTestContext(image.Pt(460, 450))
a.splash.Layout(a, gtx, st)
}
func splashTestSteps() []core.BootStep {
now := time.Now()
return []core.BootStep{
@@ -184,8 +202,12 @@ func TestDiagPageWithReport(t *testing.T) {
CNReachable: 3, CNTotal: 5, IntlReachabl: 1, IntlTotal: 7,
BlockedPorts: []int{19302},
Probes: []netdiag.UDPProbe{
{Target: "stun.miwifi.com:3478", Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
{Target: "stun.l.google.com:19302", Region: netdiag.RegionIntl, Err: "i/o timeout"},
// A resolved probe: Host names the server, Target is the
// address actually hit, and the label must show the former.
{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond, Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")},
// DNS failed, so Target still holds the hostname.
{Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google", Region: netdiag.RegionIntl, Err: "i/o timeout"},
},
},
NAT: netdiag.NATReport{
@@ -253,15 +275,6 @@ func TestDiagPageWithReport(t *testing.T) {
}
}
// TestOverlayLayout covers the floating (non-docked) overlay, which has a
// different anchor and a close button the docked one hides.
func TestOverlayLayout(t *testing.T) {
a := testApp(t)
a.overlay.visible = true
gtx, _ := newTestContext(image.Pt(1000, 700))
a.overlay.Layout(a, gtx, false)
}
func TestFormatHelpers(t *testing.T) {
cases := []struct {
got, want string
@@ -306,3 +319,47 @@ func TestTrFallsBackToEnglish(t *testing.T) {
}
}
}
// TestLayoutForceSplash exercises the top-level frame that runWindow drives.
// The splash window passes forceSplash=true; the shell window passes false.
// With no supervisor the state is not ready, so both must fall to the splash
// branch and lay out without panicking — the guard for the compile-time change
// to layout's signature and the forceSplash branch it added.
func TestLayoutForceSplash(t *testing.T) {
a := testApp(t)
for _, forceSplash := range []bool{true, false} {
gtx, _ := newTestContext(image.Pt(int(shellWindowW), int(shellWindowH)))
a.layout(gtx, forceSplash)
}
}
// TestStatTilesUniformHeight guards the overview's top row. The tiles sit in a
// Flex, which does not equalise child heights, so anything that makes one tile
// taller — a wrapped value, a hint line present on some tiles but not others —
// visibly misaligns the row. Narrow widths are the interesting case: that is
// where "19 / 25" wraps and "8" does not.
func TestStatTilesUniformHeight(t *testing.T) {
a := testApp(t)
tiles := []struct{ value, label, hint string }{
{"19 / 25", "在线节点", "3 已关联"},
{"8", "本机服务", "5 已广播"},
{"8 / 0", "连接规则 / 转发规则", ""},
{"10s", "运行时长", ""},
}
for _, w := range []int{60, 80, 100, 140, 200, 300} {
var first int
for i, c := range tiles {
gtx, _ := newTestContext(image.Pt(w, 400))
gtx.Constraints.Min = image.Point{}
h := a.overview.statTile(a, gtx, c.value, c.label, c.hint, LevelNeutral, IconNodes).Size.Y
if i == 0 {
first = h
continue
}
if h != first {
t.Errorf("width=%d: tile %q is %dpx, tile %q is %dpx — the row must be flush",
w, c.label, h, tiles[0].label, first)
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
package gui
import (
"context"
"log/slog"
"os/exec"
"path/filepath"
"runtime"
"time"
)
// revealTimeout bounds the helper process. A missing or wedged file manager
// must not leave a goroutine parked forever.
const revealTimeout = 10 * time.Second
// RevealInFileManager opens the platform file manager with path selected,
// falling back to opening its containing directory.
//
// Writing a log file and only printing where it went is not much use to someone
// who is about to attach it to a bug report, so the export shows it instead of
// describing it.
//
// It blocks; callers should run it off the UI goroutine.
func RevealInFileManager(path string, logger *slog.Logger) error {
if logger == nil {
logger = slog.Default()
}
ctx, cancel := context.WithTimeout(context.Background(), revealTimeout)
defer cancel()
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
switch runtime.GOOS {
case "darwin":
// -R reveals rather than opens, so Finder highlights the file.
return exec.CommandContext(ctx, "open", "-R", abs).Run()
case "windows":
// explorer wants the comma glued to the flag, and exits non-zero even
// when it succeeds, so its status is deliberately ignored.
_ = exec.CommandContext(ctx, "explorer", "/select,"+abs).Run()
return nil
default:
// The freedesktop interface highlights the file; every major Linux file
// manager implements it. Fall back to opening the directory when the
// service is absent — dbus-send itself may not even be installed.
uri := "file://" + abs
dbus := exec.CommandContext(ctx, "dbus-send",
"--session", "--dest=org.freedesktop.FileManager1", "--type=method_call",
"/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems",
"array:string:"+uri, "string:tslink",
)
if err := dbus.Run(); err == nil {
return nil
} else {
logger.Debug("FileManager1.ShowItems unavailable, opening the directory",
"err", err)
}
return exec.CommandContext(ctx, "xdg-open", filepath.Dir(abs)).Run()
}
}
+257
View File
@@ -0,0 +1,257 @@
package gui
import (
"net"
"sort"
"strings"
"gioui.org/layout"
"gioui.org/widget"
"tslink/core"
)
// The services section answers "what did tslink open on this machine, and which
// server is behind it".
//
// It is built entirely from the parsed config plus the peer snapshot the app
// already holds — no multicast, no I/O on the render path. The previous LAN page
// listened for the same MOTD broadcasts tslink itself emits, which meant the
// list was assembled from packets: the same server appeared once per IP family,
// nothing deduplicated the two, and the rows were ordered by last-seen so a 1.5s
// broadcast cycle permuted them continuously. Deriving the list from config
// instead makes it exact and, because it is sorted on a stable key, still.
// serviceEntry is one local listener created by a connect rule.
type serviceEntry struct {
Name string // the rule's MOTD, or its config tag
Tag string // the [[connect.<tag>]] key
Proto string
Addr string // the local address a client points at
Port int
// Broadcast reports that this service is announced on the LAN, i.e. it
// shows up in Minecraft's server list without being typed in.
Broadcast bool
}
// serviceServer groups every local listener that targets one remote host.
type serviceServer struct {
// Host is the dst_addr hostname, already MagicDNS-qualified by the
// supervisor's NormalizeConnectRulesDstAddr pass.
Host string
// Peer is the tailnet node Host resolved to, when the peer monitor managed
// to resolve it. Nil for destinations outside the tailnet, which are
// legitimate config entries and must still render.
Peer *core.PeerInfo
Services []serviceEntry
}
// Online reports the peer's reachability, defaulting to true when the
// destination is not a tailnet peer and we therefore have nothing to say.
func (s serviceServer) Online() bool { return s.Peer == nil || s.Peer.Online }
// Title is the friendliest name available for the target.
func (s serviceServer) Title() string {
if s.Peer != nil && s.Peer.DisplayName != "" {
return s.Peer.DisplayName
}
return s.Host
}
// buildServices turns connect rules into the per-server view.
//
// Grouping is by destination host rather than by config tag: a server reached
// over both TCP and UDP is written as two tagged rules pointing at the same
// dst_addr, and the user thinks of that as one server with two services.
func buildServices(cfg *core.Config, snap core.PeerSnapshot) []serviceServer {
if cfg == nil {
return nil
}
// tag -> peer, via the links the monitor already resolved.
byTag := make(map[string]*core.PeerInfo)
for i := range snap.Peers {
pr := &snap.Peers[i]
for _, tag := range pr.LinkTags {
byTag[tag] = pr
}
}
grouped := make(map[string]*serviceServer)
for tag, rules := range cfg.Connect {
for _, rule := range rules {
host := rule.DstAddr
if h, _, err := net.SplitHostPort(rule.DstAddr); err == nil {
host = h
}
g, ok := grouped[host]
if !ok {
g = &serviceServer{Host: host, Peer: byTag[tag]}
grouped[host] = g
} else if g.Peer == nil {
g.Peer = byTag[tag]
}
g.Services = append(g.Services, serviceEntry{
Name: rule.LANMotdOr(tag),
Tag: tag,
Proto: rule.Protocol,
Addr: net.JoinHostPort(rule.BindIP(), itoa(rule.LocalPort)),
Port: rule.LocalPort,
Broadcast: rule.LANEnabled(),
})
}
}
out := make([]serviceServer, 0, len(grouped))
for _, g := range grouped {
// Stable within a server: port, then protocol for the tcp/udp pair that
// shares one.
sort.SliceStable(g.Services, func(i, j int) bool {
if g.Services[i].Port != g.Services[j].Port {
return g.Services[i].Port < g.Services[j].Port
}
return g.Services[i].Proto < g.Services[j].Proto
})
out = append(out, *g)
}
// Ordered by what is actually on screen, so the list reads alphabetically
// rather than by a hostname the user may never see. Host breaks ties and
// keeps the order total — map iteration is randomised, so without a full
// ordering the whole section would reshuffle every frame.
sort.SliceStable(out, func(i, j int) bool {
if ti, tj := out[i].Title(), out[j].Title(); ti != tj {
return ti < tj
}
return out[i].Host < out[j].Host
})
return out
}
// copyBtn lazily allocates a clickable per address.
func (p *overviewPage) copyBtn(addr string) *widget.Clickable {
if p.svcCopy == nil {
p.svcCopy = make(map[string]*widget.Clickable)
}
b, ok := p.svcCopy[addr]
if !ok {
b = new(widget.Clickable)
p.svcCopy[addr] = b
}
return b
}
func (p *overviewPage) servicesCard(a *App, gtx C, servers []serviceServer) D {
th := a.th
card := th.Card()
card.Title = th.T(KSvcTitle)
card.Subtitle = th.T(KSvcSubtitle)
return card.Layout(th, gtx, func(gtx C) D {
if len(servers) == 0 {
return th.EmptyState(gtx, IconServer, th.T(KSvcEmpty), "")
}
children := make([]layout.FlexChild, 0, len(servers)*2)
for i, srv := range servers {
if i > 0 {
children = append(children, layout.Rigid(th.Divider))
}
children = append(children, layout.Rigid(func(gtx C) D {
return p.serverGroup(a, gtx, srv)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
// serverGroup renders one target host and the services pointing at it.
func (p *overviewPage) serverGroup(a *App, gtx C, srv serviceServer) D {
th := a.th
level := LevelOK
if !srv.Online() {
level = LevelFail
}
return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D {
children := []layout.FlexChild{
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.StatusDot(gtx, level, false)
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return OneLine(th.Body(srv.Title())).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
// Only worth showing when it differs from the title,
// i.e. when the peer resolved to a nicer name.
if srv.Peer == nil || srv.Title() == srv.Host {
return D{}
}
return OneLine(th.MonoLabel(SizeCaption, th.P.TextDim, srv.Host)).Layout(gtx)
}),
)
}),
}
for _, svc := range srv.Services {
children = append(children, layout.Rigid(func(gtx C) D {
return p.serviceRow(a, gtx, svc)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
// serviceRow is the name-over-address entry: the address is the thing a user
// actually needs to type somewhere else, so it gets a monospace line of its own
// rather than being folded into the label.
func (p *overviewPage) serviceRow(a *App, gtx C, svc serviceEntry) D {
th := a.th
btn := p.copyBtn(svc.Addr)
if btn.Clicked(gtx) {
a.copyToClipboard(gtx, svc.Addr, "")
}
return layout.Inset{Top: 4, Bottom: 4, Left: SpaceLG}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return IconLink(gtx, gtx.Dp(14), th.P.TextDim)
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(OneLine(th.Body(svc.Name)).Layout),
layout.Rigid(func(gtx C) D {
if !svc.Broadcast {
return D{}
}
return layout.Inset{Left: SpaceSM}.Layout(gtx, func(gtx C) D {
return th.Chip(gtx, ChipStyle{
Text: th.T(KSvcBroadcast),
Level: LevelInfo,
})
})
}),
)
}),
layout.Rigid(func(gtx C) D {
return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, svc.Addr)).Layout(gtx)
}),
)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
if svc.Proto == "" {
return D{}
}
return th.Chip(gtx, ChipStyle{Text: strings.ToUpper(svc.Proto)})
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, btn, IconCopy, LevelNeutral)
}),
)
})
}
+106
View File
@@ -0,0 +1,106 @@
package gui
import (
"testing"
"tslink/core"
)
// TestBuildServicesGroupsByHost pins the two properties the old LAN page got
// wrong: one entry per server (not per rule, and not per IP family), and an
// order that does not depend on map iteration.
func TestBuildServicesGroupsByHost(t *testing.T) {
cfg := &core.Config{
Connect: map[string][]core.ConnectRule{
// Same destination host over two protocols: must collapse into one
// server carrying two services.
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}},
"l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}},
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565", LanMotd: "SFCraft"}},
// Not a tailnet host; it still has to render.
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
},
}
got := buildServices(cfg, core.PeerSnapshot{})
if len(got) != 3 {
t.Fatalf("want 3 servers, got %d: %+v", len(got), got)
}
// Sorted by host.
wantHosts := []string{"a.mc.example", "mc.lxns.net", "server.l4d2.example"}
for i, want := range wantHosts {
if got[i].Host != want {
t.Errorf("server[%d].Host = %q, want %q", i, got[i].Host, want)
}
}
l4d2 := got[2]
if len(l4d2.Services) != 2 {
t.Fatalf("l4d2 should carry both protocols, got %d", len(l4d2.Services))
}
if l4d2.Services[0].Proto != "tcp" || l4d2.Services[1].Proto != "udp" {
t.Errorf("services not ordered by protocol: %+v", l4d2.Services)
}
// No peer resolved: must not claim the server is down.
if !l4d2.Online() {
t.Error("a server with no resolved peer should not render as offline")
}
if l4d2.Title() != "server.l4d2.example" {
t.Errorf("Title() = %q, want the host", l4d2.Title())
}
// LANEnabled defaults to true only for minecraft.
mc := got[0]
if !mc.Services[0].Broadcast {
t.Error("a minecraft rule should be marked as broadcast")
}
if mc.Services[0].Name != "SFCraft" {
t.Errorf("Name = %q, want the lan_motd", mc.Services[0].Name)
}
if got[1].Services[0].Broadcast {
t.Error("a plain udp rule should not be marked as broadcast")
}
// Repeated builds must agree, or the section jitters between frames.
for i := 0; i < 20; i++ {
again := buildServices(cfg, core.PeerSnapshot{})
for j := range again {
if again[j].Host != got[j].Host {
t.Fatalf("ordering is unstable: %q vs %q", again[j].Host, got[j].Host)
}
}
}
}
// TestBuildServicesUsesPeer checks the enrichment path: a resolved peer supplies
// the display name and the online state.
func TestBuildServicesUsesPeer(t *testing.T) {
cfg := &core.Config{
Connect: map[string][]core.ConnectRule{
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565"}},
},
}
snap := core.PeerSnapshot{Peers: []core.PeerInfo{{
ID: "n1", DisplayName: "homelab", Online: false,
Linked: true, LinkTags: []string{"sfcraft"},
}}}
got := buildServices(cfg, snap)
if len(got) != 1 {
t.Fatalf("want 1 server, got %d", len(got))
}
if got[0].Title() != "homelab" {
t.Errorf("Title() = %q, want the peer display name", got[0].Title())
}
if got[0].Online() {
t.Error("an offline peer should make the server render as offline")
}
}
func TestBuildServicesNilConfig(t *testing.T) {
if got := buildServices(nil, core.PeerSnapshot{}); got != nil {
t.Errorf("want nil for a nil config, got %+v", got)
}
}
+310
View File
@@ -0,0 +1,310 @@
//go:build shots
package gui
import (
"image"
"image/png"
"log/slog"
"net/netip"
"os"
"testing"
"time"
"gioui.org/font"
"gioui.org/gpu/headless"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"tslink/core"
"tslink/netdiag"
)
// Offscreen renders of the changed UI, for eyeballing what no assertion can
// capture — glyph coverage at bold weights, legend wrapping, how full the chart
// looks with only a few samples.
//
// go test ./gui/ -tags shots -run TestShots
//
// Build-tagged so the normal suite stays GPU-free and font-config independent.
const shotDir = "/tmp/tslink-shots"
func shoot(t *testing.T, th *Theme, name string, size image.Point, w func(gtx C) D) {
t.Helper()
win, err := headless.NewWindow(size.X, size.Y)
if err != nil {
t.Skipf("no GPU backend: %v", err)
}
defer win.Release()
var r input.Router
ops := new(op.Ops)
// Two frames: the second takes the paths that depend on widget state.
for i := 0; i < 2; i++ {
ops.Reset()
gtx := layout.Context{
Ops: ops,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
Now: time.Now(),
Source: r.Source(),
}
paint.Fill(gtx.Ops, th.P.Bg)
w(gtx)
if err := win.Frame(ops); err != nil {
t.Fatalf("frame: %v", err)
}
}
img := image.NewRGBA(image.Rectangle{Max: size})
if err := win.Screenshot(img); err != nil {
t.Fatalf("screenshot: %v", err)
}
f, err := os.Create(shotDir + "/" + name + ".png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
t.Logf("wrote %s/%s.png", shotDir, name)
}
// realTheme builds the theme the way the app does, including the host's CJK
// font. Unlike testTheme this deliberately depends on the local font config —
// that is the thing under inspection.
func realTheme(t *testing.T) *Theme {
t.Helper()
fonts := LoadFonts()
if !fonts.HasCJK {
t.Skip("no CJK font on this host")
}
faces, err := LoadCJKFaces(fonts.CJKPath, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("cjk: %v", err)
}
fonts.Collection = append(fonts.Collection, faces...)
th := NewTheme(fonts, true)
th.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
th.Lang = LangZH
return th
}
func shotApp(t *testing.T, th *Theme) *App {
a := testApp(t)
a.th = th
return a
}
func TestShots(t *testing.T) {
if err := os.MkdirAll(shotDir, 0o755); err != nil {
t.Fatal(err)
}
th := realTheme(t)
// --- 1. CJK at every weight the UI uses -------------------------------
// The bug was that only weight 400 had a CJK face, so everything below
// rendered as tofu boxes. All five lines must show Chinese glyphs.
t.Run("cjk-weights", func(t *testing.T) {
weights := []struct {
w font.Weight
name string
}{
{font.Normal, "Normal 正文:延迟图谱 已关联 本机服务"},
{font.Medium, "Medium 按钮:重试 导出日志 刷新"},
{font.SemiBold, "SemiBold 标题:网络诊断 节点延迟"},
{font.Bold, "Bold 强调:局域网 转发规则"},
}
shoot(t, th, "cjk-weights", image.Pt(560, 200), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
children := make([]layout.FlexChild, 0, len(weights)*2)
for _, w := range weights {
children = append(children, layout.Rigid(func(gtx C) D {
l := th.Text(SizeSubtitle, th.P.TextPri, w.name)
l.Font.Weight = w.w
return l.Layout(gtx)
}), VGap(SpaceSM))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
})
})
// --- 2. Splash, normal and stuck --------------------------------------
for _, tc := range []struct {
name string
age time.Duration
}{
{"splash", 10 * time.Second},
{"splash-stuck", 45 * time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
a := shotApp(t, th)
steps := splashTestSteps()
for i := range steps {
if steps[i].State == core.StepRunning {
steps[i].Started = time.Now().Add(-tc.age)
}
}
st := core.State{
Phase: core.PhaseStarting, Steps: steps,
StartedAt: time.Now().Add(-tc.age),
}
shoot(t, th, tc.name, image.Pt(460, 450), func(gtx C) D {
return a.splash.Layout(a, gtx, st)
})
})
}
// --- 3. Chart + legend with 8 series and only 30s of history ----------
// Previously this filled ~2.5% of the plot and clipped the legend.
t.Run("chart", func(t *testing.T) {
a := shotApp(t, th)
p := a.peers
series := p.buildSeries(th, shotPeers())
shoot(t, th, "chart", image.Pt(760, 400), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return p.chartCard(a, gtx, series)
})
})
})
// --- 3b. Stat tiles: equal height with and without a hint -------------
t.Run("stat-tiles", func(t *testing.T) {
a := shotApp(t, th)
st := readyState(t)
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
{ID: "n1", DisplayName: "a", Online: true, Linked: true},
}}
servers := buildServices(st.Config, snap)
shoot(t, th, "stat-tiles", image.Pt(1000, 160), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.overview.statRow(a, gtx, st, snap, servers)
})
})
})
// --- 4. Services card grouped per server ------------------------------
t.Run("services", func(t *testing.T) {
a := shotApp(t, th)
cfg := &core.Config{Connect: map[string][]core.ConnectRule{
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "sfcraft.mc.homelab.ice:25565", LanMotd: "SFCraft Vanilla | 原版生电 1.21.8"}},
"mayday": {{Protocol: "minecraft", LocalPort: 25571, DstAddr: "mayday.mc.homelab.ice:25565"}},
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
"l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
}}
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
{ID: "n1", DisplayName: "homelab-mc", Online: true, Linked: true, LinkTags: []string{"sfcraft"}},
{ID: "n2", DisplayName: "l4d2-box", Online: false, Linked: true, LinkTags: []string{"l4d2_tcp", "l4d2_udp"}},
}}
servers := buildServices(cfg, snap)
shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.overview.servicesCard(a, gtx, servers)
})
})
})
}
// TestShotsDiag renders the UDP table, which must name servers rather than
// print bare resolved addresses.
func TestShotsDiag(t *testing.T) {
if err := os.MkdirAll(shotDir, 0o755); err != nil {
t.Fatal(err)
}
th := realTheme(t)
a := shotApp(t, th)
a.current = pageDiag
st := readyState(t)
a.diag.report = diagShotReport()
shoot(t, th, "diag-udp", image.Pt(1120, 900), func(gtx C) D {
return a.diag.Layout(a, gtx, st)
})
}
// diagShotReport is a healthy report whose only complaint is an HTTP-only
// egress split — the case that must read as a yellow "may affect", not a red
// "is affecting".
func diagShotReport() *netdiag.Report {
rep := &netdiag.Report{
StartedAt: time.Now().Add(-18 * time.Second),
Duration: 17 * time.Second,
UDP: netdiag.UDPReport{
Status: netdiag.StatusOK, Summary: "UDP 可用", V4OK: true,
CNReachable: 2, CNTotal: 2, IntlReachabl: 1, IntlTotal: 2,
Probes: []netdiag.UDPProbe{
{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米",
Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond,
Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米",
Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond,
Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")},
{Host: "stun.chat.bilibili.com:3478", Target: "203.107.1.33:3478", Name: "哔哩哔哩",
Region: netdiag.RegionCN, OK: true, RTT: 21 * time.Millisecond,
Mapped: netip.MustParseAddrPort("1.2.3.4:54322")},
{Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google",
Region: netdiag.RegionIntl, Err: "i/o timeout"},
},
},
NAT: netdiag.NATReport{
Status: netdiag.StatusOK, Type: netdiag.NATFullCone,
Mapping: netdiag.BehaviorEndpointIndependent,
Filtering: netdiag.BehaviorUnknown,
},
Overseas: netdiag.OverseasReport{Status: netdiag.StatusOK, Summary: "境外可达"},
Egress: netdiag.EgressReport{
Observations: []netdiag.EgressObservation{
{Method: netdiag.MethodSTUN, Source: "stun.miwifi.com:3478", IP: netip.MustParseAddr("1.2.3.4")},
{Method: netdiag.MethodHTTPv4, Source: "https://example/ip", IP: netip.MustParseAddr("5.6.7.8")},
},
},
}
eg := &rep.Egress
eg.UniqueIPs = []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")}
eg.Divergent = true
eg.DivergentSTUN = false
eg.Status = netdiag.StatusWarn
eg.Summary = "出口 IP 不一致:IPv4 有 2 个(1.2.3.4、5.6.7.8),仅 HTTP 探测存在差异"
rep.Status = netdiag.StatusWarn
rep.Headline = "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接"
return rep
}
// shotPeers fabricates eight linked peers with ~30 seconds of history each —
// the short-uptime case the chart used to render almost entirely blank, and
// enough series to force the legend to wrap.
func shotPeers() []core.PeerInfo {
now := time.Now()
names := []string{
"sfcraft-homelab", "mayday", "l4d2-server", "voice-relay",
"mcp2-survival", "backup-node", "gateway-cn", "storage-nas",
}
peers := make([]core.PeerInfo, 0, len(names))
for i, n := range names {
var samples []core.PeerSample
for k := 0; k < 4; k++ {
samples = append(samples, core.PeerSample{
At: now.Add(time.Duration(-30+k*10) * time.Second),
Latency: time.Duration(18+i*9+k*4) * time.Millisecond,
OK: true,
})
}
peers = append(peers, core.PeerInfo{
ID: n, DisplayName: n, Online: true, Linked: true,
LinkTags: []string{n}, Route: core.RouteDirect,
LastLatency: samples[len(samples)-1].Latency, LatencyOK: true,
Samples: samples,
})
}
return peers
}
+149 -129
View File
@@ -2,28 +2,52 @@ package gui
import (
"image"
"log/slog"
"math"
"time"
"gioui.org/f32"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
"gioui.org/op/paint"
)
// Window geometry. The splash is sized to just its progress bar and checklist —
// it has nothing else to show, and a loading screen floating in a 1120x740
// window reads as a broken main window rather than as progress. App.layout
// grows the window to the shell dimensions once the service is ready.
const (
splashWindowW unit.Dp = 460
splashWindowH unit.Dp = 450
splashMinW unit.Dp = 380
splashMinH unit.Dp = 380
shellWindowW unit.Dp = 1120
shellWindowH unit.Dp = 740
shellMinW unit.Dp = 880
shellMinH unit.Dp = 560
)
// stuckAfter is how long a single boot step may run before the splash offers
// the log export. Tailscale's first connection legitimately takes several
// seconds, so this has to be long enough not to cry wolf, but short enough that
// someone staring at a hung step is told what to do about it.
const stuckAfter = 20 * time.Second
// splashView is the loading screen. It covers the window until the service is
// up, which is also the window during which the CJK font is parsed and
// tailscale negotiates its first connection — both slow enough that showing a
// bare grey rectangle would read as a hang.
type splashView struct {
retry widget.Clickable
retry widget.Clickable
export widget.Clickable
// list keeps the panel reachable on short windows. Without it the retry
// button — the one control on this screen — falls off the bottom edge once
// the checklist and an error message are both showing.
@@ -56,6 +80,16 @@ func stepTitle(th *Theme, key string) string {
}
}
// stalled reports whether a step has been running long enough to look stuck.
func stalled(st core.State) bool {
for _, step := range st.Steps {
if step.State == core.StepRunning && step.Elapsed() >= stuckAfter {
return true
}
}
return false
}
func (s *splashView) Layout(a *App, gtx C, st core.State) D {
th := a.th
paint.Fill(gtx.Ops, th.P.Bg)
@@ -63,40 +97,49 @@ func (s *splashView) Layout(a *App, gtx C, st core.State) D {
if s.retry.Clicked(gtx) && a.opt.Supervisor != nil {
a.opt.Supervisor.Restart()
}
// The docked log sheet sits along the bottom edge, so the panel is centred
// in whatever is left above it. Reserving the space rather than stacking
// the two is the whole point: a screenshot taken mid-load has to show both
// the checklist and the log.
reserve := dockedReserve(gtx)
if maxReserve := gtx.Constraints.Max.Y / 2; reserve > maxReserve {
reserve = maxReserve
if s.export.Clicked(gtx) {
s.exportLogs(a)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
// A single-element list: centred when it fits, scrollable when the
// window is too short for the checklist plus an error message.
return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(460))
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{Top: SpaceLG, Bottom: SpaceLG}.Layout(gtx, func(gtx C) D {
return s.panel(a, gtx, st)
})
})
// A single-element list: centred when it fits, scrollable when the window is
// too short for the checklist plus an error message.
return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(400))
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{
Top: SpaceLG, Bottom: SpaceLG, Left: SpaceMD, Right: SpaceMD,
}.Layout(gtx, func(gtx C) D {
return s.panel(a, gtx, st)
})
}),
layout.Rigid(func(gtx C) D { return D{Size: image.Pt(0, reserve)} }),
)
})
})
}
// exportLogs writes the current buffer to a file and reports where it went.
// This is the splash's replacement for the live log tail: someone looking at a
// stuck boot needs the log in a file they can attach, not on screen.
func (s *splashView) exportLogs(a *App) {
if a.opt.Logs == nil {
return
}
content := a.opt.Logs.ExportText(core.ExportOptions{
Header: a.diagnosticHeader(),
// Debug and up: a stuck boot is exactly when the quiet records matter.
Query: core.LogQuery{MinLevel: slog.LevelDebug},
})
path, err := saveLogFile(content)
if err != nil {
a.notify(a.th.T(KError)+": "+err.Error(), LevelFail)
return
}
a.notify(path, LevelOK)
a.reveal(path)
}
func (s *splashView) panel(a *App, gtx C, st core.State) D {
th := a.th
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return s.pulse(a, gtx, st)
}),
VGap(SpaceMD),
layout.Rigid(func(gtx C) D {
l := th.Text(SizeDisplay, th.P.TextPri, "tslink")
l.Font.Weight = font.Bold
@@ -122,49 +165,6 @@ func (s *splashView) panel(a *App, gtx C, st core.State) D {
)
}
// pulse draws concentric rings radiating from a solid core. Three rings offset
// in phase read as continuous motion without a spinning element, which suits a
// "connecting to a network" wait better than a rotating arc.
func (s *splashView) pulse(a *App, gtx C, st core.State) D {
th := a.th
size := gtx.Dp(64)
center := f32.Pt(float32(size)/2, float32(size)/2)
col := th.P.Accent
switch st.Phase {
case core.PhaseError:
col = th.P.Fail
case core.PhaseRetrying:
col = th.P.Warn
}
const period = 2400 * time.Millisecond
base := float32(gtx.Dp(14))
grow := float32(size)/2 - base
if st.Phase != core.PhaseError {
phase := float64(gtx.Now.UnixNano()%int64(period)) / float64(period)
for i := 0; i < 3; i++ {
p := math.Mod(phase+float64(i)/3, 1)
r := base + grow*float32(p)
// Ease the fade so rings vanish before they hit the edge.
alpha := float32(1-p) * 0.55
drawArc(gtx, center, r, float32(gtx.Dp(1.5)), 0, 2*math.Pi, WithAlpha(col, alpha))
}
// A 2.4s cycle does not need 25fps, and this is the one animation that
// can legitimately run for minutes while tailscale negotiates.
animateSlow(gtx)
}
// Solid core.
d := gtx.Dp(22)
off := op.Offset(image.Pt((size-d)/2, (size-d)/2)).Push(gtx.Ops)
Circle(gtx, d, col)
off.Pop()
return D{Size: image.Pt(size, size)}
}
func (s *splashView) checklist(a *App, gtx C, st core.State) D {
th := a.th
children := make([]layout.FlexChild, 0, len(st.Steps))
@@ -226,11 +226,19 @@ func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D {
)
}),
layout.Rigid(func(gtx C) D {
if step.State != core.StepDone || step.Elapsed() < 100*time.Millisecond {
// A running step shows its timer once it is slow enough to be
// worth watching; a finished one shows what it cost.
switch {
case step.State == core.StepRunning && step.Elapsed() >= time.Second:
case step.State == core.StepDone && step.Elapsed() >= 100*time.Millisecond:
default:
return D{}
}
return th.MonoLabel(SizeCaption, th.P.TextDim,
FormatLatency(step.Elapsed())).Layout(gtx)
col := th.P.TextDim
if step.State == core.StepRunning && step.Elapsed() >= stuckAfter {
col = th.P.Warn
}
return th.MonoLabel(SizeCaption, col, FormatLatency(step.Elapsed())).Layout(gtx)
}),
)
})
@@ -238,60 +246,72 @@ func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D {
func (s *splashView) footer(a *App, gtx C, st core.State) D {
th := a.th
stuck := stalled(st)
return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D {
switch st.Phase {
case core.PhaseError:
// Error text and the retry button sit side by side: stacking them
// pushes the only control on this screen below the fold on a short
// window.
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
l := th.Text(SizeCaption, th.P.Fail, st.Err)
l.MaxLines = 4
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
switch st.Phase {
case core.PhaseError:
// Error text and the retry button sit side by side: stacking
// them pushes the only control on this screen below the fold
// on a short window.
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
l := th.Text(SizeCaption, th.P.Fail, st.Err)
l.MaxLines = 4
return l.Layout(gtx)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = 0
return th.Button(gtx, &s.retry, ButtonStyle{
Kind: ButtonPrimary,
Text: th.T(KRetry),
Icon: IconRefresh,
})
}),
)
case core.PhaseRetrying:
msg := th.T(KSplashRetry)
if st.Err != "" {
msg = st.Err
}
l := th.Text(SizeCaption, th.P.Warn, msg)
l.Alignment = text.Middle
l.MaxLines = 3
return l.Layout(gtx)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = 0
return th.Button(gtx, &s.retry, ButtonStyle{
Kind: ButtonPrimary,
Text: th.T(KRetry),
Icon: IconRefresh,
})
}),
)
case core.PhaseRetrying:
msg := th.T(KSplashRetry)
if st.Err != "" {
msg = st.Err
}
l := th.Text(SizeCaption, th.P.Warn, msg)
l.Alignment = text.Middle
l.MaxLines = 3
return l.Layout(gtx)
default:
l := th.Caption(th.T(KSplashHint))
l.Alignment = text.Middle
return l.Layout(gtx)
}
default:
hint, col := th.T(KSplashHint), th.P.TextDim
if stuck {
hint, col = th.T(KSplashStuckHint), th.P.Warn
}
l := th.Text(SizeCaption, col, hint)
l.Alignment = text.Middle
l.MaxLines = 3
return l.Layout(gtx)
}
}),
VGap(SpaceMD),
layout.Rigid(func(gtx C) D {
if a.opt.Logs == nil {
return D{}
}
// Promoted once something looks stuck: that is the moment the
// log is worth exporting.
kind := ButtonGhost
if stuck || st.Phase == core.PhaseError {
kind = ButtonSubtle
}
gtx.Constraints.Min.X = 0
return th.Button(gtx, &s.export, ButtonStyle{
Kind: kind,
Text: th.T(KSplashExportLog),
Icon: IconSave,
})
}),
)
})
}
// ---------------------------------------------------------------------------
// Shared: a translucent panel backdrop
// ---------------------------------------------------------------------------
// glassPanel fills the current bounds with a translucent surface plus border.
// It is what makes the log overlay readable over whatever is behind it while
// still showing that something is behind it.
func glassPanel(t *Theme, gtx C, size image.Point, radius float32) {
r := int(radius)
bg := t.P.BgElevated
bg.A = 0xE0
paint.FillShape(gtx.Ops, bg, clip.UniformRRect(image.Rectangle{Max: size}, r).Op(gtx.Ops))
spec := clip.UniformRRect(image.Rectangle{Max: size}, r).Path(gtx.Ops)
paint.FillShape(gtx.Ops, WithAlpha(t.P.BorderHi, 0.8),
clip.Stroke{Path: spec, Width: 1}.Op())
}
+58 -9
View File
@@ -139,21 +139,12 @@ func Circle(gtx C, diameter int, col color.NRGBA) D {
// window must settle at zero frames per second, not a slow trickle.
const animFrame = 40 * time.Millisecond
// animSlowFrame is the cadence for ambient motion with a multi-second cycle,
// where 12fps is indistinguishable from 25 but costs half as much.
const animSlowFrame = 80 * time.Millisecond
// animate requests the next animation frame at the capped rate. Every animated
// widget in this package goes through it.
func animate(gtx C) {
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animFrame)})
}
// animateSlow is [animate] for slow, decorative motion.
func animateSlow(gtx C) {
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animSlowFrame)})
}
// Spacer returns a fixed-size gap.
func Spacer(v unit.Dp) layout.Spacer { return layout.Spacer{Height: v, Width: v} }
@@ -167,6 +158,64 @@ func HGap(v unit.Dp) layout.FlexChild {
return layout.Rigid(layout.Spacer{Width: v}.Layout)
}
// WrapRow lays children out left to right, starting a new line whenever the
// next child would not fit. gap is the vertical space between lines; horizontal
// spacing is left to the children's own insets.
//
// Gio's Flex does not wrap — it divides the available space among its children
// and lets the overflow clip — and gioui.org/x (which has outlay.FlowWrap) is
// not a dependency, so this measures each child with op.Record and packs the
// results greedily. Children are recorded once and replayed at their final
// offset, so the cost is one layout pass, not two.
func WrapRow(gtx C, gap unit.Dp, children []layout.Widget) D {
if len(children) == 0 {
return D{}
}
maxW := gtx.Constraints.Max.X
// Each child is measured against the full width but with no minimum, so a
// child wider than the row still gets a line to itself rather than a
// negative constraint.
cgtx := gtx
cgtx.Constraints.Min = image.Point{}
type placed struct {
call op.CallOp
dims D
x, y int
}
var (
items []placed
rowW, rowH int
total, lineNo int
)
vgap := gtx.Dp(gap)
for _, w := range children {
macro := op.Record(gtx.Ops)
dims := w(cgtx)
call := macro.Stop()
if rowW > 0 && rowW+dims.Size.X > maxW {
// Commit the line and start the next one.
total += rowH + vgap
rowW, rowH = 0, 0
lineNo++
}
items = append(items, placed{call: call, dims: dims, x: rowW, y: total})
rowW += dims.Size.X
rowH = max(rowH, dims.Size.Y)
}
total += rowH
for _, it := range items {
off := op.Offset(image.Pt(it.x, it.y)).Push(gtx.Ops)
it.call.Add(gtx.Ops)
off.Pop()
}
return D{Size: image.Pt(maxW, total)}
}
// Divider draws a hairline separator.
func (t *Theme) Divider(gtx C) D {
h := max(gtx.Dp(1), 1)
+45 -8
View File
@@ -130,6 +130,7 @@ func ProbeEgress(ctx context.Context, stunResults []STUNResult, logger *slog.Log
egSortObservations(rep.Observations)
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
log.With(
@@ -221,17 +222,26 @@ func egSortObservations(os []EgressObservation) {
// egUniqueIPs returns the deduplicated, sorted set of valid addresses.
func egUniqueIPs(os []EgressObservation) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(os))
var out []netip.Addr
ips := make([]netip.Addr, 0, len(os))
for _, o := range os {
if !o.IP.IsValid() {
ips = append(ips, o.IP)
}
return egDedupAddrs(ips)
}
// egDedupAddrs drops invalid and repeated addresses and sorts the rest.
func egDedupAddrs(ips []netip.Addr) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(ips))
var out []netip.Addr
for _, ip := range ips {
if !ip.IsValid() {
continue
}
if _, dup := seen[o.IP]; dup {
if _, dup := seen[ip]; dup {
continue
}
seen[o.IP] = struct{}{}
out = append(out, o.IP)
seen[ip] = struct{}{}
out = append(out, ip)
}
sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 })
return out
@@ -261,6 +271,22 @@ func egDivergent(ips []netip.Addr) bool {
return len(v4) > 1 || len(v6) > 1
}
// egDivergentSTUN applies the same test to the STUN observations alone.
//
// Only these travel the UDP path Tailscale actually uses, so a split visible
// here is the one that costs you a direct connection. HTTP-only disagreement
// says something about the browser path, not the tunnel.
func egDivergentSTUN(obs []EgressObservation) bool {
var ips []netip.Addr
for _, o := range obs {
if o.Method != MethodSTUN || o.Err != "" || !o.IP.IsValid() {
continue
}
ips = append(ips, o.IP.Unmap())
}
return egDivergent(egDedupAddrs(ips))
}
// egFinish derives Status and the one-line Chinese Summary from the collected
// addresses. It is called again by [AnnotateGeo] once geolocation is known, so
// it must stay idempotent.
@@ -270,6 +296,10 @@ func egFinish(rep *EgressReport) {
switch {
case len(rep.UniqueIPs) == 0:
rep.Status = StatusFail
case rep.DivergentSTUN:
// The UDP egress itself varies, which is what actually costs a direct
// connection — a stronger claim than "some probe disagreed".
rep.Status = StatusFail
case rep.Divergent:
rep.Status = StatusWarn
default:
@@ -291,8 +321,15 @@ func egFinish(rep *EgressReport) {
if len(v6) > 1 {
parts = append(parts, fmt.Sprintf("IPv6 有 %d 个(%s", len(v6), egJoinAddrs(v6, 4)))
}
fmt.Fprintf(&b, "出口 IP 不一致:%s,代理、VPN 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路",
strings.Join(parts, ""))
if rep.DivergentSTUN {
fmt.Fprintf(&b, "出口 IP 不一致:%s,STUN 探测本身就看到多个地址,代理、VPN 或多线接入正在拆分 UDP 流量,对端看到的地址取决于走哪条链路",
strings.Join(parts, ""))
} else {
// HTTP saw a split that STUN did not: the web path is proxied but
// the UDP path Tailscale uses may well be intact.
fmt.Fprintf(&b, "出口 IP 不一致:%s,仅 HTTP 探测存在差异,STUN(UDP)出口一致,多为浏览器代理或分流规则所致,通常不影响打洞",
strings.Join(parts, ""))
}
default:
var parts []string
+154
View File
@@ -0,0 +1,154 @@
package netdiag
import (
"net/netip"
"testing"
)
func obs(m EgressMethod, ip string) EgressObservation {
o := EgressObservation{Method: m}
if ip != "" {
o.IP = netip.MustParseAddr(ip)
}
return o
}
// TestEgressDivergenceSeverity pins the distinction the verdict depends on:
// STUN disagreeing with itself is a hard failure for hole punching, whereas
// HTTP-only disagreement is a proxy artefact and must stay a warning.
func TestEgressDivergenceSeverity(t *testing.T) {
cases := []struct {
name string
obs []EgressObservation
wantDivergent bool
wantSTUN bool
wantStatus Status
}{
{
name: "single egress",
obs: []EgressObservation{obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv4, "1.2.3.4")},
wantStatus: StatusOK,
},
{
name: "dual stack is not divergence",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv6, "2001:db8::1"),
},
wantStatus: StatusOK,
},
{
name: "http-only split warns",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodHTTPv4, "5.6.7.8"),
},
wantDivergent: true,
wantSTUN: false,
wantStatus: StatusWarn,
},
{
name: "stun split fails",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodSTUN, "5.6.7.8"),
},
wantDivergent: true,
wantSTUN: true,
wantStatus: StatusFail,
},
{
name: "proxy split alone stays a warning",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodHTTPProxy, "9.9.9.9"),
},
wantDivergent: true,
wantSTUN: false,
wantStatus: StatusWarn,
},
{
name: "no observations fails",
obs: []EgressObservation{obs(MethodSTUN, "")},
wantStatus: StatusFail,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := EgressReport{Observations: tc.obs}
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
if rep.Divergent != tc.wantDivergent {
t.Errorf("Divergent = %v, want %v", rep.Divergent, tc.wantDivergent)
}
if rep.DivergentSTUN != tc.wantSTUN {
t.Errorf("DivergentSTUN = %v, want %v", rep.DivergentSTUN, tc.wantSTUN)
}
if rep.Status != tc.wantStatus {
t.Errorf("Status = %v, want %v (summary: %s)", rep.Status, tc.wantStatus, rep.Summary)
}
})
}
}
// A STUN observation that errored carries no address and must not be mistaken
// for a second egress.
func TestEgressDivergentSTUNIgnoresErrors(t *testing.T) {
o := []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
{Method: MethodSTUN, Err: "timeout"},
}
if egDivergentSTUN(o) {
t.Error("a failed STUN probe must not count as a second egress IP")
}
}
// egFinish runs again after geolocation, so it must not drift.
func TestEgFinishIdempotent(t *testing.T) {
rep := EgressReport{Observations: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"), obs(MethodSTUN, "5.6.7.8"),
}}
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
first, status := rep.Summary, rep.Status
egFinish(&rep)
if rep.Summary != first || rep.Status != status {
t.Errorf("egFinish is not idempotent:\n first: %s (%v)\nsecond: %s (%v)",
first, status, rep.Summary, rep.Status)
}
}
// TestHeadlineDivergence checks the two verdict strings the user sees.
//
// The report is otherwise healthy: earlier branches (blocked UDP, symmetric
// NAT, unreachable overseas) all outrank egress and would mask it.
func healthyReport(eg EgressReport) *Report {
return &Report{
UDP: UDPReport{V4OK: true},
NAT: NATReport{Type: NATFullCone},
Overseas: OverseasReport{Status: StatusOK},
Egress: eg,
}
}
func TestHeadlineDivergence(t *testing.T) {
strong := healthyReport(EgressReport{Divergent: true, DivergentSTUN: true})
if got, lvl := headline(strong); got != "STUN 检测到多个出口 IP,代理或分流工具正在影响连接" {
t.Errorf("strong headline = %q", got)
} else if lvl != StatusFail {
t.Errorf("strong headline severity = %v, want fail", lvl)
}
weak := healthyReport(EgressReport{Divergent: true})
if got, lvl := headline(weak); got != "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接" {
t.Errorf("weak headline = %q", got)
} else if lvl != StatusWarn {
t.Errorf("weak headline severity = %v, want warn", lvl)
}
}
+38 -17
View File
@@ -214,7 +214,7 @@ func Run(ctx context.Context, opt Options) *Report {
rep.Egress.Status,
rep.Tailscale.Status,
)
rep.Headline = headline(rep)
rep.Headline, rep.HeadlineStatus = headline(rep)
logger.Info("diagnostics finished",
"took", rep.Duration.Round(time.Millisecond),
"status", rep.Status.String(),
@@ -232,29 +232,40 @@ func stepTitle(key string) string {
return key
}
// headline picks the single most consequential finding. The ordering is by how
// badly each condition breaks the thing this app exists to do — carry game
// traffic between peers — not by section order.
func headline(r *Report) string {
// headline picks the single most consequential finding, together with that
// sentence's own severity. The ordering is by how badly each condition breaks
// the thing this app exists to do — carry game traffic between peers — not by
// section order.
//
// The severity is returned separately because Report.Status is the worst of
// every section: an unrelated port-mapping failure would otherwise render a
// "may be affecting" headline in the same red as "is affecting", which is
// exactly the overstatement this split exists to prevent.
func headline(r *Report) (string, Status) {
switch {
case r.NAT.Type == NATUDPBlocked:
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继"
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继", StatusFail
case !r.UDP.V4OK && !r.UDP.V6OK:
return "UDP 探测全部失败,请检查防火墙或网络策略"
return "UDP 探测全部失败,请检查防火墙或网络策略", StatusFail
case r.NAT.Type == NATSymmetric:
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继"
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继", StatusFail
case r.Overseas.Status == StatusFail:
return "无法访问任何外部网络"
return "无法访问任何外部网络", StatusFail
case r.Overseas.Status == StatusWarn:
return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响"
return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响", StatusWarn
case r.Egress.DivergentSTUN:
// STUN itself saw several egress addresses: the UDP path Tailscale uses
// really does vary per flow.
return "STUN 检测到多个出口 IP,代理或分流工具正在影响连接", StatusFail
case r.Egress.Divergent:
return "检测到多个出口 IP,代理或分流工具正在影响连接"
// Only the web path disagreed; UDP may well be intact.
return "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接", StatusWarn
case r.PortMap.Status == StatusWarn && r.NAT.Type == NATPortRestrict:
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般"
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般", StatusWarn
case r.Status == StatusOK:
return "网络状况良好,具备直连条件"
return "网络状况良好,具备直连条件", StatusOK
default:
return "诊断完成,存在若干需要注意的项目"
return "诊断完成,存在若干需要注意的项目", r.Status
}
}
@@ -319,7 +330,15 @@ func (r *Report) Text() string {
status = "OK"
detail = p.Mapped.String() + " " + p.RTT.Round(time.Millisecond).String()
}
w(" %-4s %-34s %-5s %s\n", status, p.Target, p.Region, detail)
// Name the server, then the address actually probed — a shared bundle
// has to be readable without the reader resolving IPs by hand.
target := p.Host
if target == "" {
target = p.Target
} else if p.Target != "" && p.Target != p.Host {
target += " (" + p.Target + ")"
}
w(" %-4s %-46s %-5s %s\n", status, target, p.Region, detail)
}
b.WriteByte('\n')
@@ -389,8 +408,10 @@ func (r *Report) Text() string {
if r.Egress.Summary != "" {
w("%s\n", r.Egress.Summary)
}
if r.Egress.Divergent {
w("!! 不同探测方式得到了不同的公网 IP,通常说明有代理或分流在生效\n")
if r.Egress.DivergentSTUN {
w("!! STUN(UDP) 本身看到多个公网 IP,直连打洞会受影响\n")
} else if r.Egress.Divergent {
w("!! 仅 HTTP 探测得到了不同的公网 IP,STUN(UDP) 出口一致,通常不影响打洞\n")
}
for _, o := range r.Egress.Observations {
val := o.IP.String()
+3 -1
View File
@@ -647,6 +647,7 @@ func ProbeUDP(ctx context.Context, servers []STUNServer, logger *slog.Logger) UD
mu.Lock()
per[i] = []udpAttempt{{
probe: UDPProbe{
Host: srv.Host,
Target: srv.Host,
Name: srv.Name,
Region: srv.Region,
@@ -734,6 +735,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [
if err != nil {
return []udpAttempt{{
probe: UDPProbe{
Host: srv.Host,
Target: srv.Host,
Name: srv.Name,
Region: srv.Region,
@@ -756,7 +758,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [
doneV4 = true
}
dst := netip.AddrPortFrom(a, port)
p := UDPProbe{Target: dst.String(), Name: srv.Name, Region: srv.Region, Port: int(port)}
p := UDPProbe{Host: srv.Host, Target: dst.String(), Name: srv.Name, Region: srv.Region, Port: int(port)}
pctx, cancel := context.WithTimeout(ctx, stunProbeTimeout)
msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval)
cancel()
+19
View File
@@ -130,6 +130,11 @@ type STUNResult struct {
// UDPProbe is a plain "can I send and receive UDP here" datapoint.
type UDPProbe struct {
// Host is the configured "hostname:port", kept alongside the resolved
// Target so the UI can name the server rather than an anonymous address.
Host string
// Target is the address actually probed, "ip:port". A server reachable over
// both families yields one probe per family, and only this tells them apart.
Target string
Name string
Region Region
@@ -317,6 +322,15 @@ type EgressReport struct {
// intercepting part of the traffic. Having both an IPv4 and an IPv6 egress
// is ordinary dual stack and does not set this.
Divergent bool
// DivergentSTUN narrows Divergent to the case that actually breaks NAT
// traversal: STUN itself — plain UDP, the same path Tailscale punches
// through — saw more than one address in a family. That means the UDP
// egress genuinely varies per flow.
//
// Divergence seen only by the HTTP probes is a weaker signal. An HTTP proxy
// or split-tunnel rule can rewrite web traffic while leaving UDP alone, so
// it warrants a warning, not a verdict.
DivergentSTUN bool
// Countries is the set of distinct countries seen, sorted.
Countries []string
Status Status
@@ -386,6 +400,11 @@ type Report struct {
// Headline is the single most important sentence about this report.
Headline string
// HeadlineStatus is the severity of Headline specifically, which is not
// always Status. Status is the worst of every section, so a report with an
// unrelated failure elsewhere would otherwise paint a merely-cautionary
// headline in alarm red and overstate what was actually found.
HeadlineStatus Status
// Status is the worst status across all sections.
Status Status
}