This commit is contained in:
iceBear67
2026-07-26 20:44:49 +08:00
parent ae79082481
commit 7eb35f82b1
29 changed files with 2000 additions and 1463 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{})
-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 {
+91 -27
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
@@ -71,17 +70,22 @@ type App struct {
overview *overviewPage
peers *peersPage
lan *lanPage
diag *diagPage
logs *logsPage
settings *settingsPage
splash *splashView
overlay *logOverlay
overlayBtn widget.Clickable
themeBtn widget.Clickable
// grown records that the window has already been resized from its compact
// splash dimensions to the full shell. The splash sizes the window to just
// its progress bar and checklist, so the transition to the shell has to grow
// it — exactly once, or a user who resized the window would have it snapped
// back every time the service restarted. Atomic because the resize runs on
// the watch goroutine, not the UI one.
grown atomic.Bool
toastMsg string
toastLevel StatusLevel
toastUntil time.Time
@@ -121,19 +125,16 @@ 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
}
@@ -141,15 +142,24 @@ func New(opt Options) *App {
// closes.
func (a *App) Run(ctx context.Context) error {
w := new(app.Window)
// Opens at splash size; grown to the shell dimensions below once the
// service is ready.
w.Option(
app.Title("tslink"),
app.Size(unit.Dp(1120), unit.Dp(740)),
app.MinSize(unit.Dp(880), unit.Dp(560)),
app.Size(splashWindowW, splashWindowH),
app.MinSize(splashMinW, splashMinH),
)
a.win = w
go a.watch(ctx, w)
go a.upgradeFonts()
// Ctrl+C at the terminal cancels ctx. Without this the supervisor tears
// down but the window survives, dropping the user back to the splash — the
// GUI is the process, so cancelling it has to close the window too.
go func() {
<-ctx.Done()
w.Perform(system.ActionClose)
}()
var ops op.Ops
for {
@@ -161,9 +171,33 @@ func (a *App) Run(ctx context.Context) error {
a.applyFontUpgrade()
a.layout(gtx)
e.Frame(gtx.Ops)
// After the frame, so the resize is not applied midway through
// laying one out against the old constraints. Once grown this is a
// single atomic load.
if !a.grown.Load() && a.state().Ready() {
a.grow(w)
}
}
}
}
// grow resizes the window from its compact splash dimensions to the full shell,
// once, when the service comes up.
//
// It must run on the goroutine that drives the event loop. On Linux the driver
// executes Window.Option's work inline on the calling goroutine rather than
// handing it to a UI thread, so calling this from anywhere else would mutate
// driver state concurrently with event dispatch.
func (a *App) grow(w *app.Window) {
if a.grown.Swap(true) {
return
}
w.Option(
app.Size(shellWindowW, shellWindowH),
app.MinSize(shellMinW, shellMinH),
)
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
@@ -295,6 +329,18 @@ 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
@@ -325,9 +371,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)
}
@@ -341,10 +384,6 @@ func (a *App) layout(gtx C) D {
}
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 +418,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 +573,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 +704,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
+29 -56
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: "设置",
@@ -254,7 +244,8 @@ var zhStrings = [kCount]string{
KStepMonitors: "启动状态监控",
KStepReady: "准备就绪",
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
KSplashLogHint: "实时日志(截图时可一并保留)",
KSplashStuckHint: "当前步骤耗时异常,可导出日志以便排查",
KSplashExportLog: "导出日志",
KSplashRetry: "启动失败,正在重试",
KYes: "是",
@@ -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",
@@ -447,7 +428,8 @@ var enStrings = [kCount]string{
KStepMonitors: "Starting monitors",
KStepReady: "Ready",
KSplashHint: "The first tailnet join can take a dozen seconds",
KSplashLogHint: "Live log (stays visible in screenshots)",
KSplashStuckHint: "This step is taking unusually long — export the log to investigate",
KSplashExportLog: "Export log",
KSplashRetry: "Startup failed, retrying",
KYes: "Yes",
@@ -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()
}
+177 -10
View File
@@ -2,6 +2,7 @@ package gui
import (
"context"
"net/netip"
"sort"
"strings"
"sync"
@@ -188,7 +189,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 +397,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 +541,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 +623,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 +719,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 +762,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 +866,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)
}),
)
})
}
+1
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) {
+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)
}
// 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)
+61 -40
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 {
+82 -16
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"gioui.org/app"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
@@ -95,7 +96,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 +105,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 +138,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 +203,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 +276,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 +320,55 @@ func TestTrFallsBackToEnglish(t *testing.T) {
}
}
}
// TestGrowOnce guards the window-resize latch. Growing more than once would
// snap a window the user had deliberately resized back to the shell default
// every time the service restarted.
func TestGrowOnce(t *testing.T) {
a := testApp(t)
// A Window with no driver queues options instead of touching a display,
// which is what makes this testable without one.
w := new(app.Window)
if a.grown.Load() {
t.Fatal("a fresh App must not be marked grown")
}
a.grow(w)
if !a.grown.Load() {
t.Fatal("grow() must latch")
}
// Must be a no-op now.
a.grow(w)
a.grow(w)
}
// 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
}
+111 -91
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
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.
// 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.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}.Layout(gtx, func(gtx C) D {
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,12 +246,16 @@ 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 {
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.
// 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)
@@ -272,26 +284,34 @@ func (s *splashView) footer(a *App, gtx C, st core.State) D {
return l.Layout(gtx)
default:
l := th.Caption(th.T(KSplashHint))
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)
+44 -7
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 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路",
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
}