diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c2e85a..a72e5d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,34 @@ on: types: [published] jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.26.3' + cache: true + + # The gui package imports gioui.org/app, which needs the X11/Wayland/EGL + # headers even to compile. Without them `go vet ./...` cannot typecheck it. + - name: Install Gio build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + pkg-config libwayland-dev libx11-dev libx11-xcb-dev libxkbcommon-dev \ + libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev libffi-dev \ + libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxxf86vm-dev \ + libvulkan-dev + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race ./... + build: runs-on: ubuntu-latest env: @@ -71,6 +99,75 @@ jobs: name: tslink-${{ steps.version.outputs.version }}-${{ matrix.goos }}_${{ matrix.goarch }} path: release/ + # The GUI cannot be cross-compiled the way the headless binary is: Gio needs + # CGO for X11/EGL on Linux and Cocoa on macOS, so each target is built on its + # own runner. Windows is the exception and builds without CGO. + build-gui: + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, goos: linux, goarch: amd64, cgo: 1} + - {os: ubuntu-24.04-arm, goos: linux, goarch: arm64, cgo: 1} + - {os: windows-latest, goos: windows, goarch: amd64, cgo: 0} + - {os: macos-latest, goos: darwin, goarch: arm64, cgo: 1} + - {os: macos-13, goos: darwin, goarch: amd64, cgo: 1} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.26.3' + cache: true + + - name: Install Gio build dependencies + if: matrix.goos == 'linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + pkg-config libwayland-dev libx11-dev libx11-xcb-dev libxkbcommon-dev \ + libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev libffi-dev \ + libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxxf86vm-dev \ + libvulkan-dev + + - name: Determine version + id: version + shell: bash + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + else + echo "version=dev$(date +'%y%m%d')" >> $GITHUB_OUTPUT + fi + + - name: Build GUI + shell: bash + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: ${{ matrix.cgo }} + run: | + BINARY_NAME="tslink-gui" + LDFLAGS="-s -w -X main.Version=${{ steps.version.outputs.version }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + BINARY_NAME="${BINARY_NAME}.exe" + # -H=windowsgui suppresses the console window that would otherwise + # open behind the app. + LDFLAGS="$LDFLAGS -H=windowsgui" + fi + go build -v -trimpath -buildvcs=false \ + -o "release/${BINARY_NAME}" -ldflags="$LDFLAGS" ./cmd/tslink-gui + + # Deliberately not UPX-compressed: packed GUI binaries trip antivirus + # heuristics on Windows and break code signing on macOS. + - name: Upload Artifact + uses: actions/upload-artifact@v5 + with: + name: tslink-gui-${{ steps.version.outputs.version }}-${{ matrix.goos }}_${{ matrix.goarch }} + path: release/ + container: if: github.event_name == 'release' runs-on: ubuntu-latest @@ -114,7 +211,7 @@ jobs: release: if: github.event_name == 'release' - needs: build + needs: [build, build-gui] runs-on: ubuntu-latest permissions: contents: write @@ -126,13 +223,16 @@ jobs: for dir in */; do dir=${dir%/} plat=$(echo "$dir" | grep -oE '(linux|windows|darwin)_(amd64|arm64)$') - version=$(echo "$dir" | sed "s/^tslink-//; s/-${plat}$//") + [ -n "$plat" ] || continue + # Artifact dirs are tslink-- or tslink-gui--. + name=$(echo "$dir" | sed -E "s/-[^-]+-${plat}$//") + version=$(echo "$dir" | sed -E "s/^${name}-//; s/-${plat}$//") platform=$(echo "$plat" | tr '_' '-') - for bin in "$dir"/tslink "$dir"/tslink.exe; do + for bin in "$dir/$name" "$dir/$name.exe"; do if [ -f "$bin" ]; then case "$bin" in - *.exe) mv "$bin" "$dir/tslink-${version}-${platform}.exe" ;; - *) mv "$bin" "$dir/tslink-${version}-${platform}" ;; + *.exe) mv "$bin" "$dir/${name}-${version}-${platform}.exe" ;; + *) mv "$bin" "$dir/${name}-${version}-${platform}" ;; esac fi done diff --git a/README.md b/README.md index a89c393..135c694 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ - **MagicDNS 主机名补全**:`dst_addr` 支持按照 Tailscale 规则正确解析 Split DNS 和 Magic DNS - **连接类型识别**:区分 `direct` 直连与 `derp` 中继,便于排查延迟问题 - **对端连通性诊断**:定期 ping 目标节点并报告延迟与连接路径(direct/DERP) +- **原生图形界面**:可选的 `tslink-gui`,基于 [Gio](https://gioui.org) 绘制,无 WebView、单文件、跨平台 +- **网络诊断**:NAT 类型判定(RFC 5780)、UDP 连通性、UPnP/NAT-PMP/PCP、境外可达性、出口 IP 与归属地 - **Web 管理**:内置 Tailscale Web Client(端口 `5252`),可在线管理节点配置 - **多配置源**:支持本地 TOML 文件、HTTP/HTTPS URL、构建时注入默认 URL @@ -32,6 +34,24 @@ tslink -c config.toml ``` +## 图形界面 + +如果你更习惯图形界面,下载 `tslink-gui` 并用同一份配置启动: + +```bash +tslink-gui -c config.toml +``` + +它在同一个进程里运行完整的转发服务,并额外提供: + +- **节点**:所关联 Tailscale 节点的在线状态、链路类型(直连 / DERP / 对等中继)与**延迟图谱** +- **局域网**:监听 `224.0.2.60:4445`,列出局域网内广播的 Minecraft 服务器,并标出哪些是 tslink 自己转发的 +- **网络诊断**:NAT 类型、UDP 连通性、本机全部 IPv4/IPv6 出口、UPnP/NAT-PMP/PCP、境外连通性(`cp.cloudflare.com`)、出口 IP 与归属地 +- **日志**:全量检索、过滤,一键复制或**上传到公共 paste 服务**生成分享链接 +- 启动过程中日志以半透明浮层呈现,截图求助时无需再单独翻日志 + +`tslink-gui` 与无界面的 `tslink` 是两个独立的二进制:服务器和容器部署继续用后者,它不含任何图形依赖。 + ## 容器部署 ```bash diff --git a/USAGE.md b/USAGE.md index 47fb352..b247346 100644 --- a/USAGE.md +++ b/USAGE.md @@ -159,3 +159,84 @@ docker run -d \ ghcr.io/saltedfishclub/tslink:latest \ -c /etc/tslink/config.toml ``` + +## 图形界面 `tslink-gui` + +`tslink-gui` 是可选的桌面前端,使用 [Gio](https://gioui.org) 直接绘制界面——不含 WebView、不打包浏览器,Linux / macOS / Windows 各是一个原生可执行文件(约 30 MB)。 + +它在自身进程内运行与无界面版**完全相同**的转发服务,因此配置文件、规则语义和行为都一致: + +```bash +tslink-gui -c config.toml +``` + +### 命令行参数 + +除下列参数外,`-c`、`-config-url`、`-level`、`-json-format`、`-diagnose` 与无界面版含义相同。 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `-light` | `false` | 以浅色主题启动(默认深色) | +| `-ipinfo-token` | `$IPINFO_TOKEN` | ipinfo.io 的 API Token,可选,用于提高归属地查询的速率限制 | +| `-pprof` | 空 | 在指定地址暴露 `net/http/pprof`,如 `127.0.0.1:6060`。仅允许回环地址 | + +无论 `-level` 设为什么,界面内的日志缓冲区**始终按 debug 级别**记录最近 20000 条,所以出问题后不必重启加 `-level debug` 再复现一次。 + +### 界面说明 + +| 页面 | 内容 | +|------|------| +| **概览** | 在线节点数、局域网服务器数、规则数、运行时长,以及最近一次诊断的结论 | +| **节点** | 每个 Tailscale 节点的在线状态、链路类型、实时延迟、抖动与丢包;顶部为多节点**延迟图谱**(最近 20 分钟,鼠标悬停可查看某一时刻的取值,点击图例可隐藏某个节点) | +| **局域网** | 监听 `224.0.2.60:4445` / `[ff75:230::60]:4445` 的 Minecraft LAN 广播。由 tslink 自己广播的条目会标记为「本机广播」——**配置了规则却听不到自己的广播,说明隧道或组播链路有问题** | +| **网络诊断** | 见下 | +| **日志** | 按级别、来源、关键字检索,复制 / 保存 / 上传 | +| **设置** | 主题、语言、版本与配置来源 | + +启动过程中,界面显示分步进度的加载动画;实时日志以**半透明浮层**固定在底部,因此启动卡住时直接截图就包含了排查所需的信息。服务就绪后,浮层可通过标题栏按钮随时唤出。 + +### 网络诊断 + +点击「开始诊断」后并行执行以下检查,整体不超过 45 秒: + +| 检查项 | 说明 | +|--------|------| +| **NAT 类型** | 依 RFC 5780 做映射行为与过滤行为探测,并映射到常见的完全锥形 / 地址限制 / 端口限制 / 对称型命名。对称型 NAT 会导致打洞失败、连接回退到 DERP 中继 | +| **UDP 连通性** | 对国内与境外 STUN 服务器分别探测 IPv4/IPv6,并识别疑似被封锁的目标端口 | +| **本机出口地址** | 列出所有接口上的 IPv4 与 IPv6 地址,标注默认出口以及 CGNAT / Tailscale / 私有 / 公网等类型 | +| **端口映射** | 自行实现的 UPnP IGD(SSDP + SOAP)、NAT-PMP(RFC 6886)与 PCP(RFC 6887)探测,能拿到路由器型号与外部地址 | +| **境外连通性** | 以 `cp.cloudflare.com/generate_204` 为主,辅以 gstatic / Google,并用国内基准(小米 / 百度)区分「完全没网」与「只是出不了境」 | +| **出口 IP 与归属地** | 通过 STUN(裸 UDP,绕过 HTTP 代理)、强制 IPv4、强制 IPv6、以及走系统代理四种方式分别探测,再用 ipinfo.io(失败时回退 ip-api.com / ip.sb)查询归属地 | +| **Tailscale 内部状态** | 直接调用 tailscale 自己的 netcheck,取得 DERP 各区域延迟、首选中继、门户劫持判定,以及它自己看到的 UPnP/PMP/PCP 结果 | + +STUN 服务器**同时包含国内与境外**两组(小米、B 站、腾讯、芒果 TV、Cloudflare 任播 / Google、Cloudflare、Nextcloud、BlackBerry、SipNet、StunProtocol)。这不只是为了容错:当本机启用了代理或分流工具时,不同探测路径会得到**不同的公网 IP**,诊断页会把这种分歧单独标出来——这通常正是「为什么对端连不上我」的答案。 + +> 归属地查询会把你的公网 IP 发送给第三方服务。不希望如此时,勾选「不查询归属地」即可跳过。 +> +> 出口 IP 的「不一致」判定按 IPv4 / IPv6 分别计算,双栈主机同时拥有一个 v4 和一个 v6 出口属于正常情况,不会被误报。 + +### 导出与分享日志 + +日志页提供三种导出方式,都会附带一段环境信息头(版本、系统、配置来源、运行阶段、节点数),以及最近一次的完整诊断报告: + +- **复制到剪贴板** +- **保存到文件**:写入用户主目录,文件名形如 `tslink-log-20260726-084500.txt` +- **上传并分享**:依次尝试 0x0.st、paste.rs、dpaste.org、termbin.com,成功后返回链接并自动复制 + +导出默认开启「隐去密钥」,会移除 `auth_key` 等凭据以及形如 `tskey-...` 的字符串。**上传是公开的**——任何拿到链接的人都能看到内容,其中包含你的公网 IP 与内网地址,请自行判断。 + +### 从源码构建 + +Windows 无需额外依赖。macOS 需要 Xcode Command Line Tools。Linux 需要 X11 / Wayland / EGL 的开发头文件: + +```bash +sudo apt install -y pkg-config libwayland-dev libx11-dev libx11-xcb-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev \ + libffi-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxxf86vm-dev + +go build -o tslink-gui ./cmd/tslink-gui +``` + +界面语言默认跟随中文字体的可用性:找不到任何中文字体时自动切换为英文,以免显示成方块(也可在设置里手动切换)。 + +由于 Gio 依赖 CGO,GUI 无法像无界面版那样交叉编译,需要在目标平台上分别构建。 diff --git a/cmd/tslink-gui/main.go b/cmd/tslink-gui/main.go new file mode 100644 index 0000000..6a62718 --- /dev/null +++ b/cmd/tslink-gui/main.go @@ -0,0 +1,147 @@ +// Command tslink-gui is the desktop front-end for tslink. +// +// 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 +// diagnostic run, plus a searchable log view that can be shared to a paste +// service for support. +// +// The UI is Gio: no webview, no bundled browser, one native binary per +// platform. +package main + +import ( + "context" + "flag" + "log" + "log/slog" + "net" + "net/http" + "net/http/pprof" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "gioui.org/app" + + "tslink/core" + "tslink/gui" +) + +// startPprof exposes net/http/pprof for diagnosing the GUI itself — frame-rate +// regressions in a GPU-accelerated UI are very hard to reason about without a +// profile. +// +// It refuses to bind anywhere but loopback: these handlers expose goroutine +// stacks and allow anyone who can reach them to trigger expensive profiles. +func startPprof(addr string, logger *slog.Logger) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + logger.Error("invalid -pprof address, expected host:port", "addr", addr, "err", err) + return + } + if !isLoopbackHost(host) { + logger.Error("refusing to serve pprof on a non-loopback address", "addr", addr) + return + } + + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + logger.Warn("pprof endpoint enabled", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("pprof server stopped", "err", err) + } + }() +} + +func isLoopbackHost(host string) bool { + if host == "localhost" || strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// Version is stamped at build time: +// +// go build -ldflags "-X main.Version=v1.2.3" ./cmd/tslink-gui +var Version = "dev" + +func main() { + var ( + configPath = flag.String("c", "config.toml", "path to config file") + configURL = flag.String("config-url", core.DefaultConfigURL, "URL to fetch config from") + logLevel = flag.String("level", "info", "console log level (DEBUG|INFO|WARN|ERROR)") + jsonFormat = flag.Bool("json-format", false, "use json format for the console logger") + tsnetDebug = flag.Bool("diagnose", false, "show tsnet debug log on level=debug") + ipinfoToken = flag.String("ipinfo-token", os.Getenv("IPINFO_TOKEN"), + "optional ipinfo.io token, raises the geolocation rate limit") + light = flag.Bool("light", false, "start in the light theme") + pprofA = flag.String("pprof", "", "serve net/http/pprof on this address, e.g. 127.0.0.1:6060 (loopback only)") + ) + flag.Parse() + + // The ring buffer captures at debug level regardless of what the console + // prints, so the log view and any shared bundle have the detail even when + // the user started without -level=debug. + logs := core.NewLogBuffer(core.DefaultLogCapacity) + logger := core.NewLoggerWithBuffer(*logLevel, *jsonFormat, logs) + logger.Info("starting tslink gui", + "version", Version, + "level", *logLevel, + "config", *configPath, + ) + + if *pprofA != "" { + startPprof(*pprofA, logger) + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + sup := core.NewSupervisor(core.SupervisorOptions{ + ConfigPath: *configPath, + ConfigURL: *configURL, + TsnetDebug: *tsnetDebug, + Logger: logger, + }) + go sup.Run(ctx) + + ui := gui.New(gui.Options{ + Version: Version, + ConfigPath: *configPath, + ConfigURL: *configURL, + Supervisor: sup, + Logs: logs, + Logger: logger, + IPInfoToken: *ipinfoToken, + StartDark: !*light, + }) + + go func() { + err := ui.Run(ctx) + // Closing the window shuts the service down: the GUI is the process. + cancel() + if err != nil { + logger.Error("gui exited", "err", err) + log.SetFlags(0) + os.Exit(1) + } + os.Exit(0) + }() + + app.Main() +} diff --git a/core/concurrent_test.go b/core/concurrent_test.go new file mode 100644 index 0000000..24f94ec --- /dev/null +++ b/core/concurrent_test.go @@ -0,0 +1,252 @@ +package core + +import ( + "context" + "log/slog" + "net/netip" + "sync" + "testing" + "time" +) + +// These tests exist to give `go test -race` something to chew on. The GUI +// reads every one of these structures from its frame loop while background +// goroutines write to them, which is exactly the shape of bug that never +// shows up in a single-threaded test. + +func TestLogBufferConcurrentAccess(t *testing.T) { + buf := NewLogBuffer(128) // small, so eviction runs constantly + logger := slog.New(buf.Handler(nil)) + + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + var wg sync.WaitGroup + + // Writers. + for i := 0; i < 4; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + l := logger.With("from", "writer", "id", id) + for ctx.Err() == nil { + l.Info("message", "n", id, "auth_key", "tskey-auth-SECRETVALUE123") + l.Debug("detail", slog.Group("g", slog.String("k", "v"))) + } + }(i) + } + + // Readers, mimicking the GUI's frame loop. + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + _ = buf.Tail(50) + _ = buf.Filter(LogQuery{MinLevel: slog.LevelInfo, Text: "message", Limit: 20}) + _ = buf.Sources() + _ = buf.Counts() + _ = buf.Len() + _ = buf.Dropped() + _ = buf.LastSeq() + } + }() + } + + // Subscribers churning in and out. + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + ch, cancelSub := buf.Subscribe() + select { + case <-ch: + case <-time.After(5 * time.Millisecond): + } + cancelSub() + } + }() + + // Exporter, which walks the whole ring and redacts. + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + out := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug, Limit: 100}}) + if len(out) > 0 && containsSecret(out) { + t.Error("export leaked an auth key") + return + } + time.Sleep(time.Millisecond) + } + }() + + wg.Wait() + + if buf.Len() > 128 { + t.Fatalf("ring exceeded its capacity: %d", buf.Len()) + } + if buf.Dropped() == 0 { + t.Fatal("expected eviction to have occurred") + } +} + +func containsSecret(s string) bool { + return len(s) > 0 && (indexOf(s, "SECRETVALUE123") >= 0) +} + +func indexOf(hay, needle string) int { + for i := 0; i+len(needle) <= len(hay); i++ { + if hay[i:i+len(needle)] == needle { + return i + } + } + return -1 +} + +func TestLogBufferTailOrderAndBounds(t *testing.T) { + buf := NewLogBuffer(4) + logger := slog.New(buf.Handler(nil)) + for i := 0; i < 10; i++ { + logger.Info("m", "i", i) + } + + got := buf.Tail(3) + if len(got) != 3 { + t.Fatalf("Tail(3) returned %d entries", len(got)) + } + // Oldest first, and the newest must be last. + for i := 1; i < len(got); i++ { + if got[i].Seq <= got[i-1].Seq { + t.Fatalf("Tail is not in chronological order: %v", got) + } + } + if got[len(got)-1].Seq != buf.LastSeq() { + t.Fatalf("Tail did not end at the newest record") + } + if n := len(buf.Tail(100)); n != 4 { + t.Fatalf("Tail beyond capacity returned %d, want 4", n) + } + if n := len(buf.Tail(0)); n != 0 { + t.Fatalf("Tail(0) returned %d entries", n) + } +} + +func TestLogBufferRedactsOnExport(t *testing.T) { + buf := NewLogBuffer(16) + logger := slog.New(buf.Handler(nil)) + logger.Info("joining", "auth_key", "tskey-auth-kSomeRealLookingKey123") + logger.Info("inline", "url", "https://x/?k=tskey-client-abcdefghijkl") + + out := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug}}) + if indexOf(out, "kSomeRealLookingKey123") >= 0 { + t.Error("attribute-named secret survived redaction") + } + if indexOf(out, "abcdefghijkl") >= 0 { + t.Error("inline tskey survived redaction") + } + + raw := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug}, NoRedact: true}) + if indexOf(raw, "kSomeRealLookingKey123") < 0 { + t.Error("NoRedact should preserve the original text") + } +} + +func TestLanScannerConcurrentAccess(t *testing.T) { + s := NewLanScanner(slog.New(slog.DiscardHandler)) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + s.Start(ctx) + + var wg sync.WaitGroup + + // Feed announcements the way the read loops do. + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ctx.Err() == nil; i++ { + src := netip.AddrPortFrom(netip.MustParseAddr("192.168.1.50"), uint16(40000+i%3)) + s.handle(src, "[MOTD]§aTest §bServer[/MOTD][AD]25565[/AD]") + s.handle(src, "malformed packet") + } + }() + + // Read like the GUI does. + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + for _, srv := range s.Servers() { + _ = srv.Motd + _ = srv.Addr + } + _ = s.Err() + } + }() + } + + // Reconfigure while it runs. + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + s.SetSelfEntries([]LanEntry{{Motd: "Test Server", Port: 25565}}) + time.Sleep(time.Millisecond) + s.SetSelfEntries(nil) + } + }() + + wg.Wait() + + servers := s.Servers() + if len(servers) == 0 { + t.Fatal("expected the synthetic announcements to be recorded") + } + for _, srv := range servers { + if srv.Port != 25565 { + t.Errorf("unexpected port %d", srv.Port) + } + // Colour codes must be stripped. + if indexOf(srv.Motd, "§") >= 0 { + t.Errorf("colour codes survived: %q", srv.Motd) + } + if srv.Motd != "Test Server" { + t.Errorf("motd = %q, want %q", srv.Motd, "Test Server") + } + } +} + +func TestPeerMonitorSnapshotIsIsolated(t *testing.T) { + m := NewPeerMonitor(nil, nil, slog.New(slog.DiscardHandler), PeerMonitorOptions{}) + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + // A nil server must not panic; the monitor should degrade to an invalid + // snapshot with an error rather than taking the GUI down. + m.Start(ctx) + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + snap := m.Snapshot() + for _, p := range snap.Peers { + _ = p.DisplayName + _ = len(p.Samples) + } + _ = m.History("nonexistent") + m.RefreshNow() + } + }() + } + wg.Wait() + + snap := m.Snapshot() + if snap.Valid { + t.Error("snapshot from a nil tsnet server should not be valid") + } +} diff --git a/core/lan.go b/core/lan.go index edad2f3..5fd8a7d 100644 --- a/core/lan.go +++ b/core/lan.go @@ -75,7 +75,9 @@ func LanDiscoverService(ctx context.Context, entryList []LanEntry, logger *slog. } } -func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) { +// LanEntriesFromRules collects the advertisements implied by the connect +// rules. The GUI uses it to tell our own broadcasts apart from other servers'. +func LanEntriesFromRules(rules map[string][]ConnectRule) []LanEntry { var lanEntries []LanEntry for tag, rs := range rules { for _, rule := range rs { @@ -89,6 +91,9 @@ func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, }) } } + return lanEntries +} - go LanDiscoverService(ctx, lanEntries, logger) +func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) { + go LanDiscoverService(ctx, LanEntriesFromRules(rules), logger) } diff --git a/core/lanscan.go b/core/lanscan.go new file mode 100644 index 0000000..5b7573b --- /dev/null +++ b/core/lanscan.go @@ -0,0 +1,539 @@ +package core + +import ( + "context" + "errors" + "log/slog" + "net" + "net/netip" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Minecraft's LAN discovery protocol: servers multicast the ASCII payload +// "[MOTD][/MOTD][AD][/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 +} diff --git a/core/logbuf.go b/core/logbuf.go new file mode 100644 index 0000000..c0199b6 --- /dev/null +++ b/core/logbuf.go @@ -0,0 +1,523 @@ +package core + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// LogAttr is one flattened structured field. Groups are folded into the key +// with dots so the GUI can render a single flat line per entry. +type LogAttr struct { + Key string + Value string +} + +// LogEntry is a single captured log record. +type LogEntry struct { + Seq uint64 + Time time.Time + Level slog.Level + Msg string + Attrs []LogAttr + // Source is the value of the conventional "from" attribute, used by the + // GUI to group logs by subsystem. + Source string +} + +// Text renders the entry the way the console handler would, minus colour. +func (e LogEntry) Text() string { + var b strings.Builder + b.WriteString(e.Time.Format("2006-01-02 15:04:05.000")) + b.WriteByte(' ') + b.WriteString(levelLabel(e.Level)) + b.WriteByte(' ') + b.WriteString(e.Msg) + for _, a := range e.Attrs { + b.WriteByte(' ') + b.WriteString(a.Key) + b.WriteByte('=') + if strings.ContainsAny(a.Value, " \t\"") { + fmt.Fprintf(&b, "%q", a.Value) + } else { + b.WriteString(a.Value) + } + } + return b.String() +} + +func levelLabel(l slog.Level) string { + switch { + case l < slog.LevelInfo: + return "DBG" + case l < slog.LevelWarn: + return "INF" + case l < slog.LevelError: + return "WRN" + default: + return "ERR" + } +} + +// LevelLabel exposes the three-letter level name used in exports and the GUI. +func LevelLabel(l slog.Level) string { return levelLabel(l) } + +// LogQuery filters a buffer snapshot. +type LogQuery struct { + // MinLevel drops anything below it. + MinLevel slog.Level + // Text is a case-insensitive substring matched against the message, the + // attribute values and the source. + Text string + // Source, when set, keeps only entries from that subsystem. + Source string + // Limit keeps only the newest N matches. Zero means unlimited. + Limit int +} + +func (q LogQuery) match(e LogEntry) bool { + if e.Level < q.MinLevel { + return false + } + if q.Source != "" && e.Source != q.Source { + return false + } + if q.Text == "" { + return true + } + needle := strings.ToLower(q.Text) + if strings.Contains(strings.ToLower(e.Msg), needle) { + return true + } + if strings.Contains(strings.ToLower(e.Source), needle) { + return true + } + for _, a := range e.Attrs { + if strings.Contains(strings.ToLower(a.Key), needle) || + strings.Contains(strings.ToLower(a.Value), needle) { + return true + } + } + return false +} + +// LogBuffer is a fixed-capacity ring of the most recent log records. It is the +// single source of truth for the GUI's log view and for diagnostic exports. +// +// All methods are safe for concurrent use. +type LogBuffer struct { + mu sync.RWMutex + entries []LogEntry // ring storage, len == cap once full + start int // index of the oldest entry + count int + nextSeq uint64 + dropped uint64 + subs map[int]chan struct{} + nextSub int + sources map[string]int + levelCnt map[slog.Level]int +} + +// DefaultLogCapacity is how many records the GUI keeps in memory. At roughly +// 200 bytes per record this is a few megabytes at most. +const DefaultLogCapacity = 20000 + +// NewLogBuffer returns a buffer holding at most capacity records. +func NewLogBuffer(capacity int) *LogBuffer { + if capacity <= 0 { + capacity = DefaultLogCapacity + } + return &LogBuffer{ + entries: make([]LogEntry, capacity), + subs: make(map[int]chan struct{}), + sources: make(map[string]int), + levelCnt: make(map[slog.Level]int), + } +} + +// Add appends an entry, evicting the oldest record when full. +func (b *LogBuffer) Add(e LogEntry) { + b.mu.Lock() + b.nextSeq++ + e.Seq = b.nextSeq + + capacity := len(b.entries) + if b.count == capacity { + evicted := b.entries[b.start] + b.decStatsLocked(evicted) + b.entries[b.start] = e + b.start = (b.start + 1) % capacity + b.dropped++ + } else { + b.entries[(b.start+b.count)%capacity] = e + b.count++ + } + b.incStatsLocked(e) + + for _, ch := range b.subs { + select { + case ch <- struct{}{}: + default: // subscriber has a pending wakeup already + } + } + b.mu.Unlock() +} + +func (b *LogBuffer) incStatsLocked(e LogEntry) { + b.levelCnt[e.Level]++ + if e.Source != "" { + b.sources[e.Source]++ + } +} + +func (b *LogBuffer) decStatsLocked(e LogEntry) { + b.levelCnt[e.Level]-- + if b.levelCnt[e.Level] <= 0 { + delete(b.levelCnt, e.Level) + } + if e.Source != "" { + b.sources[e.Source]-- + if b.sources[e.Source] <= 0 { + delete(b.sources, e.Source) + } + } +} + +// Len returns the number of buffered records. +func (b *LogBuffer) Len() int { + b.mu.RLock() + defer b.mu.RUnlock() + return b.count +} + +// Dropped returns how many records were evicted because the ring was full. +func (b *LogBuffer) Dropped() uint64 { + b.mu.RLock() + defer b.mu.RUnlock() + return b.dropped +} + +// LastSeq returns the sequence number of the most recent record. +func (b *LogBuffer) LastSeq() uint64 { + b.mu.RLock() + defer b.mu.RUnlock() + return b.nextSeq +} + +// Counts returns how many buffered records exist per level. +func (b *LogBuffer) Counts() map[slog.Level]int { + b.mu.RLock() + defer b.mu.RUnlock() + out := make(map[slog.Level]int, len(b.levelCnt)) + for k, v := range b.levelCnt { + out[k] = v + } + return out +} + +// Sources returns the distinct subsystem names currently buffered, sorted. +func (b *LogBuffer) Sources() []string { + b.mu.RLock() + defer b.mu.RUnlock() + out := make([]string, 0, len(b.sources)) + for k := range b.sources { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// Snapshot returns every buffered record, oldest first. +func (b *LogBuffer) Snapshot() []LogEntry { + b.mu.RLock() + defer b.mu.RUnlock() + return b.collectLocked(func(LogEntry) bool { return true }, 0) +} + +// Tail returns the newest n records, oldest first. +// +// It walks backwards from the newest record so the cost is O(n), not O(ring). +// The GUI's log overlay calls this on every frame; scanning a full 20k-entry +// ring each time was enough on its own to keep a core busy. +func (b *LogBuffer) Tail(n int) []LogEntry { + if n <= 0 { + return nil + } + b.mu.RLock() + defer b.mu.RUnlock() + return b.newestLocked(func(LogEntry) bool { return true }, n) +} + +// Filter returns the records matching q, oldest first. +func (b *LogBuffer) Filter(q LogQuery) []LogEntry { + b.mu.RLock() + defer b.mu.RUnlock() + if q.Limit > 0 { + return b.newestLocked(q.match, q.Limit) + } + return b.collectLocked(q.match, 0) +} + +// newestLocked walks the ring newest-first, keeping at most limit matches, and +// returns them oldest-first. +func (b *LogBuffer) newestLocked(keep func(LogEntry) bool, limit int) []LogEntry { + capacity := len(b.entries) + out := make([]LogEntry, 0, min(limit, b.count)) + for i := b.count - 1; i >= 0 && len(out) < limit; i-- { + e := b.entries[(b.start+i)%capacity] + if keep(e) { + out = append(out, e) + } + } + // Reverse in place to restore chronological order. + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} + +// collectLocked walks the ring oldest-first. When limit > 0 only the newest +// limit matches are kept. +func (b *LogBuffer) collectLocked(keep func(LogEntry) bool, limit int) []LogEntry { + capacity := len(b.entries) + out := make([]LogEntry, 0, min(b.count, 512)) + for i := 0; i < b.count; i++ { + e := b.entries[(b.start+i)%capacity] + if keep(e) { + out = append(out, e) + } + } + if limit > 0 && len(out) > limit { + out = out[len(out)-limit:] + } + return out +} + +// Subscribe returns a channel that receives a value whenever a record is +// added, plus a function that cancels the subscription. The channel is +// buffered and coalescing: a slow reader sees one wakeup, not a backlog. +func (b *LogBuffer) Subscribe() (<-chan struct{}, func()) { + ch := make(chan struct{}, 1) + b.mu.Lock() + id := b.nextSub + b.nextSub++ + b.subs[id] = ch + b.mu.Unlock() + + var once sync.Once + cancel := func() { + once.Do(func() { + b.mu.Lock() + delete(b.subs, id) + b.mu.Unlock() + }) + } + return ch, cancel +} + +// --------------------------------------------------------------------------- +// slog handler +// --------------------------------------------------------------------------- + +// bufHandler tees records into a LogBuffer and on to a wrapped handler. +type bufHandler struct { + buf *LogBuffer + next slog.Handler + attrs []LogAttr + groups []string +} + +// Handler returns a slog.Handler that records everything into b and forwards +// to next. next may be nil, in which case records are only buffered. +// +// The buffer always captures at debug level regardless of what next filters, +// so the GUI can show detail the console suppressed. +func (b *LogBuffer) Handler(next slog.Handler) slog.Handler { + return &bufHandler{buf: b, next: next} +} + +func (h *bufHandler) Enabled(ctx context.Context, l slog.Level) bool { + // Always capture: the buffer is the diagnostic record of last resort. + return true +} + +func (h *bufHandler) Handle(ctx context.Context, r slog.Record) error { + attrs := make([]LogAttr, 0, len(h.attrs)+r.NumAttrs()) + attrs = append(attrs, h.attrs...) + r.Attrs(func(a slog.Attr) bool { + attrs = appendAttr(attrs, h.groups, a) + return true + }) + + source := "" + for _, a := range attrs { + if a.Key == "from" { + source = a.Value + } + } + + t := r.Time + if t.IsZero() { + t = time.Now() + } + h.buf.Add(LogEntry{ + Time: t, + Level: r.Level, + Msg: r.Message, + Attrs: attrs, + Source: source, + }) + + if h.next != nil && h.next.Enabled(ctx, r.Level) { + return h.next.Handle(ctx, r) + } + return nil +} + +func (h *bufHandler) WithAttrs(as []slog.Attr) slog.Handler { + if len(as) == 0 { + return h + } + clone := *h + clone.attrs = make([]LogAttr, len(h.attrs), len(h.attrs)+len(as)) + copy(clone.attrs, h.attrs) + for _, a := range as { + clone.attrs = appendAttr(clone.attrs, h.groups, a) + } + if h.next != nil { + clone.next = h.next.WithAttrs(as) + } + return &clone +} + +func (h *bufHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + clone := *h + clone.groups = append(append([]string(nil), h.groups...), name) + if h.next != nil { + clone.next = h.next.WithGroup(name) + } + return &clone +} + +// appendAttr flattens a slog.Attr, expanding groups into dotted keys. +func appendAttr(dst []LogAttr, groups []string, a slog.Attr) []LogAttr { + a.Value = a.Value.Resolve() + if a.Equal(slog.Attr{}) { + return dst + } + if a.Value.Kind() == slog.KindGroup { + sub := a.Value.Group() + if len(sub) == 0 { + return dst + } + nested := groups + if a.Key != "" { + nested = append(append([]string(nil), groups...), a.Key) + } + for _, s := range sub { + dst = appendAttr(dst, nested, s) + } + return dst + } + key := a.Key + if len(groups) > 0 { + key = strings.Join(groups, ".") + "." + key + } + return append(dst, LogAttr{Key: key, Value: a.Value.String()}) +} + +// NewLoggerWithBuffer builds the console logger exactly as [NewLogger] does +// and tees every record into buf. +func NewLoggerWithBuffer(level string, useJsonFormat bool, buf *LogBuffer) *slog.Logger { + base := NewLogger(level, useJsonFormat) + logger := slog.New(buf.Handler(base.Handler())) + slog.SetDefault(logger) + return logger +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +// secretPattern matches Tailscale auth keys and OAuth client secrets, which +// are the one thing in these logs that must never reach a paste service. +var secretPattern = regexp.MustCompile(`\b(tskey-[a-zA-Z]+-)[A-Za-z0-9\-_]{6,}`) + +// secretKeys are attribute names whose values are replaced wholesale. +var secretKeys = map[string]bool{ + "auth_key": true, + "authkey": true, + "auth-key": true, + "token": true, + "secret": true, + "password": true, + "client_secret": true, +} + +// Redact removes credentials from a single string. +func Redact(s string) string { + return secretPattern.ReplaceAllString(s, "${1}REDACTED") +} + +func redactAttr(a LogAttr) LogAttr { + if secretKeys[strings.ToLower(a.Key)] { + if a.Value == "" { + return a + } + return LogAttr{Key: a.Key, Value: "[REDACTED]"} + } + a.Value = Redact(a.Value) + return a +} + +// ExportOptions controls how a log dump is rendered. +type ExportOptions struct { + Query LogQuery + // Redact strips credentials. Callers sharing logs publicly must leave this + // on; it defaults to on because [ExportText] is built for sharing. + NoRedact bool + // Header is prepended verbatim, used for environment metadata. + Header string +} + +// ExportText renders matching entries as a plain-text report suitable for +// pasting into an issue tracker or a paste service. +func (b *LogBuffer) ExportText(opt ExportOptions) string { + entries := b.Filter(opt.Query) + + var sb strings.Builder + if opt.Header != "" { + sb.WriteString(opt.Header) + if !strings.HasSuffix(opt.Header, "\n") { + sb.WriteByte('\n') + } + sb.WriteString("\n") + } + if dropped := b.Dropped(); dropped > 0 { + fmt.Fprintf(&sb, "# %d earlier record(s) were dropped from the ring buffer\n\n", dropped) + } + for _, e := range entries { + if !opt.NoRedact { + e.Msg = Redact(e.Msg) + redacted := make([]LogAttr, len(e.Attrs)) + for i, a := range e.Attrs { + redacted[i] = redactAttr(a) + } + e.Attrs = redacted + } + sb.WriteString(e.Text()) + sb.WriteByte('\n') + } + if len(entries) == 0 { + sb.WriteString("(no matching log entries)\n") + } + return sb.String() +} diff --git a/core/peermon.go b/core/peermon.go new file mode 100644 index 0000000..5f85e7c --- /dev/null +++ b/core/peermon.go @@ -0,0 +1,858 @@ +package core + +import ( + "context" + "errors" + "log/slog" + "net" + "net/netip" + "sort" + "strings" + "sync" + "time" + + "tailscale.com/client/local" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" + "tailscale.com/tsnet" +) + +// PeerRoute is how traffic currently reaches a peer. +type PeerRoute string + +const ( + RouteDirect PeerRoute = "direct" + RouteDERP PeerRoute = "derp" + RoutePeerRelay PeerRoute = "peer-relay" + RouteOffline PeerRoute = "offline" + RouteUnknown PeerRoute = "unknown" +) + +// PeerSample is one latency measurement. +type PeerSample struct { + At time.Time + Latency time.Duration + OK bool + Route PeerRoute +} + +// PeerInfo is everything the GUI shows about one node. +type PeerInfo struct { + ID, HostName, DNSName, DisplayName, OS string + TailscaleIPs []netip.Addr + Online, Active, ExitNode bool + CurAddr, Relay string + Route PeerRoute + RxBytes, TxBytes int64 + Created, LastSeen, LastWrite, LastHandshake time.Time + // Linked reports that a config rule points at this peer; those are the nodes + // the user actually cares about and the GUI lists them first. + Linked bool + LinkTags []string + LastLatency time.Duration + LatencyOK bool + Samples []PeerSample // chronological, oldest first + AvgLatency time.Duration + MinLatency time.Duration + MaxLatency time.Duration + JitterMs float64 // mean absolute successive difference + LossPct float64 +} + +// clone returns a deep copy of p so callers cannot reach into monitor state. +func (p PeerInfo) clone() PeerInfo { + out := p + out.TailscaleIPs = append([]netip.Addr(nil), p.TailscaleIPs...) + out.LinkTags = append([]string(nil), p.LinkTags...) + out.Samples = append([]PeerSample(nil), p.Samples...) + return out +} + +// PeerSnapshot is a consistent view of the tailnet at one instant. +type PeerSnapshot struct { + At time.Time + Valid bool + Self PeerInfo + Peers []PeerInfo + TailnetName string + BackendState string + MagicDNSSuffix string + Err string +} + +// clone returns a deep copy of s, including every peer's slices. +func (s PeerSnapshot) clone() PeerSnapshot { + out := s + out.Self = s.Self.clone() + out.Peers = make([]PeerInfo, len(s.Peers)) + for i, p := range s.Peers { + out.Peers[i] = p.clone() + } + return out +} + +// PeerMonitorOptions tunes the two polling loops and the history depth. +type PeerMonitorOptions struct { + // StatusInterval defaults to 3s, PingInterval to 10s, HistorySize to 120 samples. + StatusInterval, PingInterval time.Duration + HistorySize int +} + +const ( + defaultStatusInterval = 3 * time.Second + defaultPingInterval = 10 * time.Second + defaultHistorySize = 120 + + // pingTimeout bounds a single peer ping. A hung probe must never stall the + // sweep, and the sweep must never outlive its own interval by much. + pingTimeout = 5 * time.Second + // pingConcurrency bounds in-flight pings so a large tailnet cannot spawn + // hundreds of goroutines at once. + pingConcurrency = 4 + // linkResolveInterval re-resolves config rules, because MagicDNS answers + // change when a peer's address is reassigned. + linkResolveInterval = 5 * time.Minute + // linkResolveTimeout bounds resolution of a single rule destination. + linkResolveTimeout = 10 * time.Second + // statusTimeout bounds one lc.Status call. + statusTimeout = 10 * time.Second + // maxStatusBackoff caps the retry delay after repeated status failures. + maxStatusBackoff = 30 * time.Second +) + +func (o PeerMonitorOptions) withDefaults() PeerMonitorOptions { + if o.StatusInterval <= 0 { + o.StatusInterval = defaultStatusInterval + } + if o.PingInterval <= 0 { + o.PingInterval = defaultPingInterval + } + if o.HistorySize <= 0 { + o.HistorySize = defaultHistorySize + } + return o +} + +// pingOutcome is the most recent ping result for one peer, used to refine the +// route derivation that the status fields alone can only guess at. +type pingOutcome struct { + ok bool + latency time.Duration + derpRegion string + at time.Time +} + +// PeerMonitor keeps a live view of the tailnet for the GUI: a cheap status +// poll, an independent ping sweep, and a capped latency history per peer. +// +// All methods are safe for concurrent use. +type PeerMonitor struct { + srv *tsnet.Server + rules map[string][]ConnectRule + log *slog.Logger + opt PeerMonitorOptions + + refreshStatus chan struct{} + refreshPing chan struct{} + + mu sync.RWMutex + raw *ipnstate.Status // last good status, nil until the first poll lands + rawErr string + built PeerSnapshot // rebuilt after every poll and sweep + hist map[string][]PeerSample + last map[string]pingOutcome + links map[netip.Addr][]string + subs map[int]chan struct{} + nextSub int +} + +// NewPeerMonitor returns a monitor for srv. rules are the configured connect +// rules, used to mark which peers the user actually links to; it may be nil. +// logger may be nil. +func NewPeerMonitor(srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger, opt PeerMonitorOptions) *PeerMonitor { + if logger == nil { + logger = slog.Default() + } + return &PeerMonitor{ + srv: srv, + rules: rules, + log: logger.With("from", "peermon"), + opt: opt.withDefaults(), + refreshStatus: make(chan struct{}, 1), + refreshPing: make(chan struct{}, 1), + hist: make(map[string][]PeerSample), + last: make(map[string]pingOutcome), + links: make(map[netip.Addr][]string), + subs: make(map[int]chan struct{}), + } +} + +// Start launches the status loop, the ping loop and the link resolver. All of +// them stop when ctx is cancelled. Start does not block. +func (m *PeerMonitor) Start(ctx context.Context) { + go m.statusLoop(ctx) + go m.pingLoop(ctx) + go m.linkLoop(ctx) +} + +// RefreshNow triggers an immediate status+ping cycle without blocking the caller. +func (m *PeerMonitor) RefreshNow() { + kick(m.refreshStatus) + kick(m.refreshPing) +} + +// kick delivers a coalescing wakeup: a pending signal is enough. +func kick(ch chan struct{}) { + select { + case ch <- struct{}{}: + default: + } +} + +// Snapshot returns a consistent, fully copied view of the tailnet. It performs +// no I/O and is safe to call from the render path. +func (m *PeerMonitor) Snapshot() PeerSnapshot { + m.mu.RLock() + defer m.mu.RUnlock() + return m.built.clone() +} + +// Subscribe returns a channel that receives a value after every status poll and +// every completed ping sweep, plus a function that cancels the subscription. +// The channel is buffered and coalescing: a slow reader sees one wakeup, not a +// backlog. +func (m *PeerMonitor) Subscribe() (<-chan struct{}, func()) { + ch := make(chan struct{}, 1) + m.mu.Lock() + id := m.nextSub + m.nextSub++ + m.subs[id] = ch + m.mu.Unlock() + + var once sync.Once + cancel := func() { + once.Do(func() { + m.mu.Lock() + delete(m.subs, id) + m.mu.Unlock() + }) + } + return ch, cancel +} + +// History returns the samples for one peer keyed by stable node ID, oldest +// first. The returned slice is a copy. +func (m *PeerMonitor) History(id string) []PeerSample { + m.mu.RLock() + defer m.mu.RUnlock() + return append([]PeerSample(nil), m.hist[id]...) +} + +func (m *PeerMonitor) notify() { + m.mu.RLock() + defer m.mu.RUnlock() + for _, ch := range m.subs { + select { + case ch <- struct{}{}: + default: // subscriber has a pending wakeup already + } + } +} + +// --------------------------------------------------------------------------- +// status loop +// --------------------------------------------------------------------------- + +// statusLoop polls lc.Status on StatusInterval. It never waits on the ping +// sweep, so a slow tailnet cannot freeze the peer list in the GUI. +func (m *PeerMonitor) statusLoop(ctx context.Context) { + timer := time.NewTimer(0) + defer timer.Stop() + + var fails int + first := true + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + case <-m.refreshStatus: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + } + + err := m.pollStatus(ctx) + if ctx.Err() != nil { + return + } + delay := m.opt.StatusInterval + if err != nil { + fails++ + delay = backoffDelay(m.opt.StatusInterval, fails) + m.log.Debug("status poll failed", "err", err, "retry_in", delay) + } else { + fails = 0 + if first { + first = false + kick(m.refreshPing) // ping as soon as we know who is out there + } + } + timer.Reset(delay) + } +} + +// backoffDelay grows the retry delay exponentially, capped at maxStatusBackoff. +func backoffDelay(base time.Duration, fails int) time.Duration { + d := base + for i := 1; i < fails && d < maxStatusBackoff; i++ { + d *= 2 + } + if d > maxStatusBackoff { + d = maxStatusBackoff + } + return d +} + +// pollStatus refreshes the cached status. On failure the previous status is +// kept so the GUI degrades to stale data instead of going blank. +func (m *PeerMonitor) pollStatus(ctx context.Context) error { + lc, err := m.localClient() + if err == nil { + var st *ipnstate.Status + st, err = func() (*ipnstate.Status, error) { + cctx, cancel := context.WithTimeout(ctx, statusTimeout) + defer cancel() + return lc.Status(cctx) + }() + if err == nil { + m.mu.Lock() + m.raw = st + m.rawErr = "" + m.rebuildLocked() + m.mu.Unlock() + m.notify() + return nil + } + } + + m.mu.Lock() + m.rawErr = err.Error() + m.rebuildLocked() + m.mu.Unlock() + m.notify() + return err +} + +func (m *PeerMonitor) localClient() (*local.Client, error) { + if m.srv == nil { + return nil, errors.New("tsnet server not started") + } + return m.srv.LocalClient() +} + +// --------------------------------------------------------------------------- +// ping loop +// --------------------------------------------------------------------------- + +// pingLoop sweeps every online peer on PingInterval. A sweep that overruns its +// interval simply delays the next sweep; the status loop is unaffected. +func (m *PeerMonitor) pingLoop(ctx context.Context) { + timer := time.NewTimer(m.opt.PingInterval) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + case <-m.refreshPing: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + } + + m.pingSweep(ctx) + if ctx.Err() != nil { + return + } + timer.Reset(m.opt.PingInterval) + } +} + +// pingTarget is one node to probe in a sweep. +type pingTarget struct { + id string + name string + addr netip.Addr +} + +// pingTargets lists the online peers worth probing, taken from the last good +// status. Self is skipped: pinging your own address is not a network test. +func (m *PeerMonitor) pingTargets() []pingTarget { + m.mu.RLock() + defer m.mu.RUnlock() + if m.raw == nil { + return nil + } + var out []pingTarget + for _, ps := range m.raw.Peer { + if ps == nil || !ps.Online { + continue + } + addr := pingAddr(ps.TailscaleIPs) + if !addr.IsValid() { + continue + } + out = append(out, pingTarget{id: peerKey(ps), name: displayName(ps), addr: addr}) + } + sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id }) + return out +} + +// pingSweep probes every online peer, bounded to pingConcurrency in flight. +func (m *PeerMonitor) pingSweep(ctx context.Context) { + targets := m.pingTargets() + if len(targets) == 0 { + return + } + lc, err := m.localClient() + if err != nil { + m.log.Debug("ping sweep skipped", "err", err) + return + } + + sem := make(chan struct{}, pingConcurrency) + var wg sync.WaitGroup + for _, t := range targets { + select { + case <-ctx.Done(): + wg.Wait() + return + case sem <- struct{}{}: + } + wg.Add(1) + go func(t pingTarget) { + defer wg.Done() + defer func() { <-sem }() + m.pingOne(ctx, lc, t) + }(t) + } + wg.Wait() + if ctx.Err() != nil { + return + } + + m.mu.Lock() + m.rebuildLocked() + m.mu.Unlock() + m.notify() +} + +// pingOne probes a single peer and records the outcome. A failure is recorded +// as a sample with OK=false: loss is data. +func (m *PeerMonitor) pingOne(ctx context.Context, lc *local.Client, t pingTarget) { + cctx, cancel := context.WithTimeout(ctx, pingTimeout) + defer cancel() + + res, err := lc.Ping(cctx, t.addr, tailcfg.PingDisco) + now := time.Now() + + out := pingOutcome{at: now} + switch { + case err != nil: + if !errors.Is(err, context.Canceled) { + m.log.Debug("peer ping failed", "peer", t.name, "addr", t.addr, "err", err) + } + case res == nil: + m.log.Debug("peer ping returned nothing", "peer", t.name, "addr", t.addr) + case res.Err != "": + m.log.Debug("peer ping error", "peer", t.name, "addr", t.addr, "err", res.Err) + default: + out.ok = true + out.latency = time.Duration(res.LatencySeconds * float64(time.Second)) + out.derpRegion = res.DERPRegionCode + } + + sample := PeerSample{At: now, Latency: out.latency, OK: out.ok} + if out.ok { + if out.derpRegion == "" { + sample.Route = RouteDirect + } else { + sample.Route = RouteDERP + } + } else { + sample.Route = RouteUnknown + } + + m.mu.Lock() + m.last[t.id] = out + m.hist[t.id] = appendSample(m.hist[t.id], sample, m.opt.HistorySize) + m.mu.Unlock() +} + +// appendSample pushes s onto a capped ring, dropping the oldest entry when +// full. Chronological order is preserved. +func appendSample(ring []PeerSample, s PeerSample, size int) []PeerSample { + if size <= 0 { + size = defaultHistorySize + } + if len(ring) < size { + return append(ring, s) + } + // Shift left by the overflow so a shrunken HistorySize also converges. + drop := len(ring) - size + 1 + copy(ring, ring[drop:]) + ring = ring[:size-1] + return append(ring, s) +} + +// --------------------------------------------------------------------------- +// link resolution +// --------------------------------------------------------------------------- + +// linkLoop resolves every connect rule's destination to a tailnet address once +// at start and again every linkResolveInterval. Resolution touches the network, +// so it never happens on the render path. +func (m *PeerMonitor) linkLoop(ctx context.Context) { + if len(m.rules) == 0 || m.srv == nil { + return + } + ticker := time.NewTicker(linkResolveInterval) + defer ticker.Stop() + + m.resolveLinks(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.resolveLinks(ctx) + } + } +} + +// resolveLinks maps every rule destination to a peer address, remembering which +// config tags referenced it. +func (m *PeerMonitor) resolveLinks(ctx context.Context) { + found := make(map[netip.Addr]map[string]struct{}) + + for tag, rules := range m.rules { + for _, rule := range rules { + if ctx.Err() != nil { + return + } + host, _, err := net.SplitHostPort(rule.DstAddr) + if err != nil { + m.log.Debug("link: bad dst_addr", "tag", tag, "dst", rule.DstAddr, "err", err) + continue + } + addr, err := func() (*netip.Addr, error) { + cctx, cancel := context.WithTimeout(ctx, linkResolveTimeout) + defer cancel() + return resolveAddr(cctx, m.srv, host) + }() + if err != nil || addr == nil { + m.log.Debug("link: failed to resolve dst_addr", "tag", tag, "dst", rule.DstAddr, "err", err) + continue + } + if found[*addr] == nil { + found[*addr] = make(map[string]struct{}) + } + found[*addr][tag] = struct{}{} + } + } + + if ctx.Err() != nil { + return + } + + links := make(map[netip.Addr][]string, len(found)) + for addr, tags := range found { + list := make([]string, 0, len(tags)) + for tag := range tags { + list = append(list, tag) + } + sort.Strings(list) + links[addr] = list + } + + m.mu.Lock() + m.links = links + m.rebuildLocked() + m.mu.Unlock() + m.log.Debug("link targets resolved", "count", len(links)) + m.notify() +} + +// --------------------------------------------------------------------------- +// snapshot assembly +// --------------------------------------------------------------------------- + +// rebuildLocked recomputes the cached snapshot from the last good status, the +// latency history and the resolved links. m.mu must be held for writing. +func (m *PeerMonitor) rebuildLocked() { + snap := PeerSnapshot{At: time.Now(), Err: m.rawErr} + st := m.raw + if st == nil { + snap.Valid = false + m.built = snap + return + } + + // Stale data is still useful data: Valid stays true once a status landed, + // and Err tells the GUI the view may be out of date. + snap.Valid = true + snap.BackendState = st.BackendState + snap.MagicDNSSuffix = st.MagicDNSSuffix + if st.CurrentTailnet != nil { + snap.TailnetName = st.CurrentTailnet.Name + if st.CurrentTailnet.MagicDNSSuffix != "" { + snap.MagicDNSSuffix = st.CurrentTailnet.MagicDNSSuffix + } + } + + live := make(map[string]struct{}, len(st.Peer)+1) + if st.Self != nil { + snap.Self = m.peerInfoLocked(st.Self) + live[snap.Self.ID] = struct{}{} + } + snap.Peers = make([]PeerInfo, 0, len(st.Peer)) + for _, ps := range st.Peer { + if ps == nil { + continue + } + info := m.peerInfoLocked(ps) + live[info.ID] = struct{}{} + snap.Peers = append(snap.Peers, info) + } + sortPeers(snap.Peers) + + // Forget history for nodes that left the netmap, so a long-running GUI + // session does not grow without bound. + for id := range m.hist { + if _, ok := live[id]; !ok { + delete(m.hist, id) + delete(m.last, id) + } + } + + m.built = snap +} + +// peerInfoLocked converts one PeerStatus into the GUI's view of it. m.mu must +// be held. +func (m *PeerMonitor) peerInfoLocked(ps *ipnstate.PeerStatus) PeerInfo { + id := peerKey(ps) + info := PeerInfo{ + ID: id, + HostName: ps.HostName, + DNSName: strings.TrimSuffix(ps.DNSName, "."), + DisplayName: displayName(ps), + OS: ps.OS, + TailscaleIPs: append([]netip.Addr(nil), ps.TailscaleIPs...), + Online: ps.Online, + Active: ps.Active, + ExitNode: ps.ExitNode, + CurAddr: ps.CurAddr, + Relay: ps.Relay, + RxBytes: ps.RxBytes, + TxBytes: ps.TxBytes, + Created: ps.Created, + LastSeen: ps.LastSeen, + LastWrite: ps.LastWrite, + LastHandshake: ps.LastHandshake, + } + + for _, ip := range ps.TailscaleIPs { + tags, ok := m.links[ip] + if !ok { + continue + } + info.Linked = true + info.LinkTags = mergeTags(info.LinkTags, tags) + } + + last, hasPing := m.last[id] + info.Route = deriveRoute(ps, last, hasPing) + if hasPing { + info.LatencyOK = last.ok + if last.ok { + info.LastLatency = last.latency + } + } + + samples := m.hist[id] + info.Samples = append([]PeerSample(nil), samples...) + summariseSamples(&info) + return info +} + +// deriveRoute decides how traffic reaches the peer. Status fields give the +// baseline; a successful ping is authoritative because it reports the path the +// packet actually took. +func deriveRoute(ps *ipnstate.PeerStatus, last pingOutcome, hasPing bool) PeerRoute { + if hasPing && last.ok { + if last.derpRegion != "" { + return RouteDERP + } + if ps.PeerRelay != "" { + return RoutePeerRelay + } + return RouteDirect + } + switch { + case ps.PeerRelay != "": + return RoutePeerRelay + case ps.CurAddr != "": + return RouteDirect + case ps.Relay != "": + return RouteDERP + case !ps.Online: + return RouteOffline + default: + return RouteUnknown + } +} + +// summariseSamples fills the aggregate latency fields. Averages, minimum, +// maximum and jitter consider successful samples only; loss covers the whole +// window. +func summariseSamples(info *PeerInfo) { + if len(info.Samples) == 0 { + return + } + var ( + sum time.Duration + ok int + fails int + lo, hi time.Duration + prev time.Duration + havePrev bool + diffSum float64 + diffs int + ) + for _, s := range info.Samples { + if !s.OK { + fails++ + continue + } + ok++ + sum += s.Latency + if ok == 1 || s.Latency < lo { + lo = s.Latency + } + if ok == 1 || s.Latency > hi { + hi = s.Latency + } + if havePrev { + d := float64(s.Latency-prev) / float64(time.Millisecond) + if d < 0 { + d = -d + } + diffSum += d + diffs++ + } + prev = s.Latency + havePrev = true + } + + info.LossPct = float64(fails) / float64(len(info.Samples)) * 100 + if ok == 0 { + return + } + info.AvgLatency = sum / time.Duration(ok) + info.MinLatency = lo + info.MaxLatency = hi + if diffs > 0 { + info.JitterMs = diffSum / float64(diffs) + } +} + +// sortPeers orders the list the way the GUI renders it: linked nodes first, +// then online before offline, then by display name. The final tiebreak on ID +// keeps the order stable across refreshes. +func sortPeers(peers []PeerInfo) { + sort.Slice(peers, func(i, j int) bool { + a, b := peers[i], peers[j] + if a.Linked != b.Linked { + return a.Linked + } + if a.Online != b.Online { + return a.Online + } + if an, bn := strings.ToLower(a.DisplayName), strings.ToLower(b.DisplayName); an != bn { + return an < bn + } + return a.ID < b.ID + }) +} + +// peerKey is the stable identity used to key history. It falls back to the DNS +// name and then the first address for nodes without a stable ID. +func peerKey(ps *ipnstate.PeerStatus) string { + if id := string(ps.ID); id != "" { + return id + } + if dns := strings.TrimSuffix(ps.DNSName, "."); dns != "" { + return dns + } + if len(ps.TailscaleIPs) > 0 { + return ps.TailscaleIPs[0].String() + } + return ps.HostName +} + +// displayName prefers the first label of the MagicDNS name, which is what the +// user typed in the config, then the reported hostname, then an address. +func displayName(ps *ipnstate.PeerStatus) string { + if dns := strings.TrimSuffix(ps.DNSName, "."); dns != "" { + if label, _, ok := strings.Cut(dns, "."); ok && label != "" { + return label + } + return dns + } + if ps.HostName != "" { + return ps.HostName + } + if len(ps.TailscaleIPs) > 0 { + return ps.TailscaleIPs[0].String() + } + return string(ps.ID) +} + +// pingAddr picks the address to probe, preferring IPv4 because that is what +// MagicDNS hands out for tailnet peers. +func pingAddr(ips []netip.Addr) netip.Addr { + var v6 netip.Addr + for _, ip := range ips { + if ip.Is4() { + return ip + } + if !v6.IsValid() { + v6 = ip + } + } + return v6 +} + +// mergeTags appends the tags missing from dst, keeping the result sorted and +// free of duplicates. +func mergeTags(dst, extra []string) []string { + for _, t := range extra { + i := sort.SearchStrings(dst, t) + if i < len(dst) && dst[i] == t { + continue + } + dst = append(dst, "") + copy(dst[i+1:], dst[i:]) + dst[i] = t + } + return dst +} diff --git a/core/supervisor.go b/core/supervisor.go new file mode 100644 index 0000000..f42f592 --- /dev/null +++ b/core/supervisor.go @@ -0,0 +1,469 @@ +package core + +import ( + "context" + "errors" + "log/slog" + "sync" + "time" + + "tailscale.com/tsnet" +) + +// Phase is the coarse lifecycle state of the service, shown as the header +// status pill in the GUI. +type Phase int + +const ( + PhaseIdle Phase = iota + PhaseStarting + PhaseReady + PhaseRetrying + PhaseError + PhaseStopped +) + +func (p Phase) String() string { + switch p { + case PhaseStarting: + return "starting" + case PhaseReady: + return "ready" + case PhaseRetrying: + return "retrying" + case PhaseError: + return "error" + case PhaseStopped: + return "stopped" + default: + return "idle" + } +} + +// StepState is the state of one boot step. +type StepState int + +const ( + StepPending StepState = iota + StepRunning + StepDone + StepFailed + StepSkipped +) + +// Boot step keys. The GUI maps these onto localised titles. +const ( + StepKeyConfig = "config" + StepKeyTsnet = "tsnet" + StepKeyRules = "rules" + StepKeyServices = "services" + StepKeyMonitors = "monitors" + StepKeyReady = "ready" +) + +// BootStep is one entry in the startup checklist. +type BootStep struct { + Key string + State StepState + Err string + Started time.Time + Finished time.Time +} + +// Elapsed is how long the step took, or how long it has been running. +func (s BootStep) Elapsed() time.Duration { + if s.Started.IsZero() { + return 0 + } + if s.Finished.IsZero() { + return time.Since(s.Started) + } + return s.Finished.Sub(s.Started) +} + +// State is an immutable snapshot of the supervisor, safe to read from the UI +// goroutine. +type State struct { + Phase Phase + Steps []BootStep + Err string + StartedAt time.Time + ReadyAt time.Time + Restarts int + // NextRetryAt is set while Phase is PhaseRetrying. + NextRetryAt time.Time + + Config *Config + Server *tsnet.Server + Peers *PeerMonitor + Lan *LanScanner +} + +// Ready reports whether the service finished booting. +func (s State) Ready() bool { return s.Phase == PhaseReady } + +// Progress is the fraction of boot steps completed, for the splash bar. +func (s State) Progress() float32 { + if len(s.Steps) == 0 { + return 0 + } + done := 0 + for _, st := range s.Steps { + if st.State == StepDone || st.State == StepSkipped { + done++ + } + } + return float32(done) / float32(len(s.Steps)) +} + +// SupervisorOptions configures a Supervisor. +type SupervisorOptions struct { + ConfigPath string + ConfigURL string + TsnetDebug bool + Logger *slog.Logger + // MaxBackoff caps the retry delay. Zero means 30s. + MaxBackoff time.Duration +} + +// Supervisor owns the service lifecycle for the GUI. It is the same startup +// sequence the headless binary runs in serviceLogic, split into observable +// steps and wrapped in a restart loop that keeps the window alive when +// tailscale is unreachable — a CLI can exit on failure, a GUI must explain +// itself instead. +type Supervisor struct { + opt SupervisorOptions + logger *slog.Logger + + mu sync.RWMutex + state State + + subsMu sync.Mutex + subs map[int]chan struct{} + nextSub int + + restartCh chan struct{} + stopOnce sync.Once +} + +// NewSupervisor creates an unstarted supervisor. +func NewSupervisor(opt SupervisorOptions) *Supervisor { + logger := opt.Logger + if logger == nil { + logger = slog.Default() + } + if opt.MaxBackoff <= 0 { + opt.MaxBackoff = 30 * time.Second + } + return &Supervisor{ + opt: opt, + logger: logger.With("from", "supervisor"), + subs: make(map[int]chan struct{}), + restartCh: make(chan struct{}, 1), + state: State{ + Phase: PhaseIdle, + Steps: freshSteps(), + }, + } +} + +func freshSteps() []BootStep { + keys := []string{ + StepKeyConfig, StepKeyTsnet, StepKeyRules, + StepKeyServices, StepKeyMonitors, StepKeyReady, + } + steps := make([]BootStep, len(keys)) + for i, k := range keys { + steps[i] = BootStep{Key: k} + } + return steps +} + +// Snapshot returns the current state. +func (s *Supervisor) Snapshot() State { + s.mu.RLock() + defer s.mu.RUnlock() + st := s.state + st.Steps = append([]BootStep(nil), s.state.Steps...) + return st +} + +// Subscribe returns a coalescing wakeup channel and a cancel func. +func (s *Supervisor) Subscribe() (<-chan struct{}, func()) { + ch := make(chan struct{}, 1) + s.subsMu.Lock() + id := s.nextSub + s.nextSub++ + s.subs[id] = ch + s.subsMu.Unlock() + + var once sync.Once + return ch, func() { + once.Do(func() { + s.subsMu.Lock() + delete(s.subs, id) + s.subsMu.Unlock() + }) + } +} + +func (s *Supervisor) notify() { + s.subsMu.Lock() + for _, ch := range s.subs { + select { + case ch <- struct{}{}: + default: + } + } + s.subsMu.Unlock() +} + +func (s *Supervisor) update(f func(*State)) { + s.mu.Lock() + f(&s.state) + s.mu.Unlock() + s.notify() +} + +func (s *Supervisor) stepStart(key string) { + s.update(func(st *State) { + for i := range st.Steps { + if st.Steps[i].Key == key { + st.Steps[i].State = StepRunning + st.Steps[i].Started = time.Now() + st.Steps[i].Err = "" + return + } + } + }) +} + +func (s *Supervisor) stepDone(key string, err error) { + s.update(func(st *State) { + for i := range st.Steps { + if st.Steps[i].Key != key { + continue + } + st.Steps[i].Finished = time.Now() + if err != nil { + st.Steps[i].State = StepFailed + st.Steps[i].Err = err.Error() + } else { + st.Steps[i].State = StepDone + } + return + } + }) +} + +// Restart asks the supervisor to tear down and boot again. It never blocks. +func (s *Supervisor) Restart() { + select { + case s.restartCh <- struct{}{}: + default: + } +} + +// Run drives the boot-and-supervise loop until ctx is cancelled. It blocks, so +// callers run it on their own goroutine. +func (s *Supervisor) Run(ctx context.Context) { + backoff := time.Second + for { + if ctx.Err() != nil { + s.update(func(st *State) { st.Phase = PhaseStopped }) + return + } + + runCtx, cancel := context.WithCancel(ctx) + err := s.boot(runCtx) + if err == nil { + backoff = time.Second + // Supervise until something asks us to restart. + reason := s.supervise(runCtx) + cancel() + s.teardown() + if ctx.Err() != nil { + s.update(func(st *State) { st.Phase = PhaseStopped }) + return + } + s.logger.Warn("restarting service", "reason", reason) + s.update(func(st *State) { + st.Phase = PhaseRetrying + st.Restarts++ + st.Steps = freshSteps() + st.NextRetryAt = time.Now().Add(time.Second) + }) + select { + case <-ctx.Done(): + case <-time.After(time.Second): + } + continue + } + + cancel() + s.teardown() + if ctx.Err() != nil { + s.update(func(st *State) { st.Phase = PhaseStopped }) + return + } + + // Configuration errors will not fix themselves; surface them and wait + // for an explicit Restart rather than looping on a broken file. + if errors.Is(err, errFatalConfig) { + // Log it as well as showing it: the on-screen log sheet is the + // thing users screenshot, and a bare error panel with an empty log + // tells whoever is helping them nothing. + s.logger.Error("configuration error, waiting for retry", "err", err) + s.update(func(st *State) { + st.Phase = PhaseError + st.Err = err.Error() + }) + select { + case <-ctx.Done(): + s.update(func(st *State) { st.Phase = PhaseStopped }) + return + case <-s.restartCh: + s.update(func(st *State) { + st.Phase = PhaseStarting + st.Err = "" + st.Steps = freshSteps() + }) + continue + } + } + + s.logger.Warn("startup failed, retrying", "err", err, "backoff", backoff) + s.update(func(st *State) { + st.Phase = PhaseRetrying + st.Err = err.Error() + st.Restarts++ + st.NextRetryAt = time.Now().Add(backoff) + }) + select { + case <-ctx.Done(): + s.update(func(st *State) { st.Phase = PhaseStopped }) + return + case <-s.restartCh: + case <-time.After(backoff): + } + backoff *= 2 + if backoff > s.opt.MaxBackoff { + backoff = s.opt.MaxBackoff + } + s.update(func(st *State) { st.Steps = freshSteps() }) + } +} + +// errFatalConfig marks an error that retrying cannot fix. +var errFatalConfig = errors.New("configuration error") + +// boot runs the startup sequence, reporting each step. +func (s *Supervisor) boot(ctx context.Context) error { + s.update(func(st *State) { + st.Phase = PhaseStarting + st.Err = "" + st.StartedAt = time.Now() + st.ReadyAt = time.Time{} + st.NextRetryAt = time.Time{} + }) + + // --- config ----------------------------------------------------------- + s.stepStart(StepKeyConfig) + source := s.opt.ConfigPath + if s.opt.ConfigURL != "" { + source = s.opt.ConfigURL + s.logger.Info("using config url", "url", s.opt.ConfigURL) + } + cfg, err := LoadConfig(source) + if err != nil { + s.logger.Error("failed to load config", "source", source, "err", err) + s.stepDone(StepKeyConfig, err) + return errors.Join(errFatalConfig, err) + } + SetDoHServers(cfg.DNS.DoHServers) + if len(cfg.DNS.DoHServers) > 0 { + s.logger.Info("dns-over-https fallback enabled", "servers", cfg.DNS.DoHServers) + } + s.update(func(st *State) { st.Config = cfg }) + s.stepDone(StepKeyConfig, nil) + + // --- tsnet ------------------------------------------------------------ + s.stepStart(StepKeyTsnet) + srv, err := InitTsNet(ctx, &cfg.Core, s.logger, s.opt.TsnetDebug) + if err != nil { + s.stepDone(StepKeyTsnet, err) + return err + } + s.update(func(st *State) { st.Server = srv }) + s.stepDone(StepKeyTsnet, nil) + + // --- rules ------------------------------------------------------------ + s.stepStart(StepKeyRules) + NormalizeConnectRulesDstAddr(ctx, srv, cfg.Connect, s.logger) + s.stepDone(StepKeyRules, nil) + + // --- services --------------------------------------------------------- + s.stepStart(StepKeyServices) + StartForwarders(ctx, srv, cfg.Forward) + StartConnectors(ctx, srv, cfg.Connect) + RunLanDiscoverService(ctx, cfg.Connect, s.logger.With("from", "lan_service")) + s.stepDone(StepKeyServices, nil) + + // --- monitors --------------------------------------------------------- + s.stepStart(StepKeyMonitors) + peers := NewPeerMonitor(srv, cfg.Connect, s.logger, PeerMonitorOptions{}) + peers.Start(ctx) + + lan := NewLanScanner(s.logger.With("from", "lan_scan")) + lan.SetSelfEntries(LanEntriesFromRules(cfg.Connect)) + lan.Start(ctx) + + s.update(func(st *State) { + st.Peers = peers + st.Lan = lan + }) + s.stepDone(StepKeyMonitors, nil) + + // --- ready ------------------------------------------------------------ + s.stepStart(StepKeyReady) + s.stepDone(StepKeyReady, nil) + s.update(func(st *State) { + st.Phase = PhaseReady + st.ReadyAt = time.Now() + st.Err = "" + }) + s.logger.Info("service ready", "took", time.Since(s.Snapshot().StartedAt).Round(time.Millisecond)) + return nil +} + +// supervise blocks until the service should be restarted, returning why. +func (s *Supervisor) supervise(ctx context.Context) string { + watchdog := StartTimeWatchDog(ctx, s.logger.With("from", "watchdog")) + for { + select { + case <-ctx.Done(): + return "context cancelled" + case <-watchdog: + return "system time jump" + case <-s.restartCh: + return "requested by user" + } + } +} + +// teardown closes the tsnet server and clears the per-run state. +func (s *Supervisor) teardown() { + s.mu.Lock() + srv := s.state.Server + s.state.Server = nil + s.state.Peers = nil + s.state.Lan = nil + s.mu.Unlock() + + if srv != nil { + if err := srv.Close(); err != nil { + s.logger.Debug("closing tsnet server", "err", err) + } + } + s.notify() +} diff --git a/core/tsdiag.go b/core/tsdiag.go new file mode 100644 index 0000000..fcf91e3 --- /dev/null +++ b/core/tsdiag.go @@ -0,0 +1,225 @@ +package core + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "strconv" + "time" + + "tailscale.com/net/netcheck" + "tailscale.com/net/netmon" + "tailscale.com/tailcfg" + "tailscale.com/tsnet" + + "tslink/netdiag" +) + +// TsDiagSource adapts a running tsnet server to [netdiag.TailscaleSource]. +// +// The diagnostics package probes the network from scratch; this asks tailscale +// what it already believes. The two disagreeing is itself informative — for +// DERP latency and tailscale's own UPnP/PMP/PCP probe, tailscale's answer is +// the one that governs how the tunnel will actually behave. +type TsDiagSource struct { + srv *tsnet.Server + logger *slog.Logger +} + +// NewTailscaleSource wraps srv. A nil logger falls back to slog.Default. +func NewTailscaleSource(srv *tsnet.Server, logger *slog.Logger) *TsDiagSource { + if logger == nil { + logger = slog.Default() + } + return &TsDiagSource{srv: srv, logger: logger} +} + +// DefaultTailscaleSource returns a source for srv, or a nil interface when srv +// is nil, so callers can pass the result straight into netdiag.Options without +// tripping over a typed-nil interface. +func DefaultTailscaleSource(srv *tsnet.Server, logger *slog.Logger) netdiag.TailscaleSource { + if srv == nil { + return nil + } + return NewTailscaleSource(srv, logger) +} + +// netcheckTimeout bounds one report. netcheck's own full run probes every DERP +// region, which takes a while on a slow link. +const netcheckTimeout = 15 * time.Second + +// Netcheck runs tailscale's own network check and translates the result. +func (s *TsDiagSource) Netcheck(ctx context.Context) (rep *netdiag.TailscaleReport, err error) { + if s == nil || s.srv == nil { + return &netdiag.TailscaleReport{ + Status: netdiag.StatusSkipped, + Summary: "Tailscale 未运行", + }, errors.New("tsnet server is nil") + } + + lc, err := s.srv.LocalClient() + if err != nil { + return &netdiag.TailscaleReport{ + Status: netdiag.StatusSkipped, + Err: err.Error(), + }, err + } + + dm, err := lc.CurrentDERPMap(ctx) + if err != nil || dm == nil { + if err == nil { + err = errors.New("no DERP map available") + } + return &netdiag.TailscaleReport{ + Status: netdiag.StatusSkipped, + Err: err.Error(), + Summary: "无法获取 DERP 列表,跳过 Tailscale 内部检查", + }, err + } + + // A static monitor takes a one-shot snapshot of the interfaces without + // spawning the change-watching goroutines a long-lived Monitor would. That + // is what we want for a single report, and Close on a static monitor is a + // no-op. + mon := netmon.NewStatic() + + client := &netcheck.Client{ + NetMon: mon, + Logf: func(format string, args ...any) { + s.logger.With(slog.String("from", "netcheck")). + Debug(fmt.Sprintf(format, args...)) + }, + } + + runCtx, cancel := context.WithTimeout(ctx, netcheckTimeout) + defer cancel() + + // GetReport reaches into internal magicsock machinery; a panic there must + // degrade this one panel, not take the window down. + var raw *netcheck.Report + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("netcheck panicked: %v", r) + } + }() + raw, err = client.GetReport(runCtx, dm, &netcheck.GetReportOpts{}) + }() + if err != nil || raw == nil { + if err == nil { + err = errors.New("netcheck returned no report") + } + return &netdiag.TailscaleReport{ + Status: netdiag.StatusSkipped, + Err: err.Error(), + }, err + } + + return convertNetcheck(raw, dm), nil +} + +// convertNetcheck maps tailscale's report onto the diagnostics contract. +func convertNetcheck(raw *netcheck.Report, dm *tailcfg.DERPMap) *netdiag.TailscaleReport { + out := &netdiag.TailscaleReport{ + Available: true, + UDP: raw.UDP, + IPv4: raw.IPv4, + IPv6: raw.IPv6, + ICMPv4: raw.ICMPv4, + OSHasIPv6: raw.OSHasIPv6, + + MappingVariesByDestIP: optBool(raw.MappingVariesByDestIP.Get()), + UPnP: optBool(raw.UPnP.Get()), + PMP: optBool(raw.PMP.Get()), + PCP: optBool(raw.PCP.Get()), + CaptivePortal: optBool(raw.CaptivePortal.Get()), + } + if raw.GlobalV4.IsValid() { + out.GlobalV4 = raw.GlobalV4.String() + } + if raw.GlobalV6.IsValid() { + out.GlobalV6 = raw.GlobalV6.String() + } + + for id, latency := range raw.RegionLatency { + entry := netdiag.DERPLatency{ + RegionID: id, + Latency: latency, + Preferred: id == raw.PreferredDERP, + } + if dm != nil { + if region, ok := dm.Regions[id]; ok && region != nil { + entry.RegionCode = region.RegionCode + entry.Name = region.RegionName + } + } + if entry.RegionCode == "" { + entry.RegionCode = strconv.Itoa(id) + } + if entry.Name == "" { + entry.Name = entry.RegionCode + } + if entry.Preferred { + out.PreferredDERP = entry.RegionCode + } + out.DERP = append(out.DERP, entry) + } + sort.Slice(out.DERP, func(i, j int) bool { + if out.DERP[i].Latency != out.DERP[j].Latency { + return out.DERP[i].Latency < out.DERP[j].Latency + } + return out.DERP[i].RegionID < out.DERP[j].RegionID + }) + if out.PreferredDERP == "" && raw.PreferredDERP != 0 { + out.PreferredDERP = strconv.Itoa(raw.PreferredDERP) + } + + out.Status, out.Summary = netcheckVerdict(out) + return out +} + +// netcheckVerdict grades the report from the perspective of whether tailscale +// can carry traffic well, not whether every box is ticked. +func netcheckVerdict(r *netdiag.TailscaleReport) (netdiag.Status, string) { + switch { + case !r.UDP: + return netdiag.StatusFail, + "Tailscale 无法通过 UDP 与 DERP 通信,连接将非常不稳定" + case r.CaptivePortal != nil && *r.CaptivePortal: + return netdiag.StatusWarn, + "检测到门户劫持(Captive Portal),需要先在浏览器完成网络认证" + case len(r.DERP) == 0: + return netdiag.StatusWarn, + "没有任何 DERP 节点响应,中继回退可能不可用" + } + + best := r.DERP[0] + summary := fmt.Sprintf("首选 DERP %s,延迟 %dms", + nonEmpty(r.PreferredDERP, best.RegionCode), + best.Latency.Milliseconds()) + if r.MappingVariesByDestIP != nil && *r.MappingVariesByDestIP { + return netdiag.StatusWarn, + summary + ";NAT 映射随目标变化(对称型),直连打洞成功率低" + } + return netdiag.StatusOK, summary +} + +func nonEmpty(v, fallback string) string { + if v != "" { + return v + } + return fallback +} + +// optBool converts tailscale's opt.Bool (value, ok) pair into a tri-state +// pointer: nil means tailscale could not determine the answer, which is +// different from determining "no". +func optBool(v, ok bool) *bool { + if !ok { + return nil + } + out := v + return &out +} diff --git a/go.mod b/go.mod index 57a9117..b5ad809 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,17 @@ module tslink go 1.26.3 require ( + gioui.org v0.10.1 github.com/BurntSushi/toml v1.6.0 github.com/lmittmann/tint v1.1.3 github.com/mattn/go-colorable v0.1.13 + golang.org/x/net v0.53.0 tailscale.com v1.98.2 ) require ( filippo.io/edwards25519 v1.2.0 // indirect + gioui.org/shader v1.0.8 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/coder/websocket v1.8.12 // indirect @@ -19,6 +22,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.26.1 // indirect github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/go-text/typesetting v0.3.4 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect @@ -44,7 +48,8 @@ require ( go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/image v0.27.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect diff --git a/go.sum b/go.sum index f6518a2..682d85a 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,16 @@ 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0= +gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= @@ -76,6 +83,10 @@ github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHj github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU= +github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= +github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= @@ -193,6 +204,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= +golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= diff --git a/gui/app.go b/gui/app.go new file mode 100644 index 0000000..c5a2a86 --- /dev/null +++ b/gui/app.go @@ -0,0 +1,677 @@ +package gui + +import ( + "context" + "image" + "io" + "log/slog" + "strings" + "sync/atomic" + "time" + + "gioui.org/app" + "gioui.org/font" + "gioui.org/io/clipboard" + "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" +) + +// Options configures the GUI. +type Options struct { + Version string + ConfigPath string + ConfigURL string + Supervisor *core.Supervisor + Logs *core.LogBuffer + Logger *slog.Logger + // IPInfoToken is passed through to the diagnostics runner. + IPInfoToken string + // StartDark selects the initial theme. + StartDark bool +} + +// pageID identifies a top-level view. +type pageID int + +const ( + pageOverview pageID = iota + pagePeers + pageLan + pageDiag + pageLogs + pageSettings +) + +type navEntry struct { + id pageID + label Key + icon IconFunc + click widget.Clickable +} + +// App is the whole GUI. It owns the window event loop and holds every page's +// state. +type App struct { + opt Options + logger *slog.Logger + th *Theme + fonts *FontSet + + win *app.Window + + nav []navEntry + current pageID + + overview *overviewPage + peers *peersPage + lan *lanPage + diag *diagPage + logs *logsPage + settings *settingsPage + + splash *splashView + overlay *logOverlay + + overlayBtn widget.Clickable + themeBtn widget.Clickable + + toastMsg string + toastLevel StatusLevel + toastUntil time.Time + + // fontUpgrade carries CJK faces parsed off the UI goroutine. + fontUpgrade chan []font.FontFace + + // needsTick is set during layout when the current frame shows something + // that changes with wall-clock time — relative timestamps, uptime, a + // running step's elapsed counter. When it is false the periodic refresh is + // skipped and the window stops repainting altogether. + // + // This is not micro-optimisation: a full repaint costs tens of + // milliseconds under software rendering (Gio stencils every rounded + // rectangle and icon as a path), so a once-a-second refresh of a screen + // with nothing time-dependent on it is pure waste. + needsTick atomic.Bool +} + +// New builds the application. +func New(opt Options) *App { + logger := opt.Logger + if logger == nil { + logger = slog.Default() + } + fonts := LoadFonts() + th := NewTheme(fonts, opt.StartDark) + + a := &App{ + opt: opt, + logger: logger.With("from", "gui"), + th: th, + fonts: fonts, + current: pageOverview, + fontUpgrade: make(chan []font.FontFace, 1), + } + a.nav = []navEntry{ + {id: pageOverview, label: KNavOverview, icon: IconGrid}, + {id: pagePeers, label: KNavPeers, icon: IconNodes}, + {id: pageLan, label: KNavLan, icon: IconBroadcast}, + {id: pageDiag, label: KNavDiag, icon: IconPulse}, + {id: pageLogs, label: KNavLogs, icon: IconList}, + {id: pageSettings, label: KNavSettings, icon: IconSliders}, + } + a.overview = newOverviewPage() + a.peers = newPeersPage() + a.lan = newLanPage() + a.diag = newDiagPage(a) + a.logs = newLogsPage(a) + a.settings = newSettingsPage(a) + a.splash = newSplashView() + a.overlay = newLogOverlay() + return a +} + +// Run opens the window and drives the event loop. It returns when the window +// closes. +func (a *App) Run(ctx context.Context) error { + w := new(app.Window) + w.Option( + app.Title("tslink"), + app.Size(unit.Dp(1120), unit.Dp(740)), + app.MinSize(unit.Dp(880), unit.Dp(560)), + ) + a.win = w + + go a.watch(ctx, w) + go a.upgradeFonts() + + var ops op.Ops + for { + switch e := w.Event().(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + a.applyFontUpgrade() + a.layout(gtx) + e.Frame(gtx.Ops) + } + } +} + +// upgradeFonts parses the system CJK font off the UI goroutine. The splash +// screen exists partly to cover this: a 20 MB font collection takes long +// enough to parse that doing it inline would stall the first frame. +func (a *App) upgradeFonts() { + if !a.fonts.HasCJK || a.fonts.CJKPath == "" { + return + } + faces, err := LoadCJKFaces(a.fonts.CJKPath, a.logger) + if err != nil { + a.logger.Warn("failed to load cjk font, relying on system fallback", + "path", a.fonts.CJKPath, "err", err) + return + } + if len(faces) == 0 { + return + } + select { + case a.fontUpgrade <- faces: + if a.win != nil { + a.win.Invalidate() + } + default: + } +} + +func (a *App) applyFontUpgrade() { + select { + case faces := <-a.fontUpgrade: + merged := append(append([]font.FontFace(nil), a.fonts.Collection...), faces...) + a.fonts.Collection = merged + a.th.Shaper = text.NewShaper(text.WithCollection(merged)) + a.logger.Debug("shaper upgraded with cjk faces", "faces", len(faces)) + default: + } +} + +// watch coalesces change notifications from every data source into window +// invalidations, capped so a burst of log lines cannot drive the render loop. +func (a *App) watch(ctx context.Context, w *app.Window) { + var chans []<-chan struct{} + var cancels []func() + defer func() { + for _, c := range cancels { + c() + } + }() + + if a.opt.Supervisor != nil { + ch, cancel := a.opt.Supervisor.Subscribe() + chans = append(chans, ch) + cancels = append(cancels, cancel) + } + if a.opt.Logs != nil { + ch, cancel := a.opt.Logs.Subscribe() + chans = append(chans, ch) + cancels = append(cancels, cancel) + } + + // A ticker keeps relative timestamps ("3m ago") and the live latency + // column honest even when nothing else changed. + tick := time.NewTicker(time.Second) + defer tick.Stop() + + dirty := false + throttle := time.NewTicker(70 * time.Millisecond) + defer throttle.Stop() + + agg := make(chan struct{}, 1) + for _, ch := range chans { + go func(ch <-chan struct{}) { + for { + select { + case <-ctx.Done(): + return + case _, ok := <-ch: + if !ok { + return + } + select { + case agg <- struct{}{}: + default: + } + } + } + }(ch) + } + + for { + select { + case <-ctx.Done(): + return + case <-agg: + dirty = true + case <-tick.C: + if a.needsTick.Load() { + dirty = true + } + case <-throttle.C: + if dirty { + dirty = false + w.Invalidate() + } + } + } +} + +// state returns the current supervisor snapshot, or a zero value. +func (a *App) state() core.State { + if a.opt.Supervisor == nil { + return core.State{} + } + return a.opt.Supervisor.Snapshot() +} + +// --------------------------------------------------------------------------- +// Clipboard + toast +// --------------------------------------------------------------------------- + +// copyToClipboard puts s on the system clipboard and shows a confirmation. +func (a *App) copyToClipboard(gtx C, s string, msg string) { + gtx.Execute(clipboard.WriteCmd{ + Type: "application/text", + Data: io.NopCloser(strings.NewReader(s)), + }) + if msg == "" { + msg = a.th.T(KCopied) + } + a.notify(msg, LevelOK) +} + +// notify shows a transient message at the bottom of the window. +func (a *App) notify(msg string, level StatusLevel) { + a.toastMsg = msg + a.toastLevel = level + a.toastUntil = time.Now().Add(3200 * time.Millisecond) + if a.win != nil { + a.win.Invalidate() + } +} + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +func (a *App) layout(gtx C) D { + th := a.th + paint.Fill(gtx.Ops, th.P.Bg) + + st := a.state() + + // A terminal error screen has nothing that ages; everything else does + // (uptime, "last seen", a running step's timer). + a.needsTick.Store(st.Phase != core.PhaseError && st.Phase != core.PhaseStopped) + + // Handle nav clicks before drawing so the click lands on this frame. + for i := range a.nav { + if a.nav[i].click.Clicked(gtx) { + 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) + } + + return layout.Stack{}.Layout(gtx, + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min = gtx.Constraints.Max + if !st.Ready() { + // The splash owns the whole window until the service is up. + return a.splash.Layout(a, gtx, st) + } + return a.shell(gtx, st) + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min = gtx.Constraints.Max + return a.overlay.Layout(a, gtx, !st.Ready()) + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min = gtx.Constraints.Max + return a.layoutToast(gtx) + }), + ) +} + +// shell draws the sidebar plus the active page. +func (a *App) shell(gtx C, st core.State) D { + compact := gtx.Constraints.Max.X < gtx.Dp(1000) + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return a.sidebar(gtx, compact) + }), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return a.header(gtx, st) }), + layout.Flexed(1, func(gtx C) D { + return layout.Inset{ + Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceLG, + }.Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return a.page(gtx, st) + }) + }), + ) + }), + ) +} + +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: + return a.logs.Layout(a, gtx, st) + case pageSettings: + return a.settings.Layout(a, gtx, st) + default: + return a.overview.Layout(a, gtx, st) + } +} + +func (a *App) sidebar(gtx C, compact bool) D { + th := a.th + w := gtx.Dp(212) + if compact { + w = gtx.Dp(64) + } + gtx.Constraints.Min.X = w + gtx.Constraints.Max.X = w + + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + size := image.Pt(w, gtx.Constraints.Max.Y) + paint.FillShape(gtx.Ops, th.P.BgElevated, clip.Rect{Max: size}.Op()) + // Hairline separating rail from content. + paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{ + Min: image.Pt(size.X-1, 0), Max: size, + }.Op()) + return D{Size: size} + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min.X = w + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return a.brand(gtx, compact) }), + layout.Rigid(func(gtx C) D { + children := make([]layout.FlexChild, 0, len(a.nav)) + for i := range a.nav { + children = append(children, layout.Rigid(func(gtx C) D { + return a.navItem(gtx, &a.nav[i], compact) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }), + ) + }), + ) +} + +func (a *App) brand(gtx C, compact bool) D { + th := a.th + return layout.Inset{ + Top: SpaceXL, Bottom: SpaceLG, Left: SpaceLG, Right: SpaceLG, + }.Layout(gtx, func(gtx C) D { + if compact { + return layout.Center.Layout(gtx, func(gtx C) D { + return IconBroadcast(gtx, gtx.Dp(22), th.P.Accent) + }) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return IconBroadcast(gtx, gtx.Dp(20), th.P.Accent) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + l := th.Text(SizeSubtitle, th.P.TextPri, "tslink") + l.Font.Weight = font.Bold + return l.Layout(gtx) + }), + layout.Rigid(OneLine(th.Caption(th.T(KAppSubtitle))).Layout), + ) + }), + ) + }) +} + +func (a *App) navItem(gtx C, n *navEntry, compact bool) D { + th := a.th + selected := a.current == n.id + fg := th.P.TextSec + if selected { + fg = th.P.TextPri + } else if n.click.Hovered() { + fg = th.P.TextPri + } + + return n.click.Layout(gtx, func(gtx C) D { + return layout.Inset{Left: SpaceSM, Right: SpaceSM, Top: 2, Bottom: 2}.Layout(gtx, func(gtx C) D { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + size := gtx.Constraints.Min + switch { + case selected: + FillRRect(gtx, size, RadiusSM, WithAlpha(th.P.Accent, 0.16)) + paint.FillShape(gtx.Ops, th.P.Accent, clip.UniformRRect( + image.Rect(0, size.Y/2-gtx.Dp(8), gtx.Dp(3), size.Y/2+gtx.Dp(8)), + gtx.Dp(2)).Op(gtx.Ops)) + case n.click.Hovered(): + FillRRect(gtx, size, RadiusSM, th.P.SurfaceHi) + } + return D{Size: size} + }), + layout.Stacked(func(gtx C) D { + pad := layout.Inset{Top: 9, Bottom: 9, Left: SpaceMD, Right: SpaceMD} + if compact { + pad = layout.Inset{Top: 10, Bottom: 10} + } + return pad.Layout(gtx, func(gtx C) D { + if compact { + return layout.Center.Layout(gtx, func(gtx C) D { + return n.icon(gtx, gtx.Dp(19), fg) + }) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return n.icon(gtx, gtx.Dp(17), fg) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + l := th.Text(SizeBody, fg, th.T(n.label)) + if selected { + l.Font.Weight = font.Medium + } + return l.Layout(gtx) + }), + ) + }) + }), + ) + }) + }) +} + +func (a *App) header(gtx C, st core.State) D { + th := a.th + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + size := gtx.Constraints.Min + paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{ + Min: image.Pt(0, size.Y-1), Max: size, + }.Op()) + return D{Size: size} + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceMD, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return th.Title(a.pageTitle()).Layout(gtx) + }), + 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) + }), + ) + }) + }), + ) +} + +func (a *App) pageTitle() string { + th := a.th + for _, n := range a.nav { + if n.id == a.current { + return th.T(n.label) + } + } + return "tslink" +} + +func (a *App) statusPill(gtx C, st core.State) D { + th := a.th + var ( + label string + level StatusLevel + pulse bool + ) + switch st.Phase { + case core.PhaseReady: + // Steady state: no animation. See [Theme.StatusDot]. + label, level = th.T(KStateRunning), LevelOK + case core.PhaseStarting: + label, level = th.T(KStateConnecting), LevelInfo + pulse = true + case core.PhaseRetrying: + label, level = th.T(KStateRetrying), LevelWarn + pulse = true + case core.PhaseError: + label, level = th.T(KStateError), LevelFail + case core.PhaseStopped: + label, level = th.T(KStateStopped), LevelNeutral + default: + label, level = th.T(KStateStarting), LevelNeutral + } + + fg := th.StatusColor(level) + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + FillRRect(gtx, gtx.Constraints.Min, RadiusPill, WithAlpha(fg, 0.13)) + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return layout.Inset{Top: 5, Bottom: 5, Left: SpaceMD, Right: SpaceMD}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return th.StatusDot(gtx, level, pulse) + }), + HGap(SpaceSM), + layout.Rigid(th.Text(SizeCaption, fg, label).Layout), + ) + }) + }), + ) +} + +func (a *App) layoutToast(gtx C) D { + if a.toastMsg == "" || time.Now().After(a.toastUntil) { + return D{} + } + th := a.th + // Keep repainting until the toast expires. + gtx.Execute(op.InvalidateCmd{At: a.toastUntil}) + + return layout.S.Layout(gtx, func(gtx C) D { + return layout.Inset{Bottom: Space2XL}.Layout(gtx, func(gtx C) D { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + FillRRect(gtx, gtx.Constraints.Min, RadiusSM, th.P.SurfaceHi) + StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, th.P.Border) + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return layout.Inset{ + Top: SpaceSM, Bottom: SpaceSM, Left: SpaceLG, Right: SpaceLG, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return th.StatusDot(gtx, a.toastLevel, false) + }), + HGap(SpaceSM), + layout.Rigid(th.Text(SizeBody, th.P.TextPri, a.toastMsg).Layout), + ) + }) + }), + ) + }) + }) +} + +// --------------------------------------------------------------------------- +// Section heading used by pages +// --------------------------------------------------------------------------- + +// sectionTitle renders a page-level heading with an optional trailing widget. +func (a *App) sectionTitle(gtx C, title, subtitle string, trailing layout.Widget) D { + th := a.th + return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + l := th.Text(SizeSubtitle, th.P.TextPri, title) + l.Font.Weight = font.SemiBold + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if subtitle == "" { + return D{} + } + return th.Caption(subtitle).Layout(gtx) + }), + ) + }), + layout.Rigid(func(gtx C) D { + if trailing == nil { + return D{} + } + return trailing(gtx) + }), + ) + }) +} diff --git a/gui/chart.go b/gui/chart.go new file mode 100644 index 0000000..06ac1fb --- /dev/null +++ b/gui/chart.go @@ -0,0 +1,515 @@ +package gui + +import ( + "image" + "image/color" + "math" + "time" + + "gioui.org/f32" + "gioui.org/io/event" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" +) + +// ChartPoint is one sample. A point with OK false is a failed probe: the line +// breaks there rather than being interpolated across, because pretending a +// dropped ping was a slow one hides exactly the problem the user opened this +// panel to find. +type ChartPoint struct { + At time.Time + Value float64 // milliseconds + OK bool +} + +// ChartSeries is one line on the chart. +type ChartSeries struct { + Name string + Color color.NRGBA + Points []ChartPoint + Hidden bool + // Subtitle appears under the name in the legend, typically the peer's route. + Subtitle string +} + +// 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. + Now time.Time + // Unit labels the y axis. + Unit string + // FillSingle draws a soft gradient under the line when exactly one series + // is visible, which reads better than a lone stroke on a big canvas. + FillSingle bool +} + +// Chart is the stateful part of the plot: which point the pointer is near. +type Chart struct { + hover f32.Point + hovering bool + // plot is the last plotted rectangle, used to map hover x back to a time. + plot image.Rectangle +} + +// HoverIndex returns the sample index the pointer is nearest within s, or -1. +func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int { + if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 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))) + best, bestDelta := -1, time.Duration(math.MaxInt64) + for i, p := range series.Points { + d := p.At.Sub(target) + if d < 0 { + d = -d + } + if d < bestDelta { + best, bestDelta = i, d + } + } + // 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 { + return -1 + } + return best +} + +// 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 + } + h := gtx.Dp(st.Height) + if h <= 0 { + h = gtx.Dp(180) + } + w := gtx.Constraints.Max.X + size := image.Pt(w, h) + + gutterL := gtx.Dp(44) + gutterB := gtx.Dp(18) + plot := image.Rect(gutterL, gtx.Dp(6), w-gtx.Dp(6), h-gutterB) + c.plot = plot + if plot.Dx() <= 0 || plot.Dy() <= 0 { + return D{Size: size} + } + + // Pointer tracking over the plot area. + c.update(gtx, size) + + yMax := niceMax(maxVisible(series)) + tMin := st.Now.Add(-st.Window) + + c.drawGrid(t, gtx, plot, yMax, st) + 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.drawCrosshair(t, gtx, plot, series, st, tMin, yMax) + + return D{Size: size} +} + +func (c *Chart) update(gtx C, size image.Point) { + defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() + event.Op(gtx.Ops, c) + for { + ev, ok := gtx.Event(pointer.Filter{ + Target: c, + Kinds: pointer.Move | pointer.Enter | pointer.Leave | pointer.Drag, + }) + if !ok { + break + } + pe, ok := ev.(pointer.Event) + if !ok { + continue + } + switch pe.Kind { + case pointer.Leave, pointer.Cancel: + c.hovering = false + default: + c.hovering = true + c.hover = pe.Position + } + } +} + +func maxVisible(series []ChartSeries) float64 { + m := 0.0 + for _, s := range series { + if s.Hidden { + continue + } + for _, p := range s.Points { + if p.OK && p.Value > m { + m = p.Value + } + } + } + return m +} + +func visibleCount(series []ChartSeries) int { + n := 0 + for _, s := range series { + if !s.Hidden && len(s.Points) > 0 { + n++ + } + } + return n +} + +// niceMax rounds an axis maximum up to a 1/2/5 x 10^n step so the gridlines +// land on numbers a human reads without effort. +func niceMax(v float64) float64 { + if v <= 0 { + return 50 + } + v *= 1.15 // headroom so the peak is not glued to the top edge + exp := math.Floor(math.Log10(v)) + base := math.Pow(10, exp) + switch f := v / base; { + case f <= 1: + return base + case f <= 2: + return 2 * base + case f <= 5: + return 5 * base + default: + return 10 * base + } +} + +func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st ChartStyle) { + const rows = 4 + lineCol := WithAlpha(t.P.Border, 0.9) + for i := 0; i <= rows; i++ { + frac := float64(i) / rows + y := plot.Max.Y - int(frac*float64(plot.Dy())) + paint.FillShape(gtx.Ops, lineCol, clip.Rect{ + Min: image.Pt(plot.Min.X, y), + Max: image.Pt(plot.Max.X, y+1), + }.Op()) + + val := frac * yMax + lbl := t.MonoLabel(SizeCaption, t.P.TextDim, trimZero(val, 0)) + lbl.Alignment = text.End + off := op.Offset(image.Pt(0, y-gtx.Dp(7))).Push(gtx.Ops) + lgtx := gtx + lgtx.Constraints.Max.X = plot.Min.X - gtx.Dp(6) + lgtx.Constraints.Min.X = lgtx.Constraints.Max.X + lbl.Layout(lgtx) + off.Pop() + } + + // X axis: three labels, oldest to newest. + labels := []struct { + frac float64 + txt string + }{ + {0, "-" + FormatDuration(st.Window)}, + {0.5, "-" + FormatDuration(st.Window/2)}, + {1, "now"}, + } + if t.Lang == LangZH { + labels[2].txt = "现在" + } + for _, l := range labels { + x := plot.Min.X + int(l.frac*float64(plot.Dx())) + lbl := t.Text(SizeCaption, t.P.TextDim, l.txt) + switch { + case l.frac == 0: + lbl.Alignment = text.Start + case l.frac == 1: + lbl.Alignment = text.End + default: + lbl.Alignment = text.Middle + } + wide := gtx.Dp(70) + ox := x - wide/2 + if l.frac == 0 { + ox = x + } + if l.frac == 1 { + ox = x - wide + } + off := op.Offset(image.Pt(ox, plot.Max.Y+gtx.Dp(3))).Push(gtx.Ops) + lgtx := gtx + lgtx.Constraints.Max.X = wide + lgtx.Constraints.Min.X = wide + lbl.Layout(lgtx) + off.Pop() + } +} + +// pos maps a sample onto plot coordinates. +func pos(plot image.Rectangle, tMin, tMax time.Time, yMax float64, p ChartPoint) f32.Point { + span := tMax.Sub(tMin) + if span <= 0 { + span = time.Second + } + fx := float64(p.At.Sub(tMin)) / float64(span) + fx = math.Max(0, math.Min(1, fx)) + fy := p.Value / yMax + fy = math.Max(0, math.Min(1, fy)) + return f32.Pt( + float32(plot.Min.X)+float32(fx)*float32(plot.Dx()), + float32(plot.Max.Y)-float32(fy)*float32(plot.Dy()), + ) +} + +func (c *Chart) drawSeries(t *Theme, gtx C, plot image.Rectangle, s ChartSeries, tMin, tMax time.Time, yMax float64, fill bool) { + defer clip.Rect(plot).Push(gtx.Ops).Pop() + + // Optional area fill, drawn first so the stroke sits on top. + if fill { + var ap clip.Path + ap.Begin(gtx.Ops) + started := false + var lastX float32 + for _, p := range s.Points { + if !p.OK { + continue + } + pt := pos(plot, tMin, tMax, yMax, p) + if !started { + ap.MoveTo(f32.Pt(pt.X, float32(plot.Max.Y))) + ap.LineTo(pt) + started = true + } else { + ap.LineTo(pt) + } + lastX = pt.X + } + if started { + ap.LineTo(f32.Pt(lastX, float32(plot.Max.Y))) + ap.Close() + paint.FillShape(gtx.Ops, WithAlpha(s.Color, 0.13), clip.Outline{Path: ap.End()}.Op()) + } + } + + var p clip.Path + p.Begin(gtx.Ops) + pen := false + for _, sp := range s.Points { + if !sp.OK { + pen = false // break the line across a dropped probe + continue + } + pt := pos(plot, tMin, tMax, yMax, sp) + if !pen { + p.MoveTo(pt) + pen = true + } else { + p.LineTo(pt) + } + } + paint.FillShape(gtx.Ops, s.Color, + clip.Stroke{Path: p.End(), Width: float32(gtx.Dp(1.6))}.Op()) + + // Mark failures with a small tick on the baseline so loss is visible even + // when the surrounding samples are fine. + for _, sp := range s.Points { + if sp.OK { + continue + } + pt := pos(plot, tMin, tMax, yMax, ChartPoint{At: sp.At, Value: 0, OK: true}) + x := int(pt.X) + paint.FillShape(gtx.Ops, WithAlpha(t.P.Fail, 0.75), clip.Rect{ + Min: image.Pt(x, plot.Max.Y-gtx.Dp(5)), + Max: image.Pt(x+max(gtx.Dp(1.5), 1), plot.Max.Y), + }.Op()) + } + + // A dot on the most recent successful sample anchors the eye to "now". + for i := len(s.Points) - 1; i >= 0; i-- { + if !s.Points[i].OK { + continue + } + pt := pos(plot, tMin, tMax, yMax, s.Points[i]) + d := gtx.Dp(5) + off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops) + Circle(gtx, d, s.Color) + off.Pop() + break + } +} + +func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, st ChartStyle, tMin time.Time, yMax float64) { + if !c.hovering { + return + } + x := int(c.hover.X) + if x < plot.Min.X || x > plot.Max.X { + return + } + paint.FillShape(gtx.Ops, WithAlpha(t.P.TextDim, 0.5), clip.Rect{ + Min: image.Pt(x, plot.Min.Y), + Max: image.Pt(x+1, plot.Max.Y), + }.Op()) + + for _, s := range series { + if s.Hidden { + continue + } + i := c.HoverIndex(s, st) + if i < 0 || !s.Points[i].OK { + continue + } + pt := pos(plot, tMin, st.Now, 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) + inner := gtx.Dp(3) + off2 := op.Offset(image.Pt((d-inner)/2, (d-inner)/2)).Push(gtx.Ops) + Circle(gtx, inner, t.P.Bg) + off2.Pop() + off.Pop() + } +} + +// --------------------------------------------------------------------------- +// Legend +// --------------------------------------------------------------------------- + +// LegendEntry is one row of the chart legend. +type LegendEntry struct { + Name string + Subtitle string + Color color.NRGBA + Value string + Hidden bool +} + +// 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. +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)) + for i := range entries { + children = append(children, layout.Rigid(click(i))) + } + return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...) +} + +// LegendChip draws one legend entry. +func (t *Theme) LegendChip(gtx C, e LegendEntry, hovered bool) D { + fg := t.P.TextSec + swatch := e.Color + if e.Hidden { + fg = WithAlpha(t.P.TextDim, 0.7) + swatch = WithAlpha(e.Color, 0.3) + } + if hovered { + fg = t.P.TextPri + } + return layout.Inset{Right: SpaceMD, Top: 3, Bottom: 3}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D { + h := gtx.Dp(3) + w := gtx.Dp(12) + FillRRect(gtx, image.Pt(w, h), RadiusPill, swatch) + return D{Size: image.Pt(w, h)} + }) + }), + layout.Rigid(OneLine(t.Text(SizeCaption, fg, e.Name)).Layout), + layout.Rigid(func(gtx C) D { + if e.Value == "" { + return D{} + } + return layout.Inset{Left: 5}.Layout(gtx, + t.MonoLabel(SizeCaption, WithAlpha(fg, 0.8), e.Value).Layout) + }), + ) + }) +} + +// --------------------------------------------------------------------------- +// Sparkline +// --------------------------------------------------------------------------- + +// Sparkline draws a compact latency trace for a table row: no axes, no labels, +// just the shape of the last few minutes. +func (t *Theme) Sparkline(gtx C, points []ChartPoint, col color.NRGBA, w, h unit.Dp) D { + width, height := gtx.Dp(w), gtx.Dp(h) + size := image.Pt(width, height) + if len(points) < 2 || width <= 0 || height <= 0 { + // A flat hairline is a clearer "no data yet" than empty space. + paint.FillShape(gtx.Ops, WithAlpha(t.P.Border, 0.8), clip.Rect{ + Min: image.Pt(0, height/2), + Max: image.Pt(width, height/2+1), + }.Op()) + return D{Size: size} + } + + yMax := 0.0 + for _, p := range points { + if p.OK && p.Value > yMax { + yMax = p.Value + } + } + if yMax <= 0 { + yMax = 1 + } + yMax *= 1.2 + + plot := image.Rect(0, 1, width, height-1) + tMin, tMax := points[0].At, points[len(points)-1].At + if !tMax.After(tMin) { + tMax = tMin.Add(time.Second) + } + + defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() + var p clip.Path + p.Begin(gtx.Ops) + pen := false + for _, sp := range points { + if !sp.OK { + pen = false + continue + } + pt := pos(plot, tMin, tMax, yMax, sp) + if !pen { + p.MoveTo(pt) + pen = true + } else { + p.LineTo(pt) + } + } + paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: float32(gtx.Dp(1.3))}.Op()) + + for _, sp := range points { + if sp.OK { + continue + } + pt := pos(plot, tMin, tMax, yMax, ChartPoint{At: sp.At, Value: 0, OK: true}) + x := int(pt.X) + paint.FillShape(gtx.Ops, WithAlpha(t.P.Fail, 0.8), clip.Rect{ + Min: image.Pt(x, plot.Max.Y-gtx.Dp(3)), + Max: image.Pt(x+1, plot.Max.Y), + }.Op()) + } + return D{Size: size} +} diff --git a/gui/fontload.go b/gui/fontload.go new file mode 100644 index 0000000..bb789d5 --- /dev/null +++ b/gui/fontload.go @@ -0,0 +1,241 @@ +package gui + +import ( + "io/fs" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "gioui.org/font" + "gioui.org/font/gofont" + "gioui.org/font/opentype" +) + +// FontSet is the typeface configuration the theme is built from. +// +// Gio v0.10 already consults the operating system's fonts through go-text's +// fontscan, which handles CJK fallback on a well-configured desktop. We do not +// rely on that alone: minimal Linux images (containers, netboot, some NAS +// distros) ship a broken or empty font index, and the failure mode there is a +// window full of tofu boxes with no explanation. So we additionally locate a +// CJK font file ourselves and load it explicitly. +type FontSet struct { + Collection []font.FontFace + UI font.Typeface + Mono font.Typeface + + // HasCJK reports whether Chinese text can be rendered. It drives the + // default UI language: showing Chinese labels we cannot draw is worse than + // showing English ones. + HasCJK bool + + // CJKPath is the font file backing HasCJK, for display in the about panel. + CJKPath string +} + +// LoadFonts builds the initial font set. It is deliberately cheap — only a +// handful of os.Stat calls — so the window can open immediately. The actual +// CJK font file is parsed later by [LoadCJKFaces] while the splash screen is +// up. +func LoadFonts() *FontSet { + fs := &FontSet{ + Collection: gofont.Collection(), + UI: "Go", + Mono: "Go Mono", + } + if path, ok := FindCJKFont(); ok { + fs.HasCJK = true + fs.CJKPath = path + } + return fs +} + +// cjkCandidates returns absolute font paths to try, best first. Smaller +// single-script files come before the big pan-CJK collections: parsing a 20 MB +// .ttc costs a few hundred milliseconds and five faces we will never use. +func cjkCandidates() []string { + switch runtime.GOOS { + case "windows": + dirs := []string{} + if w := os.Getenv("WINDIR"); w != "" { + dirs = append(dirs, filepath.Join(w, "Fonts")) + } + if l := os.Getenv("LOCALAPPDATA"); l != "" { + dirs = append(dirs, filepath.Join(l, "Microsoft", "Windows", "Fonts")) + } + names := []string{ + "msyh.ttc", "msyh.ttf", // 微软雅黑 + "msyhl.ttc", "Deng.ttf", // 等线 + "simhei.ttf", // 黑体 + "simsun.ttc", "simsun.ttf", + "msjh.ttc", // 微軟正黑體 + } + var out []string + for _, d := range dirs { + for _, n := range names { + out = append(out, filepath.Join(d, n)) + } + } + return out + + case "darwin": + return []string{ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/System/Library/Fonts/STHeiti Light.ttc", + "/System/Library/Fonts/STHeiti Medium.ttc", + "/Library/Fonts/Arial Unicode.ttf", + "/System/Library/Fonts/Supplemental/Songti.ttc", + } + + default: // linux, bsd + return []string{ + // Debian/Ubuntu single-script Noto, the cheapest good option. + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", + "/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf", + // Fedora/Arch layouts. + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/adobe-source-han-sans/SourceHanSansSC-Regular.otf", + "/usr/share/fonts/opentype/source-han-sans/SourceHanSansSC-Regular.otf", + // Lightweight fallbacks common on embedded/NAS systems. + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + "/usr/share/fonts/truetype/droid/DroidSansFallback.ttf", + } + } +} + +// fontSearchDirs are walked when no candidate path matched. +func fontSearchDirs() []string { + var dirs []string + switch runtime.GOOS { + case "windows": + if w := os.Getenv("WINDIR"); w != "" { + dirs = append(dirs, filepath.Join(w, "Fonts")) + } + case "darwin": + dirs = append(dirs, "/System/Library/Fonts", "/Library/Fonts") + default: + dirs = append(dirs, "/usr/share/fonts", "/usr/local/share/fonts") + } + if home, err := os.UserHomeDir(); err == nil { + switch runtime.GOOS { + case "darwin": + dirs = append(dirs, filepath.Join(home, "Library", "Fonts")) + case "windows": + default: + dirs = append(dirs, filepath.Join(home, ".local", "share", "fonts"), filepath.Join(home, ".fonts")) + } + } + return dirs +} + +// cjkNameHints match filenames of fonts known to carry Han glyphs. +var cjkNameHints = []string{ + "notosanscjk", "notoserifcjk", "notosanssc", "notosanstc", "notosanshk", + "sourcehansans", "sourcehanserif", "wqy-microhei", "wqy-zenhei", + "droidsansfallback", "msyh", "simhei", "simsun", "pingfang", "hiragino", + "stheiti", "unifont", "arphic", "uming", "ukai", "microhei", "zenhei", + "opposans", "harmonyos_sans_sc", "arialuni", +} + +// FindCJKFont locates a font file with Chinese coverage. The walk is bounded so +// a pathological font directory cannot stall startup. +func FindCJKFont() (string, bool) { + for _, p := range cjkCandidates() { + if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Size() > 0 { + return p, true + } + } + + deadline := time.Now().Add(600 * time.Millisecond) + seen := 0 + for _, dir := range fontSearchDirs() { + var found string + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // unreadable subtree, keep going + } + if seen++; seen > 20000 || time.Now().After(deadline) { + return filepath.SkipAll + } + if d.IsDir() { + return nil + } + name := strings.ToLower(d.Name()) + switch { + case strings.HasSuffix(name, ".ttf"), + strings.HasSuffix(name, ".ttc"), + strings.HasSuffix(name, ".otf"), + strings.HasSuffix(name, ".otc"): + default: + return nil + } + for _, hint := range cjkNameHints { + if strings.Contains(name, hint) { + found = path + return filepath.SkipAll + } + } + return nil + }) + if found != "" { + return found, true + } + } + return "", false +} + +// maxFontBytes caps how large a font file we are willing to read. Pan-CJK +// collections run to ~40 MB; anything beyond that is not a font we want. +const maxFontBytes = 64 << 20 + +// LoadCJKFaces parses the font file at path and returns its faces, ready to be +// appended to a collection. It is slow enough (tens to hundreds of +// milliseconds) that callers should run it off the UI goroutine — which is +// exactly what the splash screen exists for. +func LoadCJKFaces(path string, logger *slog.Logger) ([]font.FontFace, error) { + if logger == nil { + logger = slog.Default() + } + st, err := os.Stat(path) + if err != nil { + return nil, err + } + if st.Size() > maxFontBytes { + logger.Warn("cjk font too large, skipping", "path", path, "bytes", st.Size()) + return nil, nil + } + start := time.Now() + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + faces, err := opentype.ParseCollection(data) + 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] + } + logger.Debug("cjk font loaded", + "path", path, + "faces", len(faces), + "bytes", st.Size(), + "took", time.Since(start).Round(time.Millisecond), + ) + return faces, nil +} + +// goCollection returns the built-in Go font faces. It exists so tests can +// build a theme without touching the host's font configuration. +func goCollection() []font.FontFace { return gofont.Collection() } diff --git a/gui/i18n.go b/gui/i18n.go new file mode 100644 index 0000000..dea8393 --- /dev/null +++ b/gui/i18n.go @@ -0,0 +1,631 @@ +package gui + +// Lang selects the UI label set. Chinese is the project's primary audience; +// English exists because a machine without a CJK font cannot draw Chinese, and +// silently rendering tofu boxes would be worse than translating. +type Lang int + +const ( + LangZH Lang = iota + LangEN +) + +// Name is the language's own name, for the settings toggle. +func (l Lang) Name() string { + if l == LangEN { + return "English" + } + return "中文" +} + +// Key identifies a translatable string. +type Key int + +const ( + KAppTitle Key = iota + KAppSubtitle + + // Navigation. + KNavOverview + KNavPeers + KNavLan + KNavDiag + KNavLogs + KNavSettings + + // Service lifecycle. + KStateStarting + KStateConnecting + KStateRunning + KStateDegraded + KStateStopped + KStateError + KStateRetrying + + // Splash steps. + KStepConfig + KStepFonts + KStepTsnet + KStepRules + KStepDiscovery + KStepMonitors + KStepReady + KSplashHint + KSplashLogHint + KSplashRetry + + // Shared vocabulary. + KYes + KNo + KUnknown + KSupported + KUnsupported + KEnabled + KDisabled + KNone + KRefresh + KRetry + KClose + KCopy + KCopied + KDetails + KLoading + KError + KNever + KJustNow + KSecondsAgo + KMinutesAgo + KHoursAgo + KTotal + KOnline + KOffline + + // Overview. + KOvTailnet + KOvSelf + KOvPeersOnline + KOvLanServers + KOvForwardRules + KOvConnectRules + KOvUptime + KOvHealth + KOvQuickDiag + KOvNoIssues + + // Peers page. + KPeersTitle + KPeersLinked + KPeersOther + KPeersEmpty + KPeerLatency + KPeerRoute + KPeerRouteDirect + KPeerRouteDERP + KPeerRoutePeerRelay + KPeerRouteOffline + KPeerRouteUnknown + KPeerAvg + KPeerMin + KPeerMax + KPeerJitter + KPeerLoss + KPeerRx + KPeerTx + KPeerLastSeen + KPeerLastHandshake + KPeerAddresses + KPeerEndpoint + KPeerOS + KPeerExitNode + KPeerTags + KGraphTitle + KGraphEmpty + KGraphWindow + KGraphLegendHint + + // LAN page. + KLanTitle + KLanSubtitle + KLanEmpty + KLanListening + KLanMotd + KLanPort + KLanAddress + KLanSeen + KLanSelf + KLanSelfHint + KLanPackets + KLanBindError + + // Diagnostics page. + KDiagTitle + KDiagRun + KDiagRunning + KDiagRerun + KDiagNever + KDiagLastRun + KDiagCopyReport + KDiagSecIface + KDiagSecUDP + KDiagSecNAT + KDiagSecPortMap + KDiagSecOverseas + KDiagSecEgress + KDiagSecTailscale + KDiagNatType + KDiagNatMapping + KDiagNatFiltering + KDiagNatHairpin + KDiagNatPortPreserve + KDiagUdpV4 + KDiagUdpV6 + KDiagUdpPortsOK + KDiagUdpPortsBlocked + KDiagIfaceDefaultV4 + KDiagIfaceDefaultV6 + KDiagUPnP + KDiagNATPMP + KDiagPCP + KDiagGateway + KDiagExternalIP + KDiagOverseasTarget + KDiagEgressMethod + KDiagEgressIP + KDiagEgressGeo + KDiagEgressDivergent + KDiagEgressDivergentHint + KDiagGeoSkipped + KDiagPreferredDERP + KDiagDerpLatency + KDiagCaptivePortal + KDiagMappingVaries + KDiagSkipGeo + KDiagSkipGeoHint + + // NAT names. + KNatOpen + KNatFullCone + KNatRestricted + KNatPortRestricted + KNatSymmetric + KNatUDPBlocked + KNatSymmetricFW + KNatUnknown + + // Logs page. + KLogsTitle + KLogsSearch + KLogsLevel + KLogsSource + KLogsFollow + KLogsAll + KLogsEmpty + KLogsCopyAll + KLogsSaveFile + KLogsUpload + KLogsUploading + KLogsUploaded + KLogsUploadFail + KLogsRedact + KLogsRedactHint + KLogsShown + KLogsDropped + KLogsIncludeDiag + KLogsOpenOverlay + + // Settings. + KSetTheme + KSetThemeDark + KSetThemeLight + KSetLanguage + KSetAbout + KSetConfigPath + KSetVersion + KSetFont + KSetFontMissing + + kCount +) + +var zhStrings = [kCount]string{ + KAppTitle: "tslink", + KAppSubtitle: "Tailscale 内网穿透", + + KNavOverview: "概览", + KNavPeers: "节点", + KNavLan: "局域网", + KNavDiag: "网络诊断", + KNavLogs: "日志", + KNavSettings: "设置", + + KStateStarting: "正在启动", + KStateConnecting: "正在连接", + KStateRunning: "运行中", + KStateDegraded: "降级运行", + KStateStopped: "已停止", + KStateError: "出错", + KStateRetrying: "正在重试", + + KStepConfig: "读取配置", + KStepFonts: "加载字体", + KStepTsnet: "接入 Tailscale 网络", + KStepRules: "解析转发规则", + KStepDiscovery: "启动局域网发现", + KStepMonitors: "启动状态监控", + KStepReady: "准备就绪", + KSplashHint: "首次接入 Tailscale 可能需要十几秒", + KSplashLogHint: "实时日志(截图时可一并保留)", + KSplashRetry: "启动失败,正在重试", + + KYes: "是", + KNo: "否", + KUnknown: "未知", + KSupported: "支持", + KUnsupported: "不支持", + KEnabled: "已启用", + KDisabled: "已禁用", + KNone: "无", + KRefresh: "刷新", + KRetry: "重试", + KClose: "关闭", + KCopy: "复制", + KCopied: "已复制", + KDetails: "详情", + KLoading: "加载中", + KError: "错误", + KNever: "从未", + KJustNow: "刚刚", + KSecondsAgo: "秒前", + KMinutesAgo: "分钟前", + KHoursAgo: "小时前", + KTotal: "共", + KOnline: "在线", + KOffline: "离线", + + KOvTailnet: "Tailnet", + KOvSelf: "本机", + KOvPeersOnline: "在线节点", + KOvLanServers: "局域网服务器", + KOvForwardRules: "转发规则", + KOvConnectRules: "连接规则", + KOvUptime: "运行时长", + KOvHealth: "健康状况", + KOvQuickDiag: "运行网络诊断", + KOvNoIssues: "未发现问题", + + KPeersTitle: "Tailscale 节点", + KPeersLinked: "已关联", + KPeersOther: "其他节点", + KPeersEmpty: "暂无节点", + KPeerLatency: "延迟", + KPeerRoute: "链路", + KPeerRouteDirect: "直连", + KPeerRouteDERP: "DERP 中继", + KPeerRoutePeerRelay: "对等中继", + KPeerRouteOffline: "离线", + KPeerRouteUnknown: "未知", + KPeerAvg: "平均", + KPeerMin: "最低", + KPeerMax: "最高", + KPeerJitter: "抖动", + KPeerLoss: "丢包", + KPeerRx: "接收", + KPeerTx: "发送", + KPeerLastSeen: "最后在线", + KPeerLastHandshake: "最后握手", + KPeerAddresses: "地址", + KPeerEndpoint: "端点", + KPeerOS: "系统", + KPeerExitNode: "出口节点", + KPeerTags: "标签", + KGraphTitle: "延迟图谱", + KGraphEmpty: "正在采集延迟数据", + KGraphWindow: "最近 20 分钟", + KGraphLegendHint: "点击图例可隐藏对应节点", + + KLanTitle: "局域网 Minecraft 服务器", + KLanSubtitle: "监听 224.0.2.60:4445 的广播", + KLanEmpty: "未发现局域网服务器", + KLanListening: "监听中", + KLanMotd: "服务器名称", + KLanPort: "端口", + KLanAddress: "地址", + KLanSeen: "最后广播", + KLanSelf: "本机广播", + KLanSelfHint: "由 tslink 转发并广播,说明隧道已生效", + KLanPackets: "收包", + KLanBindError: "无法监听组播", + + KDiagTitle: "网络诊断", + KDiagRun: "开始诊断", + KDiagRunning: "诊断中", + KDiagRerun: "重新诊断", + KDiagNever: "尚未运行诊断", + KDiagLastRun: "上次运行", + KDiagCopyReport: "复制诊断报告", + KDiagSecIface: "本机出口地址", + KDiagSecUDP: "UDP 连通性", + KDiagSecNAT: "NAT 类型", + KDiagSecPortMap: "端口映射", + KDiagSecOverseas: "境外连通性", + KDiagSecEgress: "出口 IP 与归属地", + KDiagSecTailscale: "Tailscale 内部状态", + KDiagNatType: "NAT 类型", + KDiagNatMapping: "映射行为", + KDiagNatFiltering: "过滤行为", + KDiagNatHairpin: "发夹回环", + KDiagNatPortPreserve: "端口保持", + KDiagUdpV4: "IPv4 UDP", + KDiagUdpV6: "IPv6 UDP", + KDiagUdpPortsOK: "可用端口", + KDiagUdpPortsBlocked: "被封端口", + KDiagIfaceDefaultV4: "默认 IPv4 源地址", + KDiagIfaceDefaultV6: "默认 IPv6 源地址", + KDiagUPnP: "UPnP IGD", + KDiagNATPMP: "NAT-PMP", + KDiagPCP: "PCP", + KDiagGateway: "网关", + KDiagExternalIP: "外部地址", + KDiagOverseasTarget: "测试目标", + KDiagEgressMethod: "探测方式", + KDiagEgressIP: "出口 IP", + KDiagEgressGeo: "归属地", + KDiagEgressDivergent: "出口不一致", + KDiagEgressDivergentHint: "不同探测方式得到了不同的公网 IP,通常说明有代理或分流工具在生效", + KDiagGeoSkipped: "已跳过归属地查询", + KDiagPreferredDERP: "首选 DERP", + KDiagDerpLatency: "DERP 延迟", + KDiagCaptivePortal: "门户劫持", + KDiagMappingVaries: "映射随目标变化", + KDiagSkipGeo: "不查询归属地", + KDiagSkipGeoHint: "归属地查询会把你的公网 IP 发送给第三方服务", + + KNatOpen: "开放网络", + KNatFullCone: "完全锥形", + KNatRestricted: "地址限制锥形", + KNatPortRestricted: "端口限制锥形", + KNatSymmetric: "对称型", + KNatUDPBlocked: "UDP 被阻断", + KNatSymmetricFW: "对称型防火墙", + KNatUnknown: "无法判定", + + KLogsTitle: "日志", + KLogsSearch: "搜索日志…", + KLogsLevel: "级别", + KLogsSource: "来源", + KLogsFollow: "自动跟随", + KLogsAll: "全部", + KLogsEmpty: "没有匹配的日志", + KLogsCopyAll: "复制到剪贴板", + KLogsSaveFile: "保存到文件", + KLogsUpload: "上传并分享", + KLogsUploading: "正在上传", + KLogsUploaded: "上传成功,链接已复制", + KLogsUploadFail: "上传失败", + KLogsRedact: "隐去密钥", + KLogsRedactHint: "上传前会自动隐去 authkey 等凭据", + KLogsShown: "已显示", + KLogsDropped: "条早期日志已被丢弃", + KLogsIncludeDiag: "附带诊断报告", + KLogsOpenOverlay: "浮层日志", + + KSetTheme: "主题", + KSetThemeDark: "深色", + KSetThemeLight: "浅色", + KSetLanguage: "语言", + KSetAbout: "关于", + KSetConfigPath: "配置文件", + KSetVersion: "版本", + KSetFont: "中文字体", + KSetFontMissing: "未找到中文字体,界面已切换为英文", +} + +var enStrings = [kCount]string{ + KAppTitle: "tslink", + KAppSubtitle: "Tailscale link layer", + + KNavOverview: "Overview", + KNavPeers: "Peers", + KNavLan: "LAN", + KNavDiag: "Diagnostics", + KNavLogs: "Logs", + KNavSettings: "Settings", + + KStateStarting: "Starting", + KStateConnecting: "Connecting", + KStateRunning: "Running", + KStateDegraded: "Degraded", + KStateStopped: "Stopped", + KStateError: "Error", + KStateRetrying: "Retrying", + + KStepConfig: "Loading configuration", + KStepFonts: "Loading fonts", + KStepTsnet: "Joining the tailnet", + KStepRules: "Resolving forward rules", + KStepDiscovery: "Starting LAN discovery", + KStepMonitors: "Starting monitors", + KStepReady: "Ready", + KSplashHint: "The first tailnet join can take a dozen seconds", + KSplashLogHint: "Live log (stays visible in screenshots)", + KSplashRetry: "Startup failed, retrying", + + KYes: "Yes", + KNo: "No", + KUnknown: "Unknown", + KSupported: "Supported", + KUnsupported: "Not supported", + KEnabled: "Enabled", + KDisabled: "Disabled", + KNone: "None", + KRefresh: "Refresh", + KRetry: "Retry", + KClose: "Close", + KCopy: "Copy", + KCopied: "Copied", + KDetails: "Details", + KLoading: "Loading", + KError: "Error", + KNever: "Never", + KJustNow: "just now", + KSecondsAgo: "s ago", + KMinutesAgo: "m ago", + KHoursAgo: "h ago", + KTotal: "Total", + KOnline: "Online", + KOffline: "Offline", + + KOvTailnet: "Tailnet", + KOvSelf: "This node", + KOvPeersOnline: "Peers online", + KOvLanServers: "LAN servers", + KOvForwardRules: "Forward rules", + KOvConnectRules: "Connect rules", + KOvUptime: "Uptime", + KOvHealth: "Health", + KOvQuickDiag: "Run diagnostics", + KOvNoIssues: "No issues found", + + KPeersTitle: "Tailscale peers", + KPeersLinked: "Linked", + KPeersOther: "Other peers", + KPeersEmpty: "No peers yet", + KPeerLatency: "Latency", + KPeerRoute: "Route", + KPeerRouteDirect: "Direct", + KPeerRouteDERP: "DERP relay", + KPeerRoutePeerRelay: "Peer relay", + KPeerRouteOffline: "Offline", + KPeerRouteUnknown: "Unknown", + KPeerAvg: "avg", + KPeerMin: "min", + KPeerMax: "max", + KPeerJitter: "jitter", + KPeerLoss: "loss", + KPeerRx: "Rx", + KPeerTx: "Tx", + KPeerLastSeen: "Last seen", + KPeerLastHandshake: "Last handshake", + KPeerAddresses: "Addresses", + KPeerEndpoint: "Endpoint", + KPeerOS: "OS", + KPeerExitNode: "Exit node", + KPeerTags: "Tags", + KGraphTitle: "Latency graph", + KGraphEmpty: "Collecting latency samples", + KGraphWindow: "last 20 minutes", + 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", + + KDiagTitle: "Network diagnostics", + KDiagRun: "Run diagnostics", + KDiagRunning: "Running", + KDiagRerun: "Run again", + KDiagNever: "Not run yet", + KDiagLastRun: "Last run", + KDiagCopyReport: "Copy report", + KDiagSecIface: "Local egress addresses", + KDiagSecUDP: "UDP connectivity", + KDiagSecNAT: "NAT type", + KDiagSecPortMap: "Port mapping", + KDiagSecOverseas: "Overseas reachability", + KDiagSecEgress: "Egress IP and geolocation", + KDiagSecTailscale: "Tailscale internals", + KDiagNatType: "NAT type", + KDiagNatMapping: "Mapping behaviour", + KDiagNatFiltering: "Filtering behaviour", + KDiagNatHairpin: "Hairpinning", + KDiagNatPortPreserve: "Port preserving", + KDiagUdpV4: "IPv4 UDP", + KDiagUdpV6: "IPv6 UDP", + KDiagUdpPortsOK: "Reachable ports", + KDiagUdpPortsBlocked: "Blocked ports", + KDiagIfaceDefaultV4: "Default IPv4 source", + KDiagIfaceDefaultV6: "Default IPv6 source", + KDiagUPnP: "UPnP IGD", + KDiagNATPMP: "NAT-PMP", + KDiagPCP: "PCP", + KDiagGateway: "Gateway", + KDiagExternalIP: "External address", + KDiagOverseasTarget: "Target", + KDiagEgressMethod: "Method", + 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", + KDiagGeoSkipped: "Geolocation skipped", + KDiagPreferredDERP: "Preferred DERP", + KDiagDerpLatency: "DERP latency", + KDiagCaptivePortal: "Captive portal", + KDiagMappingVaries: "Mapping varies by destination", + KDiagSkipGeo: "Skip geolocation", + KDiagSkipGeoHint: "Geolocation sends your public IP to a third-party service", + + KNatOpen: "Open internet", + KNatFullCone: "Full cone", + KNatRestricted: "Address-restricted cone", + KNatPortRestricted: "Port-restricted cone", + KNatSymmetric: "Symmetric", + KNatUDPBlocked: "UDP blocked", + KNatSymmetricFW: "Symmetric firewall", + KNatUnknown: "Undetermined", + + KLogsTitle: "Logs", + KLogsSearch: "Search logs…", + KLogsLevel: "Level", + KLogsSource: "Source", + KLogsFollow: "Follow", + KLogsAll: "All", + KLogsEmpty: "No matching log entries", + KLogsCopyAll: "Copy to clipboard", + KLogsSaveFile: "Save to file", + KLogsUpload: "Upload and share", + KLogsUploading: "Uploading", + KLogsUploaded: "Uploaded, link copied", + KLogsUploadFail: "Upload failed", + KLogsRedact: "Redact secrets", + KLogsRedactHint: "Credentials such as authkeys are removed before upload", + KLogsShown: "shown", + KLogsDropped: "earlier entries were dropped", + KLogsIncludeDiag: "Include diagnostics", + KLogsOpenOverlay: "Log overlay", + + KSetTheme: "Theme", + KSetThemeDark: "Dark", + KSetThemeLight: "Light", + KSetLanguage: "Language", + KSetAbout: "About", + KSetConfigPath: "Config file", + KSetVersion: "Version", + KSetFont: "CJK font", + KSetFontMissing: "No CJK font found, the UI fell back to English", +} + +// Tr returns the localised string for k, falling back to English and then to a +// visible placeholder rather than an empty label. +func Tr(l Lang, k Key) string { + if k < 0 || k >= kCount { + return "?" + } + if l == LangZH { + if s := zhStrings[k]; s != "" { + return s + } + } + if s := enStrings[k]; s != "" { + return s + } + return "?" +} diff --git a/gui/icons.go b/gui/icons.go new file mode 100644 index 0000000..3e197c0 --- /dev/null +++ b/gui/icons.go @@ -0,0 +1,367 @@ +package gui + +import ( + "image" + "image/color" + "math" + + "gioui.org/f32" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" +) + +// IconFunc draws an icon of the given pixel size in col, occupying a square of +// that size. +// +// The icons are drawn as vector line art rather than pulled from an icon font +// or the shiny material set: a dozen hand-drawn paths keep the binary small, +// avoid a dependency, and let every glyph share one stroke weight so the +// toolbar reads as a set. +type IconFunc func(gtx C, size int, col color.NRGBA) D + +// defaultStroke is the icon stroke width as a fraction of the icon box. +const defaultStroke = 0.085 + +// iconCanvas sets up a unit coordinate space (0..1 in both axes) and strokes +// whatever the draw function puts on the path. +func iconCanvas(gtx C, size int, col color.NRGBA, width float32, draw func(p *clip.Path, pt func(x, y float32) f32.Point)) D { + if size <= 0 { + return D{} + } + s := float32(size) + pt := func(x, y float32) f32.Point { return f32.Pt(x*s, y*s) } + + var p clip.Path + p.Begin(gtx.Ops) + draw(&p, pt) + + w := width * s + if w < 1 { + w = 1 + } + paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: w}.Op()) + return D{Size: image.Pt(size, size)} +} + +// arcAt appends a circle (or arc) centred at (cx, cy) with radius r, in unit +// coordinates. +func arcAt(p *clip.Path, pt func(x, y float32) f32.Point, cx, cy, r, startAngle, sweep float32) { + start := pt( + cx+r*float32(math.Cos(float64(startAngle))), + cy+r*float32(math.Sin(float64(startAngle))), + ) + c := pt(cx, cy) + p.MoveTo(start) + d := c.Sub(start) + p.Arc(d, d, sweep) +} + +func poly(p *clip.Path, pt func(x, y float32) f32.Point, pts ...[2]float32) { + if len(pts) == 0 { + return + } + p.MoveTo(pt(pts[0][0], pts[0][1])) + for _, q := range pts[1:] { + p.LineTo(pt(q[0], q[1])) + } +} + +func line(p *clip.Path, pt func(x, y float32) f32.Point, x1, y1, x2, y2 float32) { + p.MoveTo(pt(x1, y1)) + p.LineTo(pt(x2, y2)) +} + +func rect(p *clip.Path, pt func(x, y float32) f32.Point, x, y, w, h float32) { + p.MoveTo(pt(x, y)) + p.LineTo(pt(x+w, y)) + p.LineTo(pt(x+w, y+h)) + p.LineTo(pt(x, y+h)) + p.Close() +} + +// dot paints a filled circle in unit coordinates, for icons that need a solid +// node rather than an outline. +func dot(gtx C, size int, col color.NRGBA, cx, cy, r float32) { + s := float32(size) + d := int(2 * r * s) + if d < 2 { + d = 2 + } + off := op.Offset(image.Pt(int(cx*s)-d/2, int(cy*s)-d/2)).Push(gtx.Ops) + Circle(gtx, d, col) + off.Pop() +} + +// --------------------------------------------------------------------------- +// Navigation icons +// --------------------------------------------------------------------------- + +// IconGrid is the overview page: four panes. +func IconGrid(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) { + rect(p, pt, 0.14, 0.14, 0.30, 0.30) + rect(p, pt, 0.56, 0.14, 0.30, 0.30) + rect(p, pt, 0.14, 0.56, 0.30, 0.30) + rect(p, pt, 0.56, 0.56, 0.30, 0.30) + }) +} + +// IconNodes is the peers page: three linked nodes. +func IconNodes(gtx C, size int, col color.NRGBA) D { + d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { + line(p, pt, 0.50, 0.24, 0.22, 0.72) + line(p, pt, 0.50, 0.24, 0.78, 0.72) + line(p, pt, 0.22, 0.72, 0.78, 0.72) + }) + dot(gtx, size, col, 0.50, 0.22, 0.13) + dot(gtx, size, col, 0.21, 0.75, 0.13) + dot(gtx, size, col, 0.79, 0.75, 0.13) + return d +} + +// IconBroadcast is the LAN page: a source radiating outwards. +func IconBroadcast(gtx C, size int, col color.NRGBA) D { + d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { + const q = math.Pi / 4 + arcAt(p, pt, 0.5, 0.5, 0.22, -q, 2*q) + arcAt(p, pt, 0.5, 0.5, 0.40, -q, 2*q) + arcAt(p, pt, 0.5, 0.5, 0.22, float32(math.Pi)-q, 2*q) + arcAt(p, pt, 0.5, 0.5, 0.40, float32(math.Pi)-q, 2*q) + }) + dot(gtx, size, col, 0.5, 0.5, 0.12) + return d +} + +// IconPulse is the diagnostics page: an activity trace. +func IconPulse(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) { + poly(p, pt, + [2]float32{0.08, 0.52}, + [2]float32{0.28, 0.52}, + [2]float32{0.40, 0.22}, + [2]float32{0.56, 0.80}, + [2]float32{0.68, 0.52}, + [2]float32{0.92, 0.52}, + ) + }) +} + +// IconList is the logs page. +func IconList(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.16, 0.28, 0.84, 0.28) + line(p, pt, 0.16, 0.50, 0.84, 0.50) + line(p, pt, 0.16, 0.72, 0.60, 0.72) + }) +} + +// IconSliders is the settings page. +func IconSliders(gtx C, size int, col color.NRGBA) D { + d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { + line(p, pt, 0.12, 0.30, 0.88, 0.30) + line(p, pt, 0.12, 0.70, 0.88, 0.70) + }) + dot(gtx, size, col, 0.34, 0.30, 0.13) + dot(gtx, size, col, 0.66, 0.70, 0.13) + return d +} + +// --------------------------------------------------------------------------- +// Action icons +// --------------------------------------------------------------------------- + +// IconCopy is the copy-to-clipboard action. +func IconCopy(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) { + rect(p, pt, 0.32, 0.32, 0.54, 0.54) + poly(p, pt, + [2]float32{0.68, 0.20}, + [2]float32{0.14, 0.20}, + [2]float32{0.14, 0.68}, + ) + }) +} + +// IconUpload is the share/upload action. +func IconUpload(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.5, 0.16, 0.5, 0.64) + poly(p, pt, + [2]float32{0.30, 0.36}, + [2]float32{0.50, 0.16}, + [2]float32{0.70, 0.36}, + ) + poly(p, pt, + [2]float32{0.16, 0.62}, + [2]float32{0.16, 0.86}, + [2]float32{0.84, 0.86}, + [2]float32{0.84, 0.62}, + ) + }) +} + +// IconSave is the write-to-disk action. +func IconSave(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.5, 0.14, 0.5, 0.62) + poly(p, pt, + [2]float32{0.30, 0.42}, + [2]float32{0.50, 0.62}, + [2]float32{0.70, 0.42}, + ) + poly(p, pt, + [2]float32{0.16, 0.62}, + [2]float32{0.16, 0.86}, + [2]float32{0.84, 0.86}, + [2]float32{0.84, 0.62}, + ) + }) +} + +// IconRefresh is the re-run action. +func IconRefresh(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) { + arcAt(p, pt, 0.5, 0.5, 0.32, -1.9, 4.9) + poly(p, pt, + [2]float32{0.60, 0.06}, + [2]float32{0.61, 0.30}, + [2]float32{0.38, 0.24}, + ) + }) +} + +// IconCheck marks a passed check. +func IconCheck(gtx C, size int, col color.NRGBA) D { + return iconCanvas(gtx, size, col, 0.11, func(p *clip.Path, pt func(x, y float32) f32.Point) { + poly(p, pt, + [2]float32{0.18, 0.52}, + [2]float32{0.42, 0.74}, + [2]float32{0.82, 0.28}, + ) + }) +} + +// IconWarn marks a warning. +func IconWarn(gtx C, size int, col color.NRGBA) D { + d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { + poly(p, pt, + [2]float32{0.50, 0.12}, + [2]float32{0.92, 0.84}, + [2]float32{0.08, 0.84}, + ) + p.Close() + line(p, pt, 0.5, 0.40, 0.5, 0.60) + }) + dot(gtx, size, col, 0.5, 0.72, 0.055) + 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) { + poly(p, pt, + [2]float32{0.40, 0.24}, + [2]float32{0.66, 0.50}, + [2]float32{0.40, 0.76}, + ) + }) +} + +// IconChevronDown indicates an expanded row. +func IconChevronDown(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) { + poly(p, pt, + [2]float32{0.24, 0.40}, + [2]float32{0.50, 0.66}, + [2]float32{0.76, 0.40}, + ) + }) +} + +// IconSearch prefixes the log filter field. +func IconSearch(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) { + arcAt(p, pt, 0.44, 0.44, 0.28, 0, 2*math.Pi) + line(p, pt, 0.64, 0.64, 0.86, 0.86) + }) +} + +// IconGlobe marks anything about the public internet. +func IconGlobe(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) { + arcAt(p, pt, 0.5, 0.5, 0.38, 0, 2*math.Pi) + line(p, pt, 0.12, 0.5, 0.88, 0.5) + // Two meridians, drawn as opposing quadratic bows. + p.MoveTo(pt(0.5, 0.12)) + p.QuadTo(pt(0.22, 0.5), pt(0.5, 0.88)) + p.MoveTo(pt(0.5, 0.12)) + p.QuadTo(pt(0.78, 0.5), pt(0.5, 0.88)) + }) +} + +// IconServer marks a discovered game server. +func IconServer(gtx C, size int, col color.NRGBA) D { + d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { + rect(p, pt, 0.14, 0.18, 0.72, 0.26) + rect(p, pt, 0.14, 0.56, 0.72, 0.26) + }) + dot(gtx, size, col, 0.26, 0.31, 0.05) + dot(gtx, size, col, 0.26, 0.69, 0.05) + return d +} + +// IconLink marks a peer referenced by a config rule. +func IconLink(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) { + arcAt(p, pt, 0.34, 0.66, 0.22, -2.36, 3.14) + arcAt(p, pt, 0.66, 0.34, 0.22, 0.78, 3.14) + line(p, pt, 0.38, 0.62, 0.62, 0.38) + }) +} + +// IconShield marks NAT and firewall findings. +func IconShield(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) { + p.MoveTo(pt(0.5, 0.10)) + p.LineTo(pt(0.84, 0.24)) + p.LineTo(pt(0.84, 0.52)) + p.QuadTo(pt(0.84, 0.80), pt(0.5, 0.92)) + p.QuadTo(pt(0.16, 0.80), pt(0.16, 0.52)) + p.LineTo(pt(0.16, 0.24)) + p.Close() + }) +} + +// IconRouter marks port-mapping results. +func IconRouter(gtx C, size int, col color.NRGBA) D { + d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { + rect(p, pt, 0.10, 0.54, 0.80, 0.30) + line(p, pt, 0.32, 0.54, 0.32, 0.34) + line(p, pt, 0.32, 0.34, 0.62, 0.20) + line(p, pt, 0.68, 0.54, 0.68, 0.30) + }) + dot(gtx, size, col, 0.24, 0.69, 0.05) + dot(gtx, size, col, 0.40, 0.69, 0.05) + return d +} + +// IconRoute marks the local-interface section. +func IconRoute(gtx C, size int, col color.NRGBA) D { + d := iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { + p.MoveTo(pt(0.22, 0.78)) + p.QuadTo(pt(0.22, 0.50), pt(0.50, 0.50)) + p.QuadTo(pt(0.78, 0.50), pt(0.78, 0.22)) + }) + dot(gtx, size, col, 0.22, 0.80, 0.11) + dot(gtx, size, col, 0.78, 0.20, 0.11) + return d +} diff --git a/gui/overlay.go b/gui/overlay.go new file mode 100644 index 0000000..c6d5009 --- /dev/null +++ b/gui/overlay.go @@ -0,0 +1,269 @@ +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() +} diff --git a/gui/page_diag.go b/gui/page_diag.go new file mode 100644 index 0000000..2c1c417 --- /dev/null +++ b/gui/page_diag.go @@ -0,0 +1,988 @@ +package gui + +import ( + "context" + "sort" + "strings" + "sync" + "time" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/text" + "gioui.org/widget" + "gioui.org/widget/material" + + "tslink/core" + "tslink/netdiag" +) + +type diagPage struct { + app *App + list widget.List + + runBtn widget.Clickable + copyBtn widget.Clickable + skipGeo widget.Bool + + // mu guards everything the background run writes. + mu sync.Mutex + running bool + report *netdiag.Report + progress map[string]netdiag.Progress + order []string + lastRun time.Time + runErr string + cancel context.CancelFunc +} + +func newDiagPage(a *App) *diagPage { + p := &diagPage{ + app: a, + progress: make(map[string]netdiag.Progress), + } + p.list.Axis = layout.Vertical + return p +} + +func diagLevel(s netdiag.Status) StatusLevel { + switch s { + case netdiag.StatusOK: + return LevelOK + case netdiag.StatusWarn: + return LevelWarn + case netdiag.StatusFail: + return LevelFail + default: + return LevelNeutral + } +} + +// reportText renders the last report for inclusion in a shared bundle. +func (p *diagPage) reportText() string { + p.mu.Lock() + defer p.mu.Unlock() + if p.report == nil { + return "" + } + return p.report.Text() +} + +// run starts a diagnostic sweep on a background goroutine. +func (p *diagPage) run() { + p.mu.Lock() + if p.running { + p.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(context.Background()) + p.running = true + p.cancel = cancel + p.progress = make(map[string]netdiag.Progress) + p.order = nil + p.runErr = "" + skipGeo := p.skipGeo.Value + p.mu.Unlock() + + a := p.app + st := a.state() + var src netdiag.TailscaleSource + if st.Server != nil { + src = core.DefaultTailscaleSource(st.Server, a.logger) + } + + go func() { + defer cancel() + rep := netdiag.Run(ctx, netdiag.Options{ + Logger: a.logger.With("from", "netdiag"), + Tailscale: src, + IPInfoToken: a.opt.IPInfoToken, + SkipGeo: skipGeo, + OnProgress: func(pr netdiag.Progress) { + p.mu.Lock() + if _, seen := p.progress[pr.Key]; !seen { + p.order = append(p.order, pr.Key) + } + p.progress[pr.Key] = pr + p.mu.Unlock() + if a.win != nil { + a.win.Invalidate() + } + }, + }) + p.mu.Lock() + p.report = rep + p.running = false + p.lastRun = time.Now() + p.cancel = nil + p.mu.Unlock() + if a.win != nil { + a.win.Invalidate() + } + }() +} + +func (p *diagPage) Layout(a *App, gtx C, st core.State) D { + th := a.th + + if p.runBtn.Clicked(gtx) { + p.run() + } + if p.copyBtn.Clicked(gtx) { + if txt := p.reportText(); txt != "" { + a.copyToClipboard(gtx, txt, th.T(KCopied)) + } + } + + p.mu.Lock() + running := p.running + report := p.report + lastRun := p.lastRun + progress := make([]netdiag.Progress, 0, len(p.order)) + for _, k := range p.order { + progress = append(progress, p.progress[k]) + } + p.mu.Unlock() + + items := []layout.Widget{ + func(gtx C) D { return p.controlCard(a, gtx, running, report, lastRun, progress) }, + } + if report != nil { + items = append(items, + func(gtx C) D { return p.natCard(a, gtx, report.NAT) }, + func(gtx C) D { return p.udpCard(a, gtx, report.UDP) }, + func(gtx C) D { return p.portMapCard(a, gtx, report.PortMap) }, + func(gtx C) D { return p.overseasCard(a, gtx, report.Overseas) }, + func(gtx C) D { return p.egressCard(a, gtx, report.Egress) }, + func(gtx C) D { return p.ifaceCard(a, gtx, report.Interfaces) }, + func(gtx C) D { return p.tailscaleCard(a, gtx, report.Tailscale) }, + ) + } + + 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]) + }) +} + +// controlCard is the page's anchor: what the verdict is, when it was measured, +// and how to measure again. +func (p *diagPage) controlCard(a *App, gtx C, running bool, rep *netdiag.Report, lastRun time.Time, progress []netdiag.Progress) D { + th := a.th + card := th.Card() + if rep != nil { + accent := th.StatusColor(diagLevel(rep.Status)) + card.Accent = &accent + } + + return card.Layout(th, gtx, 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.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + headline := th.T(KDiagNever) + col := th.P.TextSec + if running { + headline = th.T(KDiagRunning) + "…" + col = th.P.TextPri + } else if rep != nil { + headline = rep.Headline + col = th.StatusColor(diagLevel(rep.Status)) + } + l := th.Text(SizeSubtitle, col, headline) + l.Font.Weight = font.SemiBold + l.MaxLines = 3 + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if lastRun.IsZero() { + return D{} + } + txt := th.T(KDiagLastRun) + " " + RelTime(th, lastRun, time.Now()) + if rep != nil { + txt += " · " + FormatLatency(rep.Duration) + } + return layout.Inset{Top: 2}.Layout(gtx, th.Caption(txt).Layout) + }), + ) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + if rep == nil { + return D{} + } + return th.Button(gtx, &p.copyBtn, ButtonStyle{ + Kind: ButtonSubtle, + Text: th.T(KDiagCopyReport), + Icon: IconCopy, + }) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + label := th.T(KDiagRun) + if rep != nil { + label = th.T(KDiagRerun) + } + if running { + label = th.T(KDiagRunning) + } + return th.Button(gtx, &p.runBtn, ButtonStyle{ + Kind: ButtonPrimary, + Text: label, + Icon: IconRefresh, + Disabled: running, + }) + }), + ) + }), + layout.Rigid(func(gtx C) D { + if !running && len(progress) == 0 { + return D{} + } + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + return p.progressList(a, gtx, progress, running) + }) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return th.Toggle(gtx, &p.skipGeo, th.T(KDiagSkipGeo)) + }), + HGap(SpaceMD), + layout.Flexed(1, func(gtx C) D { + return OneLine(th.Caption(th.T(KDiagSkipGeoHint))).Layout(gtx) + }), + ) + }) + }), + ) + }) +} + +func (p *diagPage) progressList(a *App, gtx C, progress []netdiag.Progress, running bool) D { + th := a.th + children := make([]layout.FlexChild, 0, len(progress)) + for _, pr := range progress { + children = append(children, layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 3, Bottom: 3}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(18) + switch { + case pr.Err != "": + return IconWarn(gtx, gtx.Dp(13), th.P.Warn) + case pr.Done: + return IconCheck(gtx, gtx.Dp(13), th.P.OK) + default: + return th.Spinner(gtx, gtx.Dp(13), th.P.Accent) + } + }), + HGap(SpaceSM), + layout.Flexed(1, func(gtx C) D { + col := th.P.TextSec + if !pr.Done { + col = th.P.TextPri + } + return OneLine(th.Text(SizeCaption, col, pr.Title)).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if !pr.Done || pr.Elapsed <= 0 { + return D{} + } + return th.MonoLabel(SizeCaption, th.P.TextDim, + FormatLatency(pr.Elapsed)).Layout(gtx) + }), + ) + }) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + +// --------------------------------------------------------------------------- +// Section cards +// --------------------------------------------------------------------------- + +// sectionCard is the shared shell for a diagnostic section: title, verdict +// chip, and body. +func (p *diagPage) sectionCard(a *App, gtx C, icon IconFunc, title string, s netdiag.Status, summary string, body layout.Widget) D { + th := a.th + level := diagLevel(s) + card := th.Card() + card.Title = title + card.Subtitle = summary + card.Trailing = func(gtx C) D { + return th.Chip(gtx, ChipStyle{Text: statusWord(th, s), Level: level, Dot: true}) + } + return card.Layout(th, gtx, body) +} + +func statusWord(th *Theme, s netdiag.Status) string { + switch s { + case netdiag.StatusOK: + if th.Lang == LangZH { + return "正常" + } + return "OK" + case netdiag.StatusWarn: + if th.Lang == LangZH { + return "注意" + } + return "Warning" + case netdiag.StatusFail: + if th.Lang == LangZH { + return "异常" + } + return "Failed" + case netdiag.StatusSkipped: + if th.Lang == LangZH { + return "已跳过" + } + return "Skipped" + default: + return th.T(KUnknown) + } +} + +func natTypeLabel(th *Theme, t netdiag.NATType) string { + switch t { + case netdiag.NATOpen: + return th.T(KNatOpen) + case netdiag.NATFullCone: + return th.T(KNatFullCone) + case netdiag.NATRestricted: + return th.T(KNatRestricted) + case netdiag.NATPortRestrict: + return th.T(KNatPortRestricted) + case netdiag.NATSymmetric: + return th.T(KNatSymmetric) + case netdiag.NATUDPBlocked: + return th.T(KNatUDPBlocked) + case netdiag.NATSymmetricFW: + return th.T(KNatSymmetricFW) + default: + return th.T(KNatUnknown) + } +} + +func behaviorLabel(th *Theme, b netdiag.Behavior) string { + if th.Lang != LangZH { + return b.String() + } + switch b { + case netdiag.BehaviorEndpointIndependent: + return "与目标无关" + case netdiag.BehaviorAddressDependent: + return "随目标地址变化" + case netdiag.BehaviorAddressAndPortDependent: + return "随目标地址和端口变化" + default: + return th.T(KUnknown) + } +} + +func (p *diagPage) triLabel(th *Theme, v *bool) (string, StatusLevel) { + if v == nil { + return th.T(KUnknown), LevelNeutral + } + if *v { + return th.T(KYes), LevelOK + } + return th.T(KNo), LevelWarn +} + +func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D { + th := a.th + hairpin, hairpinLvl := p.triLabel(th, r.Hairpin) + preserve, preserveLvl := p.triLabel(th, r.PortPreserving) + + return p.sectionCard(a, gtx, IconShield, th.T(KDiagSecNAT), r.Status, r.Summary, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + // The NAT type is the headline fact of this whole page; give it the + // display size so it wins the visual hierarchy against the table. + layout.Rigid(func(gtx C) D { + return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D { + l := th.Display(natTypeLabel(th, r.Type)) + l.Color = th.StatusColor(diagLevel(r.Status)) + return l.Layout(gtx) + }) + }), + 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}, + }) + }), + layout.Rigid(func(gtx C) D { + if len(r.MappedAddrs) == 0 { + return D{} + } + addrs := make([]string, 0, len(r.MappedAddrs)) + for _, ap := range r.MappedAddrs { + addrs = append(addrs, ap.String()) + } + return th.KV(gtx, KV{ + Key: th.T(KDiagEgressIP), + Value: strings.Join(addrs, " "), + Mono: true, + Level: mappedAddrLevel(len(r.MappedAddrs)), + }) + }), + layout.Rigid(func(gtx C) D { + if len(r.Notes) == 0 { + return D{} + } + return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D { + children := make([]layout.FlexChild, 0, len(r.Notes)) + for _, n := range r.Notes { + children = append(children, layout.Rigid(func(gtx C) D { + l := th.Caption("· " + n) + l.MaxLines = 3 + return l.Layout(gtx) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }), + layout.Rigid(func(gtx C) D { + if len(r.Results) == 0 { + return D{} + } + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + return p.stunTable(a, gtx, r.Results) + }) + }), + ) + }) +} + +func mappedAddrLevel(n int) StatusLevel { + if n > 1 { + return LevelWarn + } + return LevelNeutral +} + +func (p *diagPage) stunTable(a *App, gtx C, results []netdiag.STUNResult) D { + th := a.th + children := make([]layout.FlexChild, 0, len(results)+1) + children = append(children, layout.Rigid(func(gtx C) D { + return p.tableHeader(a, gtx, "STUN", th.T(KDiagEgressIP), "RTT") + })) + for _, r := range results { + children = append(children, layout.Rigid(func(gtx C) D { + val, level := r.Mapped.String(), LevelOK + if !r.OK { + val, level = orDash(r.Err), LevelFail + } + rtt := "" + if r.OK { + rtt = FormatLatency(r.RTT) + } + name := r.Server + if r.Name != "" { + name = r.Name + " " + r.Server + } + return p.tableRow(a, gtx, regionTag(th, r.Region)+name, val, rtt, level) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + +// 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 { + if r == netdiag.RegionCN { + if th.Lang == LangZH { + return "[国内] " + } + return "[CN] " + } + if th.Lang == LangZH { + return "[境外] " + } + return "[INTL] " +} + +func (p *diagPage) tableHeader(a *App, gtx C, cols ...string) D { + th := a.th + return layout.Inset{Bottom: SpaceXS}.Layout(gtx, func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Flexed(0.44, func(gtx C) D { + return OneLine(th.Caption(cols[0])).Layout(gtx) + }), + layout.Flexed(0.40, func(gtx C) D { + return OneLine(th.Caption(cols[1])).Layout(gtx) + }), + layout.Flexed(0.16, func(gtx C) D { + l := th.Caption(cols[2]) + l.Alignment = text.End + return OneLine(l).Layout(gtx) + }), + ) + }) +} + +func (p *diagPage) tableRow(a *App, gtx C, left, mid, right string, level StatusLevel) D { + th := a.th + col := th.P.TextPri + if level != LevelNeutral { + col = th.StatusColor(level) + } + return layout.Inset{Top: 3, Bottom: 3}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(0.44, func(gtx C) D { + return OneLine(th.Text(SizeCaption, th.P.TextSec, left)).Layout(gtx) + }), + layout.Flexed(0.40, func(gtx C) D { + return OneLine(th.MonoLabel(SizeCaption, col, mid)).Layout(gtx) + }), + layout.Flexed(0.16, func(gtx C) D { + l := th.MonoLabel(SizeCaption, th.P.TextDim, right) + l.Alignment = text.End + return OneLine(l).Layout(gtx) + }), + ) + }) +} + +func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D { + th := a.th + v4, v4lvl := boolLabel(th, r.V4OK) + v6, v6lvl := boolLabel(th, r.V6OK) + // No IPv6 is normal on most Chinese home networks; flagging it red would + // train the user to ignore the colour. + if !r.V6OK { + v6lvl = LevelNeutral + } + + 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: "国内 / 境外", + Value: itoa(r.CNReachable) + "/" + itoa(r.CNTotal) + " " + + itoa(r.IntlReachabl) + "/" + itoa(r.IntlTotal), + Mono: true, + }, + } + if th.Lang != LangZH { + rows[2].Key = "CN / International" + } + if len(r.BlockedPorts) > 0 { + rows = append(rows, KV{ + Key: th.T(KDiagUdpPortsBlocked), + Value: joinInts(r.BlockedPorts), + Mono: true, + Level: LevelWarn, + }) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return th.KVList(gtx, rows) }), + layout.Rigid(func(gtx C) D { + if len(r.Probes) == 0 { + return D{} + } + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + children := make([]layout.FlexChild, 0, len(r.Probes)+1) + children = append(children, layout.Rigid(func(gtx C) D { + return p.tableHeader(a, gtx, th.T(KDiagOverseasTarget), th.T(KDiagEgressIP), "RTT") + })) + for _, pr := range r.Probes { + children = append(children, layout.Rigid(func(gtx C) D { + val, level := pr.Mapped.String(), LevelOK + rtt := FormatLatency(pr.RTT) + 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 layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }), + ) + }) +} + +func boolLabel(th *Theme, v bool) (string, StatusLevel) { + if v { + return th.T(KYes), LevelOK + } + return th.T(KNo), LevelFail +} + +func joinInts(v []int) string { + parts := make([]string, len(v)) + for i, n := range v { + parts[i] = itoa(n) + } + return strings.Join(parts, ", ") +} + +func (p *diagPage) portMapCard(a *App, gtx C, r netdiag.PortMapReport) D { + th := a.th + return p.sectionCard(a, gtx, IconRouter, th.T(KDiagSecPortMap), r.Status, r.Summary, func(gtx C) D { + rows := []KV{} + if r.Gateway.IsValid() { + rows = append(rows, KV{Key: th.T(KDiagGateway), Value: r.Gateway.String(), Mono: true}) + } + rows = append(rows, + serviceKV(th, th.T(KDiagUPnP), r.UPnP), + serviceKV(th, th.T(KDiagNATPMP), r.NATPMP), + serviceKV(th, th.T(KDiagPCP), r.PCP), + ) + for _, s := range []netdiag.ServiceProbe{r.UPnP, r.NATPMP, r.PCP} { + if s.ExternalIP.IsValid() { + rows = append(rows, KV{ + Key: th.T(KDiagExternalIP), + Value: s.ExternalIP.String(), + Mono: true, + }) + break + } + } + return th.KVList(gtx, rows) + }) +} + +func serviceKV(th *Theme, name string, s netdiag.ServiceProbe) KV { + val, level := th.T(KUnsupported), LevelWarn + if s.Available { + val, level = th.T(KSupported), LevelOK + } + hint := s.Detail + if hint == "" { + hint = s.Err + } + return KV{Key: name, Value: val, Level: level, Hint: Truncate(hint, 60)} +} + +func (p *diagPage) overseasCard(a *App, gtx C, r netdiag.OverseasReport) D { + th := a.th + return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecOverseas), r.Status, r.Summary, func(gtx C) D { + if len(r.Probes) == 0 { + return th.EmptyState(gtx, IconGlobe, th.T(KUnknown), "") + } + children := make([]layout.FlexChild, 0, len(r.Probes)+1) + children = append(children, layout.Rigid(func(gtx C) D { + return p.tableHeader(a, gtx, th.T(KDiagOverseasTarget), th.T(KDetails), "RTT") + })) + for _, pr := range r.Probes { + children = append(children, layout.Rigid(func(gtx C) D { + detail := itoa(pr.StatusCode) + level := LevelOK + if !pr.OK { + detail, level = orDash(Truncate(pr.Err, 48)), LevelFail + } + via := "direct" + if pr.ViaProxy { + via = "proxy" + } + name := regionTag(th, pr.Region) + pr.URL + " (" + via + if pr.Network != "" { + name += "/" + pr.Network + } + name += ")" + return p.tableRow(a, gtx, name, detail, FormatLatency(pr.RTT), level) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +func (p *diagPage) egressCard(a *App, gtx C, r netdiag.EgressReport) D { + th := a.th + return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecEgress), r.Status, r.Summary, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if !r.Divergent { + return D{} + } + return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D { + return p.callout(a, gtx, LevelWarn, + th.T(KDiagEgressDivergent), th.T(KDiagEgressDivergentHint)) + }) + }), + // Geolocation first: "where do I appear to be" is the question, the + // per-probe table below is the evidence. + layout.Rigid(func(gtx C) D { + if len(r.Geo) == 0 { + return D{} + } + children := make([]layout.FlexChild, 0, len(r.Geo)) + for _, g := range r.Geo { + children = append(children, layout.Rigid(func(gtx C) D { + return p.geoRow(a, gtx, g) + })) + } + return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }), + layout.Rigid(func(gtx C) D { + if len(r.Observations) == 0 { + return th.EmptyState(gtx, IconGlobe, th.T(KUnknown), "") + } + children := make([]layout.FlexChild, 0, len(r.Observations)+1) + children = append(children, layout.Rigid(func(gtx C) D { + return p.tableHeader(a, gtx, th.T(KDiagEgressMethod), th.T(KDiagEgressIP), "RTT") + })) + for _, o := range r.Observations { + children = append(children, layout.Rigid(func(gtx C) D { + val, level := o.IP.String(), LevelOK + rtt := FormatLatency(o.RTT) + if !o.IP.IsValid() { + val, level, rtt = orDash(Truncate(o.Err, 44)), LevelFail, "" + } + label := regionTag(th, o.Region) + string(o.Method) + " · " + o.Source + return p.tableRow(a, gtx, label, val, rtt, level) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }), + ) + }) +} + +func (p *diagPage) geoRow(a *App, gtx C, g netdiag.GeoInfo) D { + th := a.th + loc := []string{} + for _, s := range []string{g.CountryName, g.Country, g.Region, g.City} { + if s != "" && !containsStr(loc, s) { + loc = append(loc, s) + } + } + locText := strings.Join(loc, " · ") + if locText == "" { + locText = orDash(g.Err) + } + org := strings.TrimSpace(g.ASN + " " + g.Org) + + return layout.Inset{Top: 4, Bottom: 4}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return IconGlobe(gtx, gtx.Dp(15), th.P.Info) + }), + HGap(SpaceSM), + 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.MonoLabel(SizeBody, th.P.TextPri, g.IP.String())).Layout), + HGap(SpaceSM), + layout.Flexed(1, OneLine(th.Text(SizeBody, th.P.TextSec, locText)).Layout), + ) + }), + layout.Rigid(func(gtx C) D { + if org == "" { + return D{} + } + hint := org + if g.Provider != "" { + hint += " · " + g.Provider + } + return OneLine(th.Caption(hint)).Layout(gtx) + }), + ) + }), + ) + }) +} + +func containsStr(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } + } + return false +} + +func (p *diagPage) ifaceCard(a *App, gtx C, r netdiag.InterfaceReport) D { + th := a.th + return p.sectionCard(a, gtx, IconRoute, th.T(KDiagSecIface), r.Status, r.Summary, func(gtx C) D { + rows := []KV{} + if r.DefaultV4Src.IsValid() { + rows = append(rows, KV{Key: th.T(KDiagIfaceDefaultV4), Value: r.DefaultV4Src.String(), Mono: true}) + } + if r.DefaultV6Src.IsValid() { + rows = append(rows, KV{Key: th.T(KDiagIfaceDefaultV6), Value: r.DefaultV6Src.String(), Mono: true}) + } else { + rows = append(rows, KV{Key: th.T(KDiagIfaceDefaultV6), Value: th.T(KNone), Level: LevelNeutral}) + } + + children := []layout.FlexChild{ + layout.Rigid(func(gtx C) D { return th.KVList(gtx, rows) }), + } + if len(r.Addrs) > 0 { + children = append(children, layout.Rigid(func(gtx C) D { + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + sub := make([]layout.FlexChild, 0, len(r.Addrs)+1) + sub = append(sub, layout.Rigid(func(gtx C) D { + return p.tableHeader(a, gtx, th.T(KPeerAddresses), "", "MTU") + })) + for _, ad := range r.Addrs { + sub = append(sub, layout.Rigid(func(gtx C) D { + name := ad.Iface + if ad.IsDefaultSrc { + name += " *" + } + mtu := "" + if ad.MTU > 0 { + mtu = itoa(ad.MTU) + } + return p.tableRow(a, gtx, + name+" "+string(ad.Kind), ad.Addr.String(), mtu, + addrKindLevel(ad.Kind)) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, sub...) + }) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +func addrKindLevel(k netdiag.AddrKind) StatusLevel { + switch k { + case netdiag.AddrGlobalV4, netdiag.AddrGlobalV6: + return LevelOK + case netdiag.AddrTailscale: + return LevelInfo + case netdiag.AddrLoopback, netdiag.AddrLinkLocal: + return LevelNeutral + default: + return LevelNeutral + } +} + +func (p *diagPage) tailscaleCard(a *App, gtx C, r netdiag.TailscaleReport) D { + th := a.th + return p.sectionCard(a, gtx, IconNodes, th.T(KDiagSecTailscale), r.Status, r.Summary, func(gtx C) D { + if !r.Available { + return th.EmptyState(gtx, IconNodes, orDash(r.Err), "") + } + upnp, upnpLvl := p.triLabel(th, r.UPnP) + pmp, pmpLvl := p.triLabel(th, r.PMP) + pcp, pcpLvl := p.triLabel(th, r.PCP) + varies, variesLvl := p.triLabel(th, r.MappingVariesByDestIP) + if r.MappingVariesByDestIP != nil && *r.MappingVariesByDestIP { + variesLvl = LevelWarn + } else if r.MappingVariesByDestIP != nil { + variesLvl = LevelOK + } + portal, portalLvl := p.triLabel(th, r.CaptivePortal) + if r.CaptivePortal != nil && *r.CaptivePortal { + portalLvl = LevelFail + } else if r.CaptivePortal != nil { + portalLvl = LevelOK + } + + rows := []KV{ + {Key: th.T(KDiagPreferredDERP), Value: orDash(r.PreferredDERP)}, + {Key: th.T(KDiagMappingVaries), Value: varies, Level: variesLvl}, + {Key: th.T(KDiagCaptivePortal), Value: portal, Level: portalLvl}, + {Key: th.T(KDiagUPnP) + " / " + th.T(KDiagNATPMP) + " / " + th.T(KDiagPCP), + Value: upnp + " · " + pmp + " · " + pcp, + Level: worstLevel(upnpLvl, pmpLvl, pcpLvl)}, + } + if r.GlobalV4 != "" { + rows = append(rows, KV{Key: "GlobalV4", Value: r.GlobalV4, Mono: true}) + } + if r.GlobalV6 != "" { + rows = append(rows, KV{Key: "GlobalV6", Value: r.GlobalV6, Mono: true}) + } + + derp := append([]netdiag.DERPLatency(nil), r.DERP...) + sort.Slice(derp, func(i, j int) bool { return derp[i].Latency < derp[j].Latency }) + if len(derp) > 6 { + derp = derp[:6] + } + + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return th.KVList(gtx, rows) }), + layout.Rigid(func(gtx C) D { + if len(derp) == 0 { + return D{} + } + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + children := make([]layout.FlexChild, 0, len(derp)+1) + children = append(children, layout.Rigid(func(gtx C) D { + return p.tableHeader(a, gtx, th.T(KDiagDerpLatency), "", "RTT") + })) + for _, d := range derp { + children = append(children, layout.Rigid(func(gtx C) D { + name := d.Name + level := LevelNeutral + if d.Preferred { + name += " ★" + level = LevelOK + } + return p.tableRow(a, gtx, name, d.RegionCode, + FormatLatency(d.Latency), level) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }), + ) + }) +} + +func worstLevel(ls ...StatusLevel) StatusLevel { + rank := map[StatusLevel]int{LevelOK: 0, LevelNeutral: 1, LevelInfo: 1, LevelWarn: 2, LevelFail: 3} + worst := LevelOK + for _, l := range ls { + if rank[l] > rank[worst] { + worst = l + } + } + return worst +} + +// callout is an inline banner for a finding that needs a sentence of +// explanation rather than a table cell. +func (p *diagPage) callout(a *App, gtx C, level StatusLevel, title, body string) D { + th := a.th + col := th.StatusColor(level) + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + FillRRect(gtx, gtx.Constraints.Min, RadiusSM, WithAlpha(col, 0.10)) + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.UniformInset(SpaceMD).Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Start}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: 2}.Layout(gtx, func(gtx C) D { + return IconWarn(gtx, gtx.Dp(15), col) + }) + }), + HGap(SpaceSM), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + l := th.Text(SizeBody, col, title) + l.Font.Weight = font.Medium + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + l := th.Text(SizeCaption, th.P.TextSec, body) + l.MaxLines = 3 + return l.Layout(gtx) + }), + ) + }), + ) + }) + }), + ) +} diff --git a/gui/page_lan.go b/gui/page_lan.go new file mode 100644 index 0000000..1003dd0 --- /dev/null +++ b/gui/page_lan.go @@ -0,0 +1,190 @@ +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) + }), + ) + }) +} diff --git a/gui/page_logs.go b/gui/page_logs.go new file mode 100644 index 0000000..04e6dc2 --- /dev/null +++ b/gui/page_logs.go @@ -0,0 +1,496 @@ +package gui + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/text" + "gioui.org/widget" + "gioui.org/widget/material" + + "tslink/core" + "tslink/netdiag" +) + +type logsPage struct { + app *App + list widget.List + + search widget.Editor + level widget.Enum + source widget.Enum + follow widget.Bool + redact widget.Bool + + copyBtn widget.Clickable + saveBtn widget.Clickable + uploadBtn widget.Clickable + urlCopyBtn widget.Clickable + + // mu guards the upload/save result fields, written from a goroutine. + mu sync.Mutex + uploading bool + uploadURL string + uploadTarget string + uploadErr string + savedPath string + + // Cached filter result. Re-running the query over the whole ring on every + // frame is wasted work: it can only change when a record is appended or + // the query itself changes. + cached []core.LogEntry + cachedSeq uint64 + cachedLen int + cachedQ core.LogQuery +} + +// entries returns the filtered records, recomputing only when the buffer or +// the query moved. +func (p *logsPage) entries(buf *core.LogBuffer) []core.LogEntry { + q := p.query() + seq, n := buf.LastSeq(), buf.Len() + if p.cached != nil && seq == p.cachedSeq && n == p.cachedLen && q == p.cachedQ { + return p.cached + } + p.cached = buf.Filter(q) + p.cachedSeq, p.cachedLen, p.cachedQ = seq, n, q + return p.cached +} + +func newLogsPage(a *App) *logsPage { + p := &logsPage{app: a} + p.list.Axis = layout.Vertical + p.search.SingleLine = true + p.level.Value = "all" + p.source.Value = "all" + p.follow.Value = true + p.redact.Value = true + return p +} + +func (p *logsPage) minLevel() slog.Level { + switch p.level.Value { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelDebug - 4 // below everything + } +} + +func (p *logsPage) query() core.LogQuery { + q := core.LogQuery{ + MinLevel: p.minLevel(), + Text: strings.TrimSpace(p.search.Text()), + } + if p.source.Value != "all" { + q.Source = p.source.Value + } + return q +} + +func (p *logsPage) Layout(a *App, gtx C, st core.State) D { + th := a.th + if a.opt.Logs == nil { + return th.EmptyState(gtx, IconList, th.T(KLogsEmpty), "") + } + buf := a.opt.Logs + + p.handleActions(a, gtx, buf) + p.list.ScrollToEnd = p.follow.Value + + entries := p.entries(buf) + + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D { + return p.toolbar(a, gtx, buf, len(entries)) + }) + }), + layout.Flexed(1, func(gtx C) D { + return p.logList(a, gtx, entries) + }), + ) +} + +func (p *logsPage) handleActions(a *App, gtx C, buf *core.LogBuffer) { + th := a.th + export := func() string { + return buf.ExportText(core.ExportOptions{ + Query: p.query(), + NoRedact: !p.redact.Value, + Header: a.diagnosticHeader(), + }) + } + + if p.copyBtn.Clicked(gtx) { + a.copyToClipboard(gtx, export(), th.T(KCopied)) + } + if p.saveBtn.Clicked(gtx) { + path, err := saveLogFile(export()) + p.mu.Lock() + if err != nil { + p.savedPath = "" + p.uploadErr = err.Error() + } else { + p.savedPath = path + p.uploadErr = "" + } + p.mu.Unlock() + if err != nil { + a.notify(th.T(KError)+": "+err.Error(), LevelFail) + } else { + a.notify(path, LevelOK) + } + } + if p.uploadBtn.Clicked(gtx) { + p.startUpload(a, export()) + } + if p.urlCopyBtn.Clicked(gtx) { + p.mu.Lock() + url := p.uploadURL + p.mu.Unlock() + if url != "" { + a.copyToClipboard(gtx, url, th.T(KCopied)) + } + } +} + +// startUpload publishes the bundle to a public paste service. +// +// This sends the user's logs off the machine, so the redaction toggle is on by +// default and the button label says "upload and share" rather than something +// vaguer: nobody should be surprised about what just left their computer. +func (p *logsPage) startUpload(a *App, text string) { + p.mu.Lock() + if p.uploading { + p.mu.Unlock() + return + } + p.uploading = true + p.uploadURL = "" + p.uploadErr = "" + p.mu.Unlock() + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + res, err := netdiag.Upload(ctx, "", text, a.logger.With("from", "paste")) + + p.mu.Lock() + p.uploading = false + if err != nil { + p.uploadErr = err.Error() + } else { + p.uploadURL = res.URL + p.uploadTarget = res.Target + } + p.mu.Unlock() + + if err != nil { + a.notify(a.th.T(KLogsUploadFail)+": "+Truncate(err.Error(), 80), LevelFail) + } else { + a.notify(a.th.T(KLogsUploaded)+" "+res.URL, LevelOK) + } + if a.win != nil { + a.win.Invalidate() + } + }() +} + +// saveLogFile writes the bundle next to the user's home directory. There is no +// native file picker without pulling in another dependency, so the app picks a +// predictable path and reports it rather than silently doing nothing. +func saveLogFile(content string) (string, error) { + dir, err := os.UserHomeDir() + if err != nil || dir == "" { + dir = "." + } + name := "tslink-log-" + time.Now().Format("20060102-150405") + ".txt" + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + return "", err + } + return path, nil +} + +func (p *logsPage) toolbar(a *App, gtx C, buf *core.LogBuffer, shown int) D { + th := a.th + counts := buf.Counts() + total := buf.Len() + + card := th.Card() + card.Pad = SpaceMD + return card.Layout(th, gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + // Row 1: search + level filter. + layout.Rigid(func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return p.searchField(a, gtx) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + return th.Segmented(gtx, &p.level, []SegmentOption{ + {Key: "all", Label: th.T(KLogsAll), Count: total}, + {Key: "debug", Label: "DBG", Count: counts[slog.LevelDebug]}, + {Key: "info", Label: "INF", Count: counts[slog.LevelInfo]}, + {Key: "warn", Label: "WRN", Count: counts[slog.LevelWarn], Level: LevelWarn}, + {Key: "error", Label: "ERR", Count: counts[slog.LevelError], Level: LevelFail}, + }) + }), + ) + }), + // Row 2: source filter. + layout.Rigid(func(gtx C) D { + sources := buf.Sources() + if len(sources) == 0 { + return D{} + } + if len(sources) > 6 { + sources = sources[:6] + } + opts := make([]SegmentOption, 0, len(sources)+1) + opts = append(opts, SegmentOption{Key: "all", Label: th.T(KLogsAll), Count: -1}) + for _, s := range sources { + opts = append(opts, SegmentOption{Key: s, Label: s, Count: -1}) + } + return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D { + return th.Segmented(gtx, &p.source, opts) + }) + }), + // Row 3: toggles + actions. + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return th.Toggle(gtx, &p.follow, th.T(KLogsFollow)) + }), + HGap(SpaceLG), + layout.Rigid(func(gtx C) D { + return th.Toggle(gtx, &p.redact, th.T(KLogsRedact)) + }), + layout.Flexed(1, func(gtx C) D { + return layout.E.Layout(gtx, func(gtx C) D { + return p.actions(a, gtx) + }) + }), + ) + }) + }), + // Row 4: counts + upload result. + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D { + return p.statusLine(a, gtx, buf, shown, total) + }) + }), + ) + }) +} + +func (p *logsPage) searchField(a *App, gtx C) D { + th := a.th + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + FillRRect(gtx, gtx.Constraints.Min, RadiusSM, th.P.BgElevated) + StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, th.P.Border) + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Top: 7, Bottom: 7, Left: SpaceMD, Right: SpaceMD, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return IconSearch(gtx, gtx.Dp(14), th.P.TextDim) + }), + HGap(SpaceSM), + layout.Flexed(1, func(gtx C) D { + ed := material.Editor(th.Theme, &p.search, th.T(KLogsSearch)) + ed.TextSize = SizeBody + ed.Color = th.P.TextPri + ed.HintColor = th.P.TextDim + return ed.Layout(gtx) + }), + ) + }) + }), + ) +} + +func (p *logsPage) actions(a *App, gtx C) D { + th := a.th + p.mu.Lock() + uploading := p.uploading + p.mu.Unlock() + + uploadLabel := th.T(KLogsUpload) + if uploading { + uploadLabel = th.T(KLogsUploading) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return th.Button(gtx, &p.copyBtn, ButtonStyle{ + Kind: ButtonGhost, Text: th.T(KLogsCopyAll), Icon: IconCopy, + }) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + return th.Button(gtx, &p.saveBtn, ButtonStyle{ + Kind: ButtonGhost, Text: th.T(KLogsSaveFile), Icon: IconSave, + }) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + return th.Button(gtx, &p.uploadBtn, ButtonStyle{ + Kind: ButtonSubtle, + Text: uploadLabel, + Icon: IconUpload, + Disabled: uploading, + }) + }), + ) +} + +func (p *logsPage) statusLine(a *App, gtx C, buf *core.LogBuffer, shown, total int) D { + th := a.th + p.mu.Lock() + url, target, upErr, saved := p.uploadURL, p.uploadTarget, p.uploadErr, p.savedPath + p.mu.Unlock() + + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + txt := th.T(KLogsShown) + " " + itoa(shown) + " / " + itoa(total) + if d := buf.Dropped(); d > 0 { + txt += " · " + itoa(int(d)) + " " + th.T(KLogsDropped) + } + return th.Caption(txt).Layout(gtx) + }), + layout.Flexed(1, func(gtx C) D { + return layout.E.Layout(gtx, func(gtx C) D { + switch { + case url != "": + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return OneLine(th.MonoLabel(SizeCaption, th.P.OK, url)).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if target == "" { + return D{} + } + return layout.Inset{Left: 6}.Layout(gtx, + th.Caption("("+target+")").Layout) + }), + layout.Rigid(func(gtx C) D { + return th.IconButton(gtx, &p.urlCopyBtn, IconCopy, LevelOK) + }), + ) + case upErr != "": + return OneLine(th.Text(SizeCaption, th.P.Fail, Truncate(upErr, 90))).Layout(gtx) + case saved != "": + return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, saved)).Layout(gtx) + default: + return OneLine(th.Caption(th.T(KLogsRedactHint))).Layout(gtx) + } + }) + }), + ) +} + +func (p *logsPage) logList(a *App, gtx C, entries []core.LogEntry) D { + th := a.th + card := th.Card() + card.Pad = SpaceSM + return card.Layout(th, gtx, func(gtx C) D { + if len(entries) == 0 { + return th.EmptyState(gtx, IconSearch, th.T(KLogsEmpty), "") + } + gtx.Constraints.Min.Y = gtx.Constraints.Max.Y + defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() + return material.List(th.Theme, &p.list).Layout(gtx, len(entries), func(gtx C, i int) D { + return p.logRow(th, gtx, entries[i]) + }) + }) +} + +func (p *logsPage) logRow(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 + } + + msg := e.Msg + attrs := make([]string, 0, len(e.Attrs)) + for _, at := range e.Attrs { + if at.Key == "from" { + continue + } + attrs = append(attrs, at.Key+"="+core.Redact(at.Value)) + } + + return layout.Inset{Top: 2, Bottom: 2, Left: SpaceSM, Right: SpaceSM}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Start}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return th.MonoLabel(SizeMono, WithAlpha(th.P.TextDim, 0.9), + e.Time.Format("15:04:05.000")).Layout(gtx) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(28) + return th.MonoLabel(SizeMono, lvlCol, core.LevelLabel(e.Level)).Layout(gtx) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + if e.Source == "" { + return D{} + } + gtx.Constraints.Max.X = gtx.Dp(96) + l := th.MonoLabel(SizeMono, WithAlpha(th.P.Info, 0.85), e.Source) + l.MaxLines = 1 + l.Alignment = text.End + return l.Layout(gtx) + }), + HGap(SpaceSM), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + l := th.MonoLabel(SizeMono, msgCol, core.Redact(msg)) + l.MaxLines = 3 + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if len(attrs) == 0 { + return D{} + } + l := th.MonoLabel(SizeMono, WithAlpha(th.P.TextDim, 0.95), + strings.Join(attrs, " ")) + l.MaxLines = 2 + return l.Layout(gtx) + }), + ) + }), + ) + }) +} diff --git a/gui/page_overview.go b/gui/page_overview.go new file mode 100644 index 0000000..e9923fd --- /dev/null +++ b/gui/page_overview.go @@ -0,0 +1,365 @@ +package gui + +import ( + "strings" + "time" + + "gioui.org/font" + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + "tslink/core" + "tslink/netdiag" +) + +type overviewPage struct { + list widget.List + diagBtn widget.Clickable + peersBtn widget.Clickable + copySelf widget.Clickable +} + +func newOverviewPage() *overviewPage { + p := &overviewPage{} + p.list.Axis = layout.Vertical + return p +} + +func (p *overviewPage) Layout(a *App, gtx C, st core.State) D { + th := a.th + + if p.diagBtn.Clicked(gtx) { + a.current = pageDiag + a.diag.run() + } + if p.peersBtn.Clicked(gtx) { + a.current = pagePeers + } + + var snap core.PeerSnapshot + if st.Peers != nil { + snap = st.Peers.Snapshot() + } + var lanServers []core.LanServer + if st.Lan != nil { + lanServers = st.Lan.Servers() + } + + 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.healthCard(a, gtx) }, + func(gtx C) D { return p.selfCard(a, gtx, st, snap) }, + 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 { + return layout.Inset{Bottom: SpaceMD}.Layout(gtx, items[i]) + }) +} + +// statTile is a headline number with its label. Four of them across the top +// answer "is anything obviously wrong" before the user reads anything else. +func (p *overviewPage) statTile(a *App, gtx C, value, label, hint string, level StatusLevel, icon IconFunc) D { + th := a.th + card := th.Card() + card.Pad = SpaceLG + return card.Layout(th, gtx, 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(func(gtx C) D { + if icon == nil { + return D{} + } + return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D { + return icon(gtx, gtx.Dp(13), th.P.TextDim) + }) + }), + layout.Flexed(1, OneLine(th.Caption(label)).Layout), + ) + }), + VGap(SpaceSM), + layout.Rigid(func(gtx C) D { + l := th.Display(value) + if level != LevelNeutral { + l.Color = th.StatusColor(level) + } + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if hint == "" { + return D{} + } + return OneLine(th.Caption(hint)).Layout(gtx) + }), + ) + }) +} + +func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, lan []core.LanServer) D { + th := a.th + + online, linked := 0, 0 + for _, pr := range snap.Peers { + if pr.Online { + online++ + } + if pr.Linked { + linked++ + } + } + forwardRules, connectRules := 0, 0 + if st.Config != nil { + for _, rs := range st.Config.Forward { + forwardRules += len(rs) + } + for _, rs := range st.Config.Connect { + connectRules += len(rs) + } + } + selfLan := 0 + for _, s := range lan { + if s.IsSelf { + selfLan++ + } + } + + peerLevel := LevelOK + if len(snap.Peers) > 0 && online == 0 { + peerLevel = LevelFail + } + + uptime := "—" + if !st.ReadyAt.IsZero() { + uptime = FormatDuration(time.Since(st.ReadyAt)) + } + + tiles := []layout.Widget{ + func(gtx C) D { + return p.statTile(a, gtx, + itoa(online)+" / "+itoa(len(snap.Peers)), + th.T(KOvPeersOnline), + itoa(linked)+" "+th.T(KPeersLinked), + peerLevel, IconNodes) + }, + func(gtx C) D { + return p.statTile(a, gtx, + itoa(len(lan)), + th.T(KOvLanServers), + itoa(selfLan)+" "+th.T(KLanSelf), + LevelNeutral, IconServer) + }, + func(gtx C) D { + return p.statTile(a, gtx, + itoa(connectRules)+" / "+itoa(forwardRules), + th.T(KOvConnectRules)+" / "+th.T(KOvForwardRules), + "", LevelNeutral, IconLink) + }, + func(gtx C) D { + hint := "" + if st.Restarts > 0 { + hint = itoa(st.Restarts) + "×" + th.T(KStateRetrying) + } + return p.statTile(a, gtx, uptime, th.T(KOvUptime), hint, LevelNeutral, IconPulse) + }, + } + + children := make([]layout.FlexChild, 0, len(tiles)*2-1) + for i, t := range tiles { + if i > 0 { + children = append(children, HGap(SpaceMD)) + } + children = append(children, layout.Flexed(1, t)) + } + return layout.Flex{Alignment: layout.Start}.Layout(gtx, children...) +} + +func (p *overviewPage) healthCard(a *App, gtx C) D { + th := a.th + a.diag.mu.Lock() + rep := a.diag.report + running := a.diag.running + lastRun := a.diag.lastRun + a.diag.mu.Unlock() + + card := th.Card() + card.Title = th.T(KOvHealth) + if rep != nil { + card.Subtitle = th.T(KDiagLastRun) + " " + RelTime(th, lastRun, time.Now()) + accent := th.StatusColor(diagLevel(rep.Status)) + card.Accent = &accent + } + + return card.Layout(th, gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + if rep == nil { + return th.Secondary(th.T(KDiagNever)).Layout(gtx) + } + 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.Font.Weight = font.Medium + l.MaxLines = 2 + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D { + return p.healthChips(a, gtx, rep) + }) + }), + ) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + label := th.T(KOvQuickDiag) + if running { + label = th.T(KDiagRunning) + } + return th.Button(gtx, &p.diagBtn, ButtonStyle{ + Kind: ButtonPrimary, + Text: label, + Icon: IconPulse, + Disabled: running, + }) + }), + ) + }) +} + +func (p *overviewPage) healthChips(a *App, gtx C, rep *netdiag.Report) D { + th := a.th + type chip struct { + label string + level StatusLevel + } + chips := []chip{ + {th.T(KDiagSecNAT) + ": " + natTypeLabel(th, rep.NAT.Type), diagLevel(rep.NAT.Status)}, + {th.T(KDiagSecUDP), diagLevel(rep.UDP.Status)}, + {th.T(KDiagSecOverseas), diagLevel(rep.Overseas.Status)}, + {th.T(KDiagSecPortMap), diagLevel(rep.PortMap.Status)}, + {th.T(KDiagSecEgress), diagLevel(rep.Egress.Status)}, + } + children := make([]layout.FlexChild, 0, len(chips)*2) + for i, c := range chips { + if i > 0 { + children = append(children, HGap(SpaceSM)) + } + children = append(children, layout.Rigid(func(gtx C) D { + return th.Chip(gtx, ChipStyle{Text: c.label, Level: c.level, Dot: true}) + })) + } + return layout.Flex{Spacing: layout.SpaceEnd}.Layout(gtx, children...) +} + +func (p *overviewPage) selfCard(a *App, gtx C, st core.State, snap core.PeerSnapshot) D { + th := a.th + card := th.Card() + card.Title = th.T(KOvSelf) + card.Trailing = func(gtx C) D { + return th.IconButton(gtx, &p.copySelf, IconCopy, LevelNeutral) + } + return card.Layout(th, gtx, func(gtx C) D { + rows := []KV{ + {Key: th.T(KOvTailnet), Value: orDash(snap.TailnetName)}, + {Key: "Hostname", Value: orDash(snap.Self.DisplayName), Mono: true}, + {Key: th.T(KPeerAddresses), Value: orDash(selfAddrText(snap.Self)), Mono: true}, + } + if snap.MagicDNSSuffix != "" { + rows = append(rows, KV{Key: "MagicDNS", Value: snap.MagicDNSSuffix, Mono: true}) + } + if snap.Err != "" { + rows = append(rows, KV{Key: th.T(KError), Value: snap.Err, Level: LevelFail}) + } + return th.KVList(gtx, rows) + }) +} + +func selfAddrText(self core.PeerInfo) string { + parts := make([]string, 0, len(self.TailscaleIPs)) + for _, ip := range self.TailscaleIPs { + parts = append(parts, ip.String()) + } + return strings.Join(parts, " ") +} + +func (p *overviewPage) linkedCard(a *App, gtx C, snap core.PeerSnapshot) D { + th := a.th + linked, _ := splitPeers(snap.Peers) + + card := th.Card() + card.Title = th.T(KPeersLinked) + card.Trailing = func(gtx C) D { + return th.Button(gtx, &p.peersBtn, ButtonStyle{ + Kind: ButtonGhost, Text: th.T(KDetails), Icon: IconChevronRight, + }) + } + return card.Layout(th, gtx, func(gtx C) D { + if len(linked) == 0 { + return th.EmptyState(gtx, IconLink, th.T(KPeersEmpty), th.T(KOvConnectRules)) + } + children := make([]layout.FlexChild, 0, len(linked)*2) + for i, pr := range linked { + if i > 0 { + children = append(children, layout.Rigid(th.Divider)) + } + children = append(children, layout.Rigid(func(gtx C) D { + return p.linkedRow(a, gtx, pr) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +func (p *overviewPage) linkedRow(a *App, gtx C, pr core.PeerInfo) D { + th := a.th + level := LevelOK + if !pr.Online { + level = LevelFail + } + latency := "—" + latLevel := LevelNeutral + if pr.LatencyOK && pr.LastLatency > 0 { + latency = FormatLatency(pr.LastLatency) + latLevel = latencyLevel(pr.LastLatency) + } + points := make([]ChartPoint, 0, len(pr.Samples)) + for _, s := range pr.Samples { + points = append(points, ChartPoint{ + At: s.At, + Value: float64(s.Latency) / float64(time.Millisecond), + OK: s.OK, + }) + } + + return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, 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(pr.DisplayName)).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + return th.Sparkline(gtx, points, th.StatusColor(latLevel), 70, 18) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(66) + return th.MonoLabel(SizeBody, th.StatusColor(latLevel), latency).Layout(gtx) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + return th.Chip(gtx, ChipStyle{ + Text: routeLabel(th, pr.Route), + Level: routeLevel(pr.Route), + }) + }), + ) + }) +} diff --git a/gui/page_peers.go b/gui/page_peers.go new file mode 100644 index 0000000..5802b8c --- /dev/null +++ b/gui/page_peers.go @@ -0,0 +1,479 @@ +package gui + +import ( + "sort" + "strings" + "time" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + "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 + +// 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 +// can be toggled on from the legend. +const maxChartSeries = 8 + +type peerRow struct { + click widget.Clickable + expanded bool +} + +type peersPage struct { + list widget.List + chart Chart + rows map[string]*peerRow + legend map[string]*widget.Clickable + hidden map[string]bool + refresh widget.Clickable +} + +func newPeersPage() *peersPage { + p := &peersPage{ + rows: make(map[string]*peerRow), + legend: make(map[string]*widget.Clickable), + hidden: make(map[string]bool), + } + p.list.Axis = layout.Vertical + return p +} + +func (p *peersPage) row(id string) *peerRow { + r, ok := p.rows[id] + if !ok { + r = &peerRow{} + p.rows[id] = r + } + return r +} + +func (p *peersPage) legendClick(id string) *widget.Clickable { + c, ok := p.legend[id] + if !ok { + c = &widget.Clickable{} + p.legend[id] = c + } + return c +} + +func (p *peersPage) Layout(a *App, gtx C, st core.State) D { + th := a.th + if st.Peers == nil { + return th.EmptyState(gtx, IconNodes, th.T(KPeersEmpty), th.T(KLoading)) + } + snap := st.Peers.Snapshot() + + if p.refresh.Clicked(gtx) { + st.Peers.RefreshNow() + a.notify(th.T(KRefresh), LevelInfo) + } + + linked, other := splitPeers(snap.Peers) + series := p.buildSeries(th, snap.Peers) + + // Legend clicks toggle series visibility. + for i := range series { + id := series[i].id + if p.legendClick(id).Clicked(gtx) { + p.hidden[id] = !p.hidden[id] + } + series[i].s.Hidden = p.hidden[id] + } + + items := make([]layout.Widget, 0, len(snap.Peers)+4) + items = append(items, func(gtx C) D { return p.chartCard(a, gtx, series) }) + + if len(linked) > 0 { + items = append(items, func(gtx C) D { + return a.sectionTitle(gtx, th.T(KPeersLinked), th.T(KGraphLegendHint), nil) + }) + 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 { + items = append(items, func(gtx C) D { + hint := snap.Err + if hint == "" { + hint = snap.BackendState + } + return th.EmptyState(gtx, IconNodes, th.T(KPeersEmpty), hint) + }) + } + + 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]) + }) +} + +// splitPeers separates the peers a config rule points at from the rest. Those +// are the only ones whose latency actually matters to the user's game session. +func splitPeers(peers []core.PeerInfo) (linked, other []core.PeerInfo) { + for _, p := range peers { + if p.Linked { + linked = append(linked, p) + } else { + other = append(other, p) + } + } + return +} + +type namedSeries struct { + id string + s ChartSeries +} + +func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries { + candidates := append([]core.PeerInfo(nil), peers...) + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Linked != candidates[j].Linked { + return candidates[i].Linked + } + return candidates[i].Online && !candidates[j].Online + }) + + out := make([]namedSeries, 0, maxChartSeries) + for i, pr := range candidates { + if len(out) >= maxChartSeries { + break + } + if len(pr.Samples) == 0 { + continue + } + pts := make([]ChartPoint, 0, len(pr.Samples)) + for _, s := range pr.Samples { + pts = append(pts, ChartPoint{ + At: s.At, + Value: float64(s.Latency) / float64(time.Millisecond), + OK: s.OK, + }) + } + out = append(out, namedSeries{ + id: pr.ID, + s: ChartSeries{ + Name: pr.DisplayName, + Color: th.SeriesColor(i), + Points: pts, + Subtitle: routeLabel(th, pr.Route), + }, + }) + } + return out +} + +func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D { + th := a.th + card := th.Card() + card.Title = th.T(KGraphTitle) + card.Subtitle = th.T(KGraphWindow) + card.Trailing = func(gtx C) D { + return th.IconButton(gtx, &p.refresh, IconRefresh, LevelNeutral) + } + return card.Layout(th, gtx, func(gtx C) 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) + }), + VGap(SpaceMD), + layout.Rigid(func(gtx C) D { + return p.legendRow(a, gtx, series) + }), + ) + }) +} + +func (p *peersPage) legendRow(a *App, gtx C, series []namedSeries) D { + th := a.th + children := make([]layout.FlexChild, 0, len(series)) + for _, s := range series { + id := s.id + entry := LegendEntry{ + Name: s.s.Name, + Color: s.s.Color, + Hidden: p.hidden[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...) +} + +func lastValue(points []ChartPoint) string { + for i := len(points) - 1; i >= 0; i-- { + if points[i].OK { + return FormatLatency(time.Duration(points[i].Value * float64(time.Millisecond))) + } + } + return "—" +} + +func routeLabel(th *Theme, r core.PeerRoute) string { + switch r { + case core.RouteDirect: + return th.T(KPeerRouteDirect) + case core.RouteDERP: + return th.T(KPeerRouteDERP) + case core.RoutePeerRelay: + return th.T(KPeerRoutePeerRelay) + case core.RouteOffline: + return th.T(KPeerRouteOffline) + default: + return th.T(KPeerRouteUnknown) + } +} + +func routeLevel(r core.PeerRoute) StatusLevel { + switch r { + case core.RouteDirect: + return LevelOK + case core.RouteDERP, core.RoutePeerRelay: + return LevelWarn + case core.RouteOffline: + return LevelFail + default: + return LevelNeutral + } +} + +func (p *peersPage) peerCard(a *App, gtx C, st core.State, pr core.PeerInfo) D { + th := a.th + row := p.row(pr.ID) + if row.click.Clicked(gtx) { + row.expanded = !row.expanded + } + + card := th.Card() + card.Pad = SpaceMD + if pr.Linked { + accent := th.SeriesColor(0) + if !pr.Online { + accent = th.P.TextDim + } + card.Accent = &accent + } + return card.Layout(th, gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return row.click.Layout(gtx, func(gtx C) D { + return p.peerHeader(a, gtx, pr, row.expanded) + }) + }), + layout.Rigid(func(gtx C) D { + if !row.expanded { + return D{} + } + return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { + return p.peerDetail(a, gtx, pr) + }) + }), + ) + }) +} + +func (p *peersPage) peerHeader(a *App, gtx C, pr core.PeerInfo, expanded bool) D { + th := a.th + level := LevelOK + if !pr.Online { + level = LevelNeutral + } + + latency := "—" + latLevel := LevelNeutral + if pr.LatencyOK && pr.LastLatency > 0 { + latency = FormatLatency(pr.LastLatency) + latLevel = latencyLevel(pr.LastLatency) + } else if pr.Online { + latency = th.T(KUnknown) + } + + points := make([]ChartPoint, 0, len(pr.Samples)) + for _, s := range pr.Samples { + points = append(points, ChartPoint{ + At: s.At, + Value: float64(s.Latency) / float64(time.Millisecond), + OK: s.OK, + }) + } + + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + // Never pulsing: one breathing dot per online peer would keep the + // whole window redrawing for as long as the page is open. + return th.StatusDot(gtx, level, false) + }), + 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(func(gtx C) D { + return OneLine(th.Body(pr.DisplayName)).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if !pr.Linked { + return D{} + } + return layout.Inset{Left: 6}.Layout(gtx, func(gtx C) D { + return IconLink(gtx, gtx.Dp(12), th.P.Accent) + }) + }), + ) + }), + layout.Rigid(func(gtx C) D { + sub := pr.DNSName + if sub == "" && len(pr.TailscaleIPs) > 0 { + sub = pr.TailscaleIPs[0].String() + } + if len(pr.LinkTags) > 0 { + sub = strings.Join(pr.LinkTags, ", ") + " · " + sub + } + return OneLine(th.Caption(sub)).Layout(gtx) + }), + ) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + return th.Sparkline(gtx, points, th.StatusColor(latLevel), 84, 22) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(70) + l := th.MonoLabel(SizeBody, th.StatusColor(latLevel), latency) + return l.Layout(gtx) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + return th.Chip(gtx, ChipStyle{ + Text: routeLabel(th, pr.Route), + Level: routeLevel(pr.Route), + }) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + icon := IconChevronRight + if expanded { + icon = IconChevronDown + } + return icon(gtx, gtx.Dp(14), th.P.TextDim) + }), + ) +} + +// latencyLevel colours a latency figure. The thresholds are chosen for the +// thing this tool carries: under 60ms a Minecraft session feels local, past +// 150ms block placement starts to feel wrong. +func latencyLevel(d time.Duration) StatusLevel { + switch { + case d <= 0: + return LevelNeutral + case d < 60*time.Millisecond: + return LevelOK + case d < 150*time.Millisecond: + return LevelWarn + default: + return LevelFail + } +} + +func (p *peersPage) peerDetail(a *App, gtx C, pr core.PeerInfo) D { + th := a.th + addrs := make([]string, 0, len(pr.TailscaleIPs)) + for _, ip := range pr.TailscaleIPs { + addrs = append(addrs, ip.String()) + } + + endpoint := pr.CurAddr + if endpoint == "" { + endpoint = pr.Relay + } + if endpoint == "" { + endpoint = "—" + } + + rows := []KV{ + {Key: th.T(KPeerAddresses), Value: strings.Join(addrs, " "), Mono: true}, + {Key: th.T(KPeerEndpoint), Value: endpoint, Mono: true}, + {Key: th.T(KPeerOS), Value: orDash(pr.OS)}, + { + Key: th.T(KPeerAvg) + " / " + th.T(KPeerMin) + " / " + th.T(KPeerMax), + Value: FormatLatency(pr.AvgLatency) + " " + + FormatLatency(pr.MinLatency) + " " + FormatLatency(pr.MaxLatency), + Mono: true, + }, + { + Key: th.T(KPeerJitter) + " / " + th.T(KPeerLoss), + Value: trimZero(pr.JitterMs, 1) + " ms · " + trimZero(pr.LossPct, 1) + " %", + Mono: true, + Level: lossLevel(pr.LossPct), + }, + { + Key: th.T(KPeerRx) + " / " + th.T(KPeerTx), + Value: FormatBytes(pr.RxBytes) + " · " + FormatBytes(pr.TxBytes), + Mono: true, + }, + {Key: th.T(KPeerLastHandshake), Value: RelTime(th, pr.LastHandshake, time.Now())}, + } + if !pr.Online { + rows = append(rows, KV{ + Key: th.T(KPeerLastSeen), + Value: RelTime(th, pr.LastSeen, time.Now()), + Level: LevelWarn, + }) + } + if pr.ExitNode { + rows = append(rows, KV{Key: th.T(KPeerExitNode), Value: th.T(KYes), Level: LevelInfo}) + } + return th.KVList(gtx, rows) +} + +func lossLevel(pct float64) StatusLevel { + switch { + case pct <= 0: + return LevelNeutral + case pct < 5: + return LevelWarn + default: + return LevelFail + } +} + +func orDash(s string) string { + if strings.TrimSpace(s) == "" { + return "—" + } + return s +} diff --git a/gui/page_settings.go b/gui/page_settings.go new file mode 100644 index 0000000..4f46340 --- /dev/null +++ b/gui/page_settings.go @@ -0,0 +1,144 @@ +package gui + +import ( + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + "tslink/core" +) + +type settingsPage struct { + app *App + list widget.List + + theme widget.Enum + lang widget.Enum + restart widget.Clickable +} + +func newSettingsPage(a *App) *settingsPage { + p := &settingsPage{app: a} + p.list.Axis = layout.Vertical + p.theme.Value = "dark" + if !a.th.Dark { + p.theme.Value = "light" + } + p.lang.Value = "zh" + if a.th.Lang == LangEN { + p.lang.Value = "en" + } + return p +} + +func (p *settingsPage) Layout(a *App, gtx C, st core.State) D { + th := a.th + + if p.theme.Update(gtx) { + th.SetDark(p.theme.Value == "dark") + } + if p.lang.Update(gtx) { + if p.lang.Value == "en" { + th.Lang = LangEN + } else { + th.Lang = LangZH + } + } + if p.restart.Clicked(gtx) && a.opt.Supervisor != nil { + a.opt.Supervisor.Restart() + a.notify(th.T(KStateRetrying), LevelInfo) + } + + items := []layout.Widget{ + func(gtx C) D { return p.appearanceCard(a, gtx) }, + func(gtx C) D { return p.aboutCard(a, gtx, st) }, + } + 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]) + }) +} + +func (p *settingsPage) appearanceCard(a *App, gtx C) D { + th := a.th + card := th.Card() + card.Title = th.T(KNavSettings) + return card.Layout(th, gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return p.settingRow(a, gtx, th.T(KSetTheme), "", func(gtx C) D { + return th.Segmented(gtx, &p.theme, []SegmentOption{ + {Key: "dark", Label: th.T(KSetThemeDark), Count: -1}, + {Key: "light", Label: th.T(KSetThemeLight), Count: -1}, + }) + }) + }), + layout.Rigid(th.Divider), + layout.Rigid(func(gtx C) D { + hint := "" + if !th.HasCJK { + hint = th.T(KSetFontMissing) + } + return p.settingRow(a, gtx, th.T(KSetLanguage), hint, func(gtx C) D { + return th.Segmented(gtx, &p.lang, []SegmentOption{ + {Key: "zh", Label: "中文", Count: -1}, + {Key: "en", Label: "English", Count: -1}, + }) + }) + }), + ) + }) +} + +func (p *settingsPage) settingRow(a *App, gtx C, label, hint string, control layout.Widget) D { + th := a.th + return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(th.Body(label).Layout), + layout.Rigid(func(gtx C) D { + if hint == "" { + return D{} + } + return th.Caption(hint).Layout(gtx) + }), + ) + }), + layout.Rigid(control), + ) + }) +} + +func (p *settingsPage) aboutCard(a *App, gtx C, st core.State) D { + th := a.th + card := th.Card() + card.Title = th.T(KSetAbout) + card.Trailing = func(gtx C) D { + return th.Button(gtx, &p.restart, ButtonStyle{ + Kind: ButtonSubtle, Text: th.T(KRetry), Icon: IconRefresh, + }) + } + + cfgPath := a.opt.ConfigPath + if a.opt.ConfigURL != "" { + cfgPath = a.opt.ConfigURL + } + fontPath := a.fonts.CJKPath + if fontPath == "" { + fontPath = th.T(KNone) + } + + return card.Layout(th, gtx, func(gtx C) D { + rows := []KV{ + {Key: th.T(KSetVersion), Value: orDash(a.opt.Version), Mono: true}, + {Key: "Runtime", Value: runtimeInfo(), Mono: true}, + {Key: th.T(KSetConfigPath), Value: orDash(cfgPath), Mono: true}, + {Key: th.T(KSetFont), Value: fontPath, Mono: true}, + {Key: th.T(KStateRunning), Value: st.Phase.String()}, + } + if st.Err != "" { + rows = append(rows, KV{Key: th.T(KError), Value: st.Err, Level: LevelFail}) + } + return th.KVList(gtx, rows) + }) +} diff --git a/gui/render_test.go b/gui/render_test.go new file mode 100644 index 0000000..2629f48 --- /dev/null +++ b/gui/render_test.go @@ -0,0 +1,308 @@ +package gui + +import ( + "image" + "log/slog" + "net/netip" + "testing" + "time" + + "gioui.org/io/input" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/text" + "gioui.org/unit" + + "tslink/core" + "tslink/netdiag" +) + +// These tests lay out every page without a GPU or a window. +// +// Layout is where a Gio UI actually breaks: a negative constraint, an +// unbounded flex child or a nil dereference in a rarely-taken branch panics at +// draw time, and there is no compiler check for any of it. Measurement runs +// the full flex/stack/text-shaping path, so exercising it catches those +// without needing a display — which also means it runs in CI. + +func testTheme(t *testing.T) *Theme { + t.Helper() + // Skip system font discovery: CI images have no CJK font and the walk + // would make the test depend on the host's font configuration. + fonts := &FontSet{Collection: goCollection(), UI: "Go", Mono: "Go Mono"} + th := NewTheme(fonts, true) + th.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(fonts.Collection)) + return th +} + +// newTestContext builds a layout context backed by a real input router, so +// widgets that register event handlers behave as they do on screen. +func newTestContext(size image.Point) (layout.Context, *input.Router) { + var r input.Router + gtx := layout.Context{ + Ops: new(op.Ops), + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + Constraints: layout.Exact(size), + Now: time.Now(), + Source: r.Source(), + } + return gtx, &r +} + +// testApp builds an App with no supervisor, which is the state the GUI is in +// before the service comes up. +func testApp(t *testing.T) *App { + t.Helper() + logs := core.NewLogBuffer(256) + logger := slog.New(logs.Handler(nil)) + for i := 0; i < 40; i++ { + logger.Info("synthetic log line", "i", i, "from", "test") + } + logger.Error("synthetic failure", "err", "boom", "from", "test") + + a := New(Options{ + Version: "test", + ConfigPath: "config.toml", + Logs: logs, + Logger: logger, + StartDark: true, + }) + a.th = testTheme(t) + return a +} + +// readyState fabricates a running service with a peer, a LAN server and a +// diagnostic report, so the populated branches of every page get exercised +// rather than just the empty states. +func readyState(t *testing.T) core.State { + t.Helper() + logger := slog.New(slog.DiscardHandler) + + cfg := &core.Config{ + Core: core.Core{Hostname: "test"}, + Connect: map[string][]core.ConnectRule{ + "survival": {{Protocol: "minecraft", LocalPort: 25565, DstAddr: "peer:25565"}}, + }, + Forward: map[string][]core.ForwardRule{ + "web": {{Protocol: "tcp", TailscalePort: 80, LocalAddr: "127.0.0.1:8080"}}, + }, + } + + return core.State{ + Phase: core.PhaseReady, + Steps: nil, + StartedAt: time.Now().Add(-time.Hour), + ReadyAt: time.Now().Add(-time.Hour), + Config: cfg, + Peers: core.NewPeerMonitor(nil, cfg.Connect, logger, core.PeerMonitorOptions{}), + Lan: core.NewLanScanner(logger), + } +} + +func TestPagesLayout(t *testing.T) { + sizes := []image.Point{ + {X: 1200, Y: 800}, // roomy + {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} + + for _, size := range sizes { + for _, page := range pages { + a := testApp(t) + a.current = page + st := readyState(t) + gtx, _ := newTestContext(size) + // Two frames: the first registers widget state, the second takes + // the paths that depend on it (hover, list position, caches). + for i := 0; i < 2; i++ { + a.shell(gtx, st) + } + } + } +} + +func TestSplashLayout(t *testing.T) { + phases := []core.Phase{ + core.PhaseIdle, core.PhaseStarting, core.PhaseRetrying, + core.PhaseError, core.PhaseStopped, + } + for _, phase := range phases { + for _, size := range []image.Point{{X: 1200, Y: 800}, {X: 880, Y: 560}, {X: 700, Y: 380}} { + a := testApp(t) + st := core.State{ + Phase: phase, + Steps: splashTestSteps(), + StartedAt: time.Now().Add(-10 * time.Second), + Err: "core.auth_key is required", + } + 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) + } + } +} + +func splashTestSteps() []core.BootStep { + now := time.Now() + return []core.BootStep{ + {Key: core.StepKeyConfig, State: core.StepDone, Started: now.Add(-3 * time.Second), Finished: now.Add(-2 * time.Second)}, + {Key: core.StepKeyTsnet, State: core.StepRunning, Started: now.Add(-2 * time.Second)}, + {Key: core.StepKeyRules, State: core.StepPending}, + {Key: core.StepKeyServices, State: core.StepFailed, Err: "listen: address already in use"}, + {Key: core.StepKeyMonitors, State: core.StepSkipped}, + {Key: core.StepKeyReady, State: core.StepPending}, + } +} + +// TestDiagPageWithReport renders every diagnostic section with a populated +// report, including the awkward cases: tri-state unknowns, divergent egress, +// and a failed probe row. +func TestDiagPageWithReport(t *testing.T) { + a := testApp(t) + a.current = pageDiag + + yes := true + rep := &netdiag.Report{ + StartedAt: time.Now().Add(-20 * time.Second), + Duration: 18 * time.Second, + Status: netdiag.StatusWarn, + Headline: "对称型 NAT:与同样受限的对端难以打洞", + Interfaces: netdiag.InterfaceReport{ + Status: netdiag.StatusOK, + Summary: "2 个接口 / 3 个地址", + DefaultV4Src: netip.MustParseAddr("192.168.1.23"), + Addrs: []netdiag.LocalAddr{ + {Iface: "eth0", Addr: netip.MustParseAddr("192.168.1.23"), Kind: netdiag.AddrPrivateV4, Up: true, MTU: 1500, IsDefaultSrc: true}, + {Iface: "tailscale0", Addr: netip.MustParseAddr("100.101.102.103"), Kind: netdiag.AddrTailscale, Up: true, MTU: 1280}, + }, + }, + UDP: netdiag.UDPReport{ + Status: netdiag.StatusWarn, Summary: "UDP 可用", V4OK: true, + 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"}, + }, + }, + NAT: netdiag.NATReport{ + Status: netdiag.StatusFail, Type: netdiag.NATSymmetric, + Mapping: netdiag.BehaviorAddressAndPortDependent, Filtering: netdiag.BehaviorUnknown, + Hairpin: nil, PortPreserving: &yes, + MappedAddrs: []netip.AddrPort{netip.MustParseAddrPort("1.2.3.4:1"), netip.MustParseAddrPort("1.2.3.4:2")}, + Notes: []string{"没有服务器支持 CHANGE-REQUEST"}, + Results: []netdiag.STUNResult{ + {Server: "stun.qq.com:3478", Name: "腾讯", Region: netdiag.RegionCN, OK: true, RTT: 9 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:1")}, + {Server: "stun.cloudflare.com:3478", Region: netdiag.RegionIntl, Err: "no response"}, + }, + }, + PortMap: netdiag.PortMapReport{ + Status: netdiag.StatusWarn, Gateway: netip.MustParseAddr("192.168.1.1"), + UPnP: netdiag.ServiceProbe{Available: true, Detail: "Archer AX73 (TP-Link)", ExternalIP: netip.MustParseAddr("1.2.3.4")}, + NATPMP: netdiag.ServiceProbe{Err: "timeout"}, + PCP: netdiag.ServiceProbe{Err: "timeout"}, + }, + Overseas: netdiag.OverseasReport{ + Status: netdiag.StatusWarn, Summary: "境外不可达", + Probes: []netdiag.ReachProbe{ + {Name: "cf", URL: "https://cp.cloudflare.com/generate_204", Region: netdiag.RegionIntl, Network: "tcp4", Err: "timeout"}, + {Name: "baidu", URL: "https://www.baidu.com", Region: netdiag.RegionCN, OK: true, StatusCode: 200, RTT: 30 * time.Millisecond}, + }, + }, + Egress: netdiag.EgressReport{ + Status: netdiag.StatusWarn, Divergent: true, Summary: "出口 IP 不一致", + UniqueIPs: []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")}, + Observations: []netdiag.EgressObservation{ + {Method: netdiag.MethodSTUN, Source: "stun.qq.com:3478", Region: netdiag.RegionCN, IP: netip.MustParseAddr("1.2.3.4")}, + {Method: netdiag.MethodHTTPProxy, Source: "https://api.ipify.org", Region: netdiag.RegionIntl, IP: netip.MustParseAddr("5.6.7.8")}, + {Method: netdiag.MethodHTTPv6, Source: "https://6.ipw.cn", Region: netdiag.RegionCN, Err: "no ipv6"}, + }, + Geo: []netdiag.GeoInfo{ + {IP: netip.MustParseAddr("1.2.3.4"), Country: "CN", City: "Shanghai", ASN: "AS4134", Org: "Chinanet", Provider: "ipinfo.io"}, + {IP: netip.MustParseAddr("5.6.7.8"), Err: "lookup failed"}, + }, + Countries: []string{"CN", "JP"}, + }, + Tailscale: netdiag.TailscaleReport{ + Available: true, UDP: true, IPv4: true, Status: netdiag.StatusOK, + Summary: "首选 DERP tok", PreferredDERP: "tok", + MappingVariesByDestIP: &yes, + DERP: []netdiag.DERPLatency{ + {RegionID: 1, RegionCode: "tok", Name: "Tokyo", Latency: 40 * time.Millisecond, Preferred: true}, + {RegionID: 2, RegionCode: "sin", Name: "Singapore", Latency: 90 * time.Millisecond}, + }, + }, + } + a.diag.report = rep + a.diag.lastRun = time.Now() + + for _, size := range []image.Point{{X: 1200, Y: 800}, {X: 880, Y: 560}} { + gtx, _ := newTestContext(size) + st := readyState(t) + for i := 0; i < 2; i++ { + a.shell(gtx, st) + } + } + + // The report must also render as shareable text without panicking. + if got := rep.Text(); got == "" { + t.Fatal("Report.Text returned empty") + } +} + +// 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 + }{ + {FormatLatency(0), "—"}, + {FormatLatency(1500 * time.Microsecond), "1.5 ms"}, + {FormatLatency(42 * time.Millisecond), "42 ms"}, + {FormatLatency(2500 * time.Millisecond), "2.5 s"}, + {FormatBytes(0), "0 B"}, + {FormatBytes(2048), "2 KiB"}, + {FormatBytes(5 * 1024 * 1024), "5 MiB"}, + {FormatDuration(90 * time.Second), "1m 30s"}, + {FormatDuration(3 * time.Hour), "3h 0m"}, + {Truncate("abcdef", 4), "abc…"}, + {Truncate("ab", 4), "ab"}, + } + for i, c := range cases { + if c.got != c.want { + t.Errorf("case %d: got %q want %q", i, c.got, c.want) + } + } +} + +func TestTrFallsBackToEnglish(t *testing.T) { + if Tr(LangEN, KNavPeers) != "Peers" { + t.Errorf("english lookup failed") + } + if Tr(LangZH, KNavPeers) != "节点" { + t.Errorf("chinese lookup failed") + } + if Tr(LangZH, Key(-1)) != "?" { + t.Errorf("out-of-range key should not panic or return empty") + } + // Every key must resolve in both languages; a missing entry would render + // as a bare "?" in the UI. + for k := Key(0); k < kCount; k++ { + if Tr(LangEN, k) == "?" { + t.Errorf("key %d has no english string", k) + } + if Tr(LangZH, k) == "?" { + t.Errorf("key %d has no chinese string", k) + } + } +} diff --git a/gui/splash.go b/gui/splash.go new file mode 100644 index 0000000..632a334 --- /dev/null +++ b/gui/splash.go @@ -0,0 +1,297 @@ +package gui + +import ( + "image" + "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/widget" + "gioui.org/widget/material" + + "tslink/core" +) + +// 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 + // 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. + list widget.List +} + +func newSplashView() *splashView { + s := &splashView{} + s.list.Axis = layout.Vertical + return s +} + +// stepTitles maps supervisor step keys onto localised labels. +func stepTitle(th *Theme, key string) string { + switch key { + case core.StepKeyConfig: + return th.T(KStepConfig) + case core.StepKeyTsnet: + return th.T(KStepTsnet) + case core.StepKeyRules: + return th.T(KStepRules) + case core.StepKeyServices: + return th.T(KStepDiscovery) + case core.StepKeyMonitors: + return th.T(KStepMonitors) + case core.StepKeyReady: + return th.T(KStepReady) + default: + return key + } +} + +func (s *splashView) Layout(a *App, gtx C, st core.State) D { + th := a.th + paint.Fill(gtx.Ops, th.P.Bg) + + 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 + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + // A single-element list: centred when it fits, scrollable when the + // window is too short for the checklist plus an error message. + return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(460)) + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{Top: SpaceLG, Bottom: SpaceLG}.Layout(gtx, func(gtx C) D { + return s.panel(a, gtx, st) + }) + }) + }) + }), + layout.Rigid(func(gtx C) D { return D{Size: image.Pt(0, reserve)} }), + ) +} + +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 + l.Alignment = text.Middle + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + l := th.Caption(th.T(KAppSubtitle)) + l.Alignment = text.Middle + return l.Layout(gtx) + }), + VGap(SpaceLG), + layout.Rigid(func(gtx C) D { + return th.ProgressBar(gtx, st.Progress(), th.P.Accent) + }), + VGap(SpaceLG), + layout.Rigid(func(gtx C) D { + return s.checklist(a, gtx, st) + }), + layout.Rigid(func(gtx C) D { + return s.footer(a, gtx, st) + }), + ) +} + +// 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)) + for _, step := range st.Steps { + children = append(children, layout.Rigid(func(gtx C) D { + return s.stepRow(a, gtx, step) + })) + } + card := th.Card() + card.Pad = SpaceMD + card.Bg = &th.P.BgElevated + return card.Layout(th, gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D { + th := a.th + var ( + fg = th.P.TextDim + badge layout.Widget + ) + switch step.State { + case core.StepRunning: + fg = th.P.TextPri + badge = func(gtx C) D { return th.Spinner(gtx, gtx.Dp(14), th.P.Accent) } + case core.StepDone: + fg = th.P.TextSec + badge = func(gtx C) D { return IconCheck(gtx, gtx.Dp(14), th.P.OK) } + case core.StepFailed: + fg = th.P.Fail + badge = func(gtx C) D { return IconWarn(gtx, gtx.Dp(14), th.P.Fail) } + case core.StepSkipped: + badge = func(gtx C) D { return Circle(gtx, gtx.Dp(6), th.P.TextDim) } + default: + badge = func(gtx C) D { + // An empty ring reads as "not started" without adding a colour. + drawArc(gtx, f32.Pt(7, 7), 5, 1, 0, 2*math.Pi, WithAlpha(th.P.TextDim, 0.5)) + return D{Size: image.Pt(gtx.Dp(14), gtx.Dp(14))} + } + } + + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Dp(18) + return layout.W.Layout(gtx, badge) + }), + HGap(SpaceSM), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(OneLine(th.Text(SizeBody, fg, stepTitle(th, step.Key))).Layout), + layout.Rigid(func(gtx C) D { + if step.Err == "" { + return D{} + } + return OneLine(th.Text(SizeCaption, th.P.Fail, step.Err)).Layout(gtx) + }), + ) + }), + layout.Rigid(func(gtx C) D { + if step.State != core.StepDone || step.Elapsed() < 100*time.Millisecond { + return D{} + } + return th.MonoLabel(SizeCaption, th.P.TextDim, + FormatLatency(step.Elapsed())).Layout(gtx) + }), + ) + }) +} + +func (s *splashView) footer(a *App, gtx C, st core.State) D { + th := a.th + return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D { + switch st.Phase { + case core.PhaseError: + // Error text and the retry button sit side by side: stacking them + // pushes the only control on this screen below the fold on a short + // window. + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + l := th.Text(SizeCaption, th.P.Fail, st.Err) + l.MaxLines = 4 + return l.Layout(gtx) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = 0 + return th.Button(gtx, &s.retry, ButtonStyle{ + Kind: ButtonPrimary, + Text: th.T(KRetry), + Icon: IconRefresh, + }) + }), + ) + + case core.PhaseRetrying: + msg := th.T(KSplashRetry) + if st.Err != "" { + msg = st.Err + } + l := th.Text(SizeCaption, th.P.Warn, msg) + l.Alignment = text.Middle + l.MaxLines = 3 + return l.Layout(gtx) + + default: + l := th.Caption(th.T(KSplashHint)) + l.Alignment = text.Middle + return l.Layout(gtx) + } + }) +} + +// --------------------------------------------------------------------------- +// 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()) +} diff --git a/gui/theme.go b/gui/theme.go new file mode 100644 index 0000000..60f7884 --- /dev/null +++ b/gui/theme.go @@ -0,0 +1,268 @@ +package gui + +import ( + "image/color" + + "gioui.org/font" + "gioui.org/text" + "gioui.org/unit" + "gioui.org/widget/material" +) + +// Spacing scale. Every gap in the UI is one of these; ad-hoc values are what +// makes an interface feel noisy. +const ( + SpaceXS unit.Dp = 4 + SpaceSM unit.Dp = 8 + SpaceMD unit.Dp = 12 + SpaceLG unit.Dp = 16 + SpaceXL unit.Dp = 24 + Space2XL unit.Dp = 32 +) + +// Corner radii. +const ( + RadiusSM unit.Dp = 6 + RadiusMD unit.Dp = 10 + RadiusLG unit.Dp = 14 + RadiusPill unit.Dp = 999 +) + +// Type scale. Only five sizes exist so hierarchy stays legible when a panel is +// dense with numbers. +const ( + SizeDisplay unit.Sp = 23 + SizeTitle unit.Sp = 17 + SizeSubtitle unit.Sp = 14 + SizeBody unit.Sp = 13 + SizeCaption unit.Sp = 11.5 + SizeMono unit.Sp = 12 +) + +// Palette holds every colour the UI is allowed to use. +type Palette struct { + // Surfaces, from furthest back to nearest front. + Bg color.NRGBA + BgElevated color.NRGBA + Surface color.NRGBA + SurfaceHi color.NRGBA + + Border color.NRGBA + BorderHi color.NRGBA + + // Three text weights carry the whole information hierarchy: primary for + // values, secondary for labels, dim for metadata. + TextPri color.NRGBA + TextSec color.NRGBA + TextDim color.NRGBA + + Accent color.NRGBA + AccentDim color.NRGBA + AccentFg color.NRGBA + + OK color.NRGBA + Warn color.NRGBA + Fail color.NRGBA + Info color.NRGBA + + // Series colours for the latency chart, in assignment order. They are + // distinguishable at 2px stroke width and stay distinct in both themes. + Series []color.NRGBA + + // Scrim dims the app behind the loading overlay. + Scrim color.NRGBA +} + +func rgb(v uint32) color.NRGBA { + return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xFF} +} + +// DarkPalette is the default. The app is a diagnostic tool that people leave +// open in the background, so it defaults to the low-glare theme. +func DarkPalette() Palette { + return Palette{ + Bg: rgb(0x0E1116), + BgElevated: rgb(0x141922), + Surface: rgb(0x1A202B), + SurfaceHi: rgb(0x222A38), + + Border: rgb(0x252E3B), + BorderHi: rgb(0x364153), + + TextPri: rgb(0xE7EBF3), + TextSec: rgb(0x9AA4B8), + TextDim: rgb(0x69738A), + + Accent: rgb(0x4C8DFF), + AccentDim: rgb(0x27447A), + AccentFg: rgb(0xFFFFFF), + + OK: rgb(0x3DCE87), + Warn: rgb(0xF0A93B), + Fail: rgb(0xFF6B6B), + Info: rgb(0x8B9BFF), + + Series: []color.NRGBA{ + rgb(0x4C8DFF), rgb(0x2DD4BF), rgb(0xA78BFA), rgb(0xFBBF24), + rgb(0xF472B6), rgb(0xA3E635), rgb(0x38BDF8), rgb(0xFB923C), + }, + + Scrim: color.NRGBA{R: 0x08, G: 0x0A, B: 0x0E, A: 0xC4}, + } +} + +// LightPalette mirrors the dark one for people working in bright rooms. +func LightPalette() Palette { + return Palette{ + Bg: rgb(0xF6F7F9), + BgElevated: rgb(0xFFFFFF), + Surface: rgb(0xFFFFFF), + SurfaceHi: rgb(0xF0F2F6), + + Border: rgb(0xE3E7ED), + BorderHi: rgb(0xCFD5DE), + + TextPri: rgb(0x111826), + TextSec: rgb(0x4A5568), + TextDim: rgb(0x818C9E), + + Accent: rgb(0x2563EB), + AccentDim: rgb(0xBFD3FA), + AccentFg: rgb(0xFFFFFF), + + OK: rgb(0x0F9D58), + Warn: rgb(0xC77700), + Fail: rgb(0xD93636), + Info: rgb(0x4F5DD1), + + Series: []color.NRGBA{ + rgb(0x2563EB), rgb(0x0D9488), rgb(0x7C3AED), rgb(0xD97706), + rgb(0xDB2777), rgb(0x65A30D), rgb(0x0284C7), rgb(0xEA580C), + }, + + Scrim: color.NRGBA{R: 0x1A, G: 0x1F, B: 0x28, A: 0xB8}, + } +} + +// Theme bundles the Gio material theme with this app's design tokens. +type Theme struct { + *material.Theme + P Palette + Dark bool + + // Mono is the typeface used for addresses, ports and log lines, where + // column alignment matters more than typographic polish. + Mono font.Typeface + + // HasCJK reports whether a font with Chinese coverage was found. When it + // is false the UI falls back to English labels rather than rendering + // tofu boxes. + HasCJK bool + + // Lang selects the label set. + Lang Lang +} + +// NewTheme builds a theme from a shaper and font collection produced by +// [LoadFonts]. +func NewTheme(fonts *FontSet, dark bool) *Theme { + mt := material.NewTheme() + mt.Shaper = text.NewShaper(text.WithCollection(fonts.Collection)) + mt.TextSize = SizeBody + mt.Face = fonts.UI + mt.FingerSize = 26 + + th := &Theme{ + Theme: mt, + Dark: dark, + Mono: fonts.Mono, + HasCJK: fonts.HasCJK, + Lang: LangEN, + } + if fonts.HasCJK { + th.Lang = LangZH + } + th.SetDark(dark) + return th +} + +// SetDark switches palettes and keeps the embedded material palette in sync so +// stock Gio widgets pick up the right colours too. +func (t *Theme) SetDark(dark bool) { + t.Dark = dark + if dark { + t.P = DarkPalette() + } else { + t.P = LightPalette() + } + t.Theme.Palette = material.Palette{ + Bg: t.P.Bg, + Fg: t.P.TextPri, + ContrastBg: t.P.Accent, + ContrastFg: t.P.AccentFg, + } +} + +// T looks up a localised string. It is a method on Theme so call sites stay +// short: th.T(K.Peers). +func (t *Theme) T(k Key) string { return Tr(t.Lang, k) } + +// StatusColor maps a traffic-light verdict onto the palette. +func (t *Theme) StatusColor(s StatusLevel) color.NRGBA { + switch s { + case LevelOK: + return t.P.OK + case LevelWarn: + return t.P.Warn + case LevelFail: + return t.P.Fail + case LevelInfo: + return t.P.Info + default: + return t.P.TextDim + } +} + +// StatusLevel is the UI-side severity, deliberately decoupled from +// netdiag.Status so widgets do not depend on the diagnostics package. +type StatusLevel int + +const ( + LevelNeutral StatusLevel = iota + LevelOK + LevelWarn + LevelFail + LevelInfo +) + +// SeriesColor returns a stable chart colour for index i. +func (t *Theme) SeriesColor(i int) color.NRGBA { + if len(t.P.Series) == 0 { + return t.P.Accent + } + return t.P.Series[i%len(t.P.Series)] +} + +// WithAlpha returns c with its alpha scaled by a (0..1). +func WithAlpha(c color.NRGBA, a float32) color.NRGBA { + if a < 0 { + a = 0 + } + if a > 1 { + a = 1 + } + c.A = uint8(float32(c.A) * a) + return c +} + +// Mix blends a into b by t (0 returns a, 1 returns b). +func Mix(a, b color.NRGBA, t float32) color.NRGBA { + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + lerp := func(x, y uint8) uint8 { return uint8(float32(x) + (float32(y)-float32(x))*t) } + return color.NRGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: lerp(a.A, b.A)} +} diff --git a/gui/util.go b/gui/util.go new file mode 100644 index 0000000..57436bc --- /dev/null +++ b/gui/util.go @@ -0,0 +1,20 @@ +package gui + +import ( + "runtime" + "time" +) + +// runtimeInfo describes the host, for diagnostic bundle headers. +func runtimeInfo() string { + return runtime.GOOS + "/" + runtime.GOARCH + " go" + runtime.Version()[2:] +} + +// timeSince is time.Since, wrapped so tests can reason about it and so call +// sites in layout code read consistently. +func timeSince(t time.Time) time.Duration { + if t.IsZero() { + return 0 + } + return time.Since(t) +} diff --git a/gui/widgets.go b/gui/widgets.go new file mode 100644 index 0000000..ad3514e --- /dev/null +++ b/gui/widgets.go @@ -0,0 +1,906 @@ +package gui + +import ( + "image" + "image/color" + "math" + "strings" + "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" +) + +// Short aliases, the conventional Gio shorthand. +type ( + C = layout.Context + D = layout.Dimensions +) + +// --------------------------------------------------------------------------- +// Text +// --------------------------------------------------------------------------- + +// Text returns a label in the app's type scale. +func (t *Theme) Text(size unit.Sp, col color.NRGBA, txt string) material.LabelStyle { + l := material.Label(t.Theme, size, txt) + l.Color = col + return l +} + +// Mono returns a monospaced label, used wherever columns of addresses, ports +// or timings need to line up. +func (t *Theme) MonoLabel(size unit.Sp, col color.NRGBA, txt string) material.LabelStyle { + l := t.Text(size, col, txt) + l.Font.Typeface = t.Mono + return l +} + +// Title is the heading of a page. +func (t *Theme) Title(txt string) material.LabelStyle { + l := t.Text(SizeTitle, t.P.TextPri, txt) + l.Font.Weight = font.SemiBold + return l +} + +// Display is the single largest text on a page, used for headline numbers. +func (t *Theme) Display(txt string) material.LabelStyle { + l := t.Text(SizeDisplay, t.P.TextPri, txt) + l.Font.Weight = font.SemiBold + return l +} + +// Body is normal running text. +func (t *Theme) Body(txt string) material.LabelStyle { + return t.Text(SizeBody, t.P.TextPri, txt) +} + +// Secondary is a de-emphasised label, typically the left column of a key/value +// row. +func (t *Theme) Secondary(txt string) material.LabelStyle { + return t.Text(SizeBody, t.P.TextSec, txt) +} + +// Caption is metadata: timestamps, hints, units. +func (t *Theme) Caption(txt string) material.LabelStyle { + return t.Text(SizeCaption, t.P.TextDim, txt) +} + +// OneLine constrains a label to a single truncated line, which keeps table +// rows from reflowing when a peer has a long name. +func OneLine(l material.LabelStyle) material.LabelStyle { + l.MaxLines = 1 + l.WrapPolicy = text.WrapGraphemes + return l +} + +// --------------------------------------------------------------------------- +// Primitive drawing helpers +// --------------------------------------------------------------------------- + +// FillRRect paints a rounded rectangle of the given size. +func FillRRect(gtx C, size image.Point, radius unit.Dp, col color.NRGBA) { + r := gtx.Dp(radius) + if max := min(size.X, size.Y) / 2; r > max { + r = max + } + paint.FillShape(gtx.Ops, col, clip.UniformRRect(image.Rectangle{Max: size}, r).Op(gtx.Ops)) +} + +// StrokeRRect outlines a rounded rectangle. +func StrokeRRect(gtx C, size image.Point, radius unit.Dp, width unit.Dp, col color.NRGBA) { + r := gtx.Dp(radius) + if max := min(size.X, size.Y) / 2; r > max { + r = max + } + w := float32(gtx.Dp(width)) + // Inset by half the stroke width so the outline lands inside the bounds. + inset := int(w / 2) + rect := image.Rectangle{Min: image.Pt(inset, inset), Max: size.Sub(image.Pt(inset, inset))} + if rect.Dx() <= 0 || rect.Dy() <= 0 { + return + } + spec := clip.UniformRRect(rect, r).Path(gtx.Ops) + paint.FillShape(gtx.Ops, col, clip.Stroke{Path: spec, Width: w}.Op()) +} + +// Circle paints a filled circle of the given diameter. +func Circle(gtx C, diameter int, col color.NRGBA) D { + if diameter <= 0 { + return D{} + } + r := diameter / 2 + paint.FillShape(gtx.Ops, col, + clip.UniformRRect(image.Rectangle{Max: image.Pt(diameter, diameter)}, r).Op(gtx.Ops)) + return D{Size: image.Pt(diameter, diameter)} +} + +// animFrame is the minimum gap between animation frames, i.e. a ~25fps cap. +// +// This matters more than it looks. op.InvalidateCmd with a zero At means +// "redraw immediately", so a widget that issues one every frame makes Gio +// render as fast as the machine can manage — several hundred percent CPU under +// software rendering, for a spinner nobody is watching. Scheduling the next +// frame at a fixed time bounds the loop, and concurrent animations coalesce +// onto the same wakeup. +// +// The cap alone is not enough, because a frame is not cheap: profiling this UI +// under llvmpipe put 73% of the time in Gio's path stenciler, which every +// rounded rectangle, border and icon goes through. So animation is also +// reserved for genuinely transient states — see [Theme.StatusDot]. An idle +// 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} } + +// VGap is a vertical gap. +func VGap(v unit.Dp) layout.FlexChild { + return layout.Rigid(layout.Spacer{Height: v}.Layout) +} + +// HGap is a horizontal gap. +func HGap(v unit.Dp) layout.FlexChild { + return layout.Rigid(layout.Spacer{Width: v}.Layout) +} + +// Divider draws a hairline separator. +func (t *Theme) Divider(gtx C) D { + h := max(gtx.Dp(1), 1) + w := gtx.Constraints.Min.X + if w == 0 { + w = gtx.Constraints.Max.X + } + paint.FillShape(gtx.Ops, t.P.Border, clip.Rect{Max: image.Pt(w, h)}.Op()) + return D{Size: image.Pt(w, h)} +} + +// --------------------------------------------------------------------------- +// Card +// --------------------------------------------------------------------------- + +// CardStyle is the standard container: a slightly raised surface with a +// hairline border. Cards are the only container in the UI, which is what keeps +// dense pages from turning into noise. +type CardStyle struct { + Title string + Subtitle string + // Accent tints the left edge, used to flag a section's severity without + // adding another coloured chip. + Accent *color.NRGBA + // Trailing renders at the top-right of the header, for actions. + Trailing layout.Widget + Pad unit.Dp + Radius unit.Dp + Bg *color.NRGBA +} + +// Card returns a default card. +func (t *Theme) Card() CardStyle { + return CardStyle{Pad: SpaceLG, Radius: RadiusMD} +} + +// Layout draws the card around w. +func (c CardStyle) Layout(t *Theme, gtx C, w layout.Widget) D { + bg := t.P.Surface + if c.Bg != nil { + bg = *c.Bg + } + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + size := gtx.Constraints.Min + FillRRect(gtx, size, c.Radius, bg) + StrokeRRect(gtx, size, c.Radius, 1, t.P.Border) + if c.Accent != nil { + // A 3dp bar hugging the left edge, clipped to the card radius. + r := gtx.Dp(c.Radius) + defer clip.UniformRRect(image.Rectangle{Max: size}, r).Push(gtx.Ops).Pop() + paint.FillShape(gtx.Ops, *c.Accent, + clip.Rect{Max: image.Pt(gtx.Dp(3), size.Y)}.Op()) + } + return D{Size: size} + }), + layout.Stacked(func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.UniformInset(c.Pad).Layout(gtx, func(gtx C) D { + if c.Title == "" { + return w(gtx) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return c.header(t, gtx) + }), + VGap(SpaceMD), + layout.Rigid(w), + ) + }) + }), + ) +} + +func (c CardStyle) header(t *Theme, gtx C) D { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + l := t.Text(SizeSubtitle, t.P.TextPri, c.Title) + l.Font.Weight = font.SemiBold + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if c.Subtitle == "" { + return D{} + } + return layout.Inset{Top: 2}.Layout(gtx, t.Caption(c.Subtitle).Layout) + }), + ) + }), + layout.Rigid(func(gtx C) D { + if c.Trailing == nil { + return D{} + } + return c.Trailing(gtx) + }), + ) +} + +// --------------------------------------------------------------------------- +// Chips, dots, badges +// --------------------------------------------------------------------------- + +// ChipStyle is a small pill carrying one piece of status. +type ChipStyle struct { + Text string + Level StatusLevel + // Solid fills the chip with the level colour instead of tinting it. + Solid bool + // Dot prefixes the label with a status dot. + Dot bool +} + +// Chip renders a status pill. +func (t *Theme) Chip(gtx C, s ChipStyle) D { + fg := t.StatusColor(s.Level) + bg := WithAlpha(fg, 0.14) + if s.Solid { + bg = fg + fg = t.P.AccentFg + } + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + FillRRect(gtx, gtx.Constraints.Min, RadiusPill, bg) + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return layout.Inset{ + Top: 3, Bottom: 3, Left: SpaceSM, Right: SpaceSM, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if !s.Dot { + return D{} + } + return layout.Inset{Right: 5}.Layout(gtx, func(gtx C) D { + return Circle(gtx, gtx.Dp(6), fg) + }) + }), + layout.Rigid(OneLine(t.Text(SizeCaption, fg, s.Text)).Layout), + ) + }) + }), + ) +} + +// StatusDot draws a coloured dot; when pulse is true it breathes. +// +// Pass pulse only for states that are actually transient — connecting, +// retrying, a probe in flight. A dot that breathes forever costs a full +// redraw of the window several times a second for as long as the app is open, +// which is not a price worth paying to say "still here". +func (t *Theme) StatusDot(gtx C, level StatusLevel, pulse bool) D { + col := t.StatusColor(level) + d := gtx.Dp(8) + if pulse { + // One breath per 1.6s, derived from frame time so it stays smooth. + phase := float64(gtx.Now.UnixNano()%int64(1600*time.Millisecond)) / float64(1600*time.Millisecond) + a := 0.35 + 0.65*(0.5+0.5*math.Sin(phase*2*math.Pi)) + halo := WithAlpha(col, float32(a)*0.35) + hd := gtx.Dp(16) + off := op.Offset(image.Pt(-(hd-d)/2, -(hd-d)/2)).Push(gtx.Ops) + Circle(gtx, hd, halo) + off.Pop() + animate(gtx) + } + return Circle(gtx, d, col) +} + +// --------------------------------------------------------------------------- +// Key/value rows +// --------------------------------------------------------------------------- + +// KV renders a label on the left and a value on the right. This is the primary +// way facts are shown; keeping every panel on the same row grammar is what +// makes a dense diagnostics page scannable. +type KV struct { + Key string + Value string + // Level colours the value. LevelNeutral leaves it primary-coloured. + Level StatusLevel + // Mono renders the value monospaced. + Mono bool + // Hint appears under the key in caption style. + Hint string + // KeyWidth fixes the label column so consecutive rows align. Zero uses a + // flexible 40% split. + KeyWidth unit.Dp +} + +// Layout draws one key/value row. +func (t *Theme) KV(gtx C, kv KV) D { + valCol := t.P.TextPri + if kv.Level != LevelNeutral { + valCol = t.StatusColor(kv.Level) + } + value := func(gtx C) D { + var l material.LabelStyle + if kv.Mono { + l = t.MonoLabel(SizeBody, valCol, kv.Value) + } else { + l = t.Text(SizeBody, valCol, kv.Value) + } + l.Alignment = text.End + return l.Layout(gtx) + } + key := func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(OneLine(t.Secondary(kv.Key)).Layout), + layout.Rigid(func(gtx C) D { + if kv.Hint == "" { + return D{} + } + return t.Caption(kv.Hint).Layout(gtx) + }), + ) + } + return layout.Inset{Top: 5, Bottom: 5}.Layout(gtx, func(gtx C) D { + if kv.KeyWidth > 0 { + w := gtx.Dp(kv.KeyWidth) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + gtx.Constraints.Max.X = w + gtx.Constraints.Min.X = w + return key(gtx) + }), + HGap(SpaceMD), + layout.Flexed(1, value), + ) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(0.42, key), + HGap(SpaceMD), + layout.Flexed(0.58, value), + ) + }) +} + +// KVList lays out consecutive rows with hairlines between them. +func (t *Theme) KVList(gtx C, rows []KV) D { + children := make([]layout.FlexChild, 0, len(rows)*2) + for i, row := range rows { + if i > 0 { + children = append(children, layout.Rigid(t.Divider)) + } + children = append(children, layout.Rigid(func(gtx C) D { + return t.KV(gtx, row) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + +// --------------------------------------------------------------------------- +// Buttons +// --------------------------------------------------------------------------- + +// ButtonKind selects a button's visual weight. A screen should have at most +// one Primary. +type ButtonKind int + +const ( + ButtonPrimary ButtonKind = iota + ButtonSubtle + ButtonGhost + ButtonDanger +) + +// ButtonStyle is this app's button, replacing material.Button so hover, radius +// and typography match the rest of the design. +type ButtonStyle struct { + Kind ButtonKind + Text string + Icon IconFunc + Disabled bool + // Width, when non-zero, fixes the button width for aligned button rows. + Width unit.Dp +} + +// Button renders a clickable button. +func (t *Theme) Button(gtx C, click *widget.Clickable, s ButtonStyle) D { + var bg, fg, border color.NRGBA + switch s.Kind { + case ButtonPrimary: + bg, fg = t.P.Accent, t.P.AccentFg + case ButtonDanger: + bg, fg = t.P.Fail, t.P.AccentFg + case ButtonSubtle: + bg, fg, border = t.P.SurfaceHi, t.P.TextPri, t.P.Border + default: // ghost + bg, fg = color.NRGBA{}, t.P.TextSec + } + if s.Disabled { + bg = WithAlpha(bg, 0.4) + fg = WithAlpha(fg, 0.45) + gtx = gtx.Disabled() + } else if click.Hovered() { + switch s.Kind { + case ButtonGhost: + bg = t.P.SurfaceHi + fg = t.P.TextPri + default: + bg = Mix(bg, t.P.TextPri, 0.12) + } + } + if click.Pressed() { + bg = Mix(bg, t.P.Bg, 0.18) + } + + return click.Layout(gtx, func(gtx C) D { + if s.Width > 0 { + gtx.Constraints.Min.X = gtx.Dp(s.Width) + } + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + if bg.A > 0 { + FillRRect(gtx, gtx.Constraints.Min, RadiusSM, bg) + } + if border.A > 0 { + StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, border) + } + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return layout.Inset{ + Top: 7, Bottom: 7, Left: SpaceMD, Right: SpaceMD, + }.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if s.Icon == nil { + return D{} + } + return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D { + return s.Icon(gtx, gtx.Dp(14), fg) + }) + }), + layout.Rigid(func(gtx C) D { + if s.Text == "" { + return D{} + } + l := t.Text(SizeBody, fg, s.Text) + l.Font.Weight = font.Medium + l.Alignment = text.Middle + return l.Layout(gtx) + }), + ) + }) + }), + ) + }) +} + +// IconButton is a square icon-only button, used in card headers. +func (t *Theme) IconButton(gtx C, click *widget.Clickable, icon IconFunc, level StatusLevel) D { + fg := t.P.TextSec + if level != LevelNeutral { + fg = t.StatusColor(level) + } + bg := color.NRGBA{} + if click.Hovered() { + bg = t.P.SurfaceHi + if level == LevelNeutral { + fg = t.P.TextPri + } + } + return click.Layout(gtx, func(gtx C) D { + sz := gtx.Dp(28) + if bg.A > 0 { + FillRRect(gtx, image.Pt(sz, sz), RadiusSM, bg) + } + icoSize := gtx.Dp(16) + off := op.Offset(image.Pt((sz-icoSize)/2, (sz-icoSize)/2)).Push(gtx.Ops) + icon(gtx, icoSize, fg) + off.Pop() + return D{Size: image.Pt(sz, sz)} + }) +} + +// --------------------------------------------------------------------------- +// Toggle +// --------------------------------------------------------------------------- + +// Toggle renders a compact switch with a label. +func (t *Theme) Toggle(gtx C, b *widget.Bool, label string) D { + return b.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + w, h := gtx.Dp(32), gtx.Dp(18) + track := t.P.SurfaceHi + knobCol := t.P.TextDim + if b.Value { + track = t.P.Accent + knobCol = t.P.AccentFg + } + FillRRect(gtx, image.Pt(w, h), RadiusPill, track) + kd := h - gtx.Dp(4) + kx := gtx.Dp(2) + if b.Value { + kx = w - kd - gtx.Dp(2) + } + off := op.Offset(image.Pt(kx, gtx.Dp(2))).Push(gtx.Ops) + Circle(gtx, kd, knobCol) + off.Pop() + return D{Size: image.Pt(w, h)} + }), + layout.Rigid(func(gtx C) D { + if label == "" { + return D{} + } + return layout.Inset{Left: SpaceSM}.Layout(gtx, t.Secondary(label).Layout) + }), + ) + }) +} + +// --------------------------------------------------------------------------- +// Segmented control (used for level/language/theme pickers) +// --------------------------------------------------------------------------- + +// SegmentOption is one choice in a segmented control. +type SegmentOption struct { + Key string + Label string + // Count, when non-negative, is shown as a trailing tally. + Count int + Level StatusLevel +} + +// Segmented renders a row of mutually exclusive options backed by a +// widget.Enum. +func (t *Theme) Segmented(gtx C, e *widget.Enum, opts []SegmentOption) D { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + FillRRect(gtx, gtx.Constraints.Min, RadiusSM, t.P.BgElevated) + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return layout.UniformInset(3).Layout(gtx, func(gtx C) D { + children := make([]layout.FlexChild, 0, len(opts)) + for _, o := range opts { + children = append(children, layout.Rigid(func(gtx C) D { + return t.segment(gtx, e, o) + })) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) + }) + }), + ) +} + +func (t *Theme) segment(gtx C, e *widget.Enum, o SegmentOption) D { + selected := e.Value == o.Key + fg := t.P.TextSec + if selected { + fg = t.P.TextPri + } + if o.Level != LevelNeutral && selected { + fg = t.StatusColor(o.Level) + } + return e.Layout(gtx, o.Key, func(gtx C) D { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx C) D { + if selected { + FillRRect(gtx, gtx.Constraints.Min, RadiusSM-2, t.P.SurfaceHi) + } + return D{Size: gtx.Constraints.Min} + }), + layout.Stacked(func(gtx C) D { + return layout.Inset{Top: 4, Bottom: 4, Left: SpaceMD, Right: SpaceMD}.Layout(gtx, func(gtx C) D { + label := o.Label + if o.Count >= 0 { + label = o.Label + " " + itoa(o.Count) + } + l := t.Text(SizeCaption, fg, label) + if selected { + l.Font.Weight = font.Medium + } + return l.Layout(gtx) + }) + }), + ) + }) +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +// EmptyState is what a panel shows instead of a blank area. It always says why +// the area is empty, never just "no data". +func (t *Theme) EmptyState(gtx C, icon IconFunc, title, hint string) D { + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.Inset{Top: Space2XL, Bottom: Space2XL}.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if icon == nil { + return D{} + } + return icon(gtx, gtx.Dp(28), WithAlpha(t.P.TextDim, 0.7)) + }), + VGap(SpaceMD), + layout.Rigid(func(gtx C) D { + l := t.Text(SizeBody, t.P.TextSec, title) + l.Alignment = text.Middle + return l.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + if hint == "" { + return D{} + } + return layout.Inset{Top: SpaceXS}.Layout(gtx, func(gtx C) D { + l := t.Caption(hint) + l.Alignment = text.Middle + return l.Layout(gtx) + }) + }), + ) + }) + }) +} + +// --------------------------------------------------------------------------- +// Spinner +// --------------------------------------------------------------------------- + +// Spinner draws an indeterminate arc. It requests the next frame itself, so +// callers just place it. +func (t *Theme) Spinner(gtx C, size int, col color.NRGBA) D { + if size <= 0 { + size = gtx.Dp(20) + } + const period = 1100 * time.Millisecond + phase := float32(gtx.Now.UnixNano()%int64(period)) / float32(period) + + stroke := float32(gtx.Dp(2)) + r := float32(size)/2 - stroke/2 + center := f32.Pt(float32(size)/2, float32(size)/2) + + // Track. + drawArc(gtx, center, r, stroke, 0, 2*math.Pi, WithAlpha(col, 0.15)) + // Sweep: the arc length breathes so the motion reads as progress rather + // than a rotating stick. + sweep := float32(0.25*math.Pi) + float32(1.2*math.Pi)*(0.5+0.5*float32(math.Sin(float64(phase)*2*math.Pi))) + start := phase * 2 * math.Pi * 2 + drawArc(gtx, center, r, stroke, start, sweep, col) + + animate(gtx) + return D{Size: image.Pt(size, size)} +} + +// drawArc strokes an arc of `sweep` radians starting at `start`. +func drawArc(gtx C, center f32.Point, radius, width, start, sweep float32, col color.NRGBA) { + if radius <= 0 || sweep <= 0 { + return + } + var p clip.Path + p.Begin(gtx.Ops) + begin := f32.Pt( + center.X+radius*float32(math.Cos(float64(start))), + center.Y+radius*float32(math.Sin(float64(start))), + ) + p.MoveTo(begin) + // clip.Path.Arc rotates the pen around the focus points; for a circle both + // foci are the centre. + p.Arc(center.Sub(begin), center.Sub(begin), sweep) + paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: width}.Op()) +} + +// ProgressBar draws a determinate bar in [0,1]. +func (t *Theme) ProgressBar(gtx C, progress float32, col color.NRGBA) D { + if progress < 0 { + progress = 0 + } + if progress > 1 { + progress = 1 + } + w := gtx.Constraints.Max.X + h := gtx.Dp(4) + FillRRect(gtx, image.Pt(w, h), RadiusPill, WithAlpha(col, 0.16)) + fw := int(float32(w) * progress) + if fw > 0 { + FillRRect(gtx, image.Pt(fw, h), RadiusPill, col) + } + return D{Size: image.Pt(w, h)} +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// FormatLatency renders a duration the way a network tool should: sub-10ms +// gets one decimal, everything else is a whole number of milliseconds. +func FormatLatency(d time.Duration) string { + if d <= 0 { + return "—" + } + ms := float64(d) / float64(time.Millisecond) + switch { + case ms < 10: + return trimZero(ms, 1) + " ms" + case ms < 1000: + return itoa(int(ms+0.5)) + " ms" + default: + return trimZero(ms/1000, 2) + " s" + } +} + +func trimZero(v float64, prec int) string { + mult := math.Pow(10, float64(prec)) + v = math.Round(v*mult) / mult + s := strconvFormat(v, prec) + if strings.Contains(s, ".") { + s = strings.TrimRight(s, "0") + s = strings.TrimSuffix(s, ".") + } + return s +} + +// strconvFormat avoids importing strconv just for one call site pattern; it +// formats with a fixed number of decimals. +func strconvFormat(v float64, prec int) string { + neg := v < 0 + if neg { + v = -v + } + mult := math.Pow(10, float64(prec)) + scaled := int64(math.Round(v * mult)) + intPart := scaled / int64(mult) + frac := scaled % int64(mult) + s := itoa(int(intPart)) + if prec > 0 { + fs := itoa(int(frac)) + for len(fs) < prec { + fs = "0" + fs + } + s += "." + fs + } + if neg { + s = "-" + s + } + return s +} + +// FormatBytes renders a byte count with binary units. +func FormatBytes(n int64) string { + if n < 0 { + return "—" + } + const unit = 1024 + if n < unit { + return itoa(int(n)) + " B" + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit && exp < 4; v /= unit { + div *= unit + exp++ + } + suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"} + return trimZero(float64(n)/float64(div), 1) + " " + suffixes[exp] +} + +// FormatDuration renders an uptime-style duration. +func FormatDuration(d time.Duration) string { + if d <= 0 { + return "—" + } + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + switch { + case h >= 24: + return itoa(h/24) + "d " + itoa(h%24) + "h" + case h > 0: + return itoa(h) + "h " + itoa(m) + "m" + case m > 0: + return itoa(m) + "m " + itoa(s) + "s" + default: + return itoa(s) + "s" + } +} + +// RelTime renders how long ago t was, localised. +func RelTime(th *Theme, t time.Time, now time.Time) string { + if t.IsZero() { + return th.T(KNever) + } + d := now.Sub(t) + switch { + case d < 0: + return th.T(KJustNow) + case d < 5*time.Second: + return th.T(KJustNow) + case d < time.Minute: + return itoa(int(d.Seconds())) + th.T(KSecondsAgo) + case d < time.Hour: + return itoa(int(d.Minutes())) + th.T(KMinutesAgo) + case d < 24*time.Hour: + return itoa(int(d.Hours())) + th.T(KHoursAgo) + default: + return t.Format("01-02 15:04") + } +} + +// Truncate shortens s to at most n runes, appending an ellipsis. +func Truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n <= 1 { + return "…" + } + return string(r[:n-1]) + "…" +} diff --git a/netdiag/egress.go b/netdiag/egress.go new file mode 100644 index 0000000..34b33cc --- /dev/null +++ b/netdiag/egress.go @@ -0,0 +1,324 @@ +package netdiag + +// This file collects every public address the machine appears to use, from as +// many different exits as possible. +// +// The methods are not redundant. STUN rides raw UDP, so it sees the address a +// peer would see and no HTTP proxy can touch it — that makes it the ground +// truth. The HTTP echo services are queried three ways: forced IPv4 with the +// proxy bypassed, forced IPv6 with the proxy bypassed, and through whatever +// proxy the environment advertises. When those answers disagree, traffic is +// being split across paths, and the address peers will actually connect back +// to is whichever path carries the tunnel — which is exactly the surprise this +// section exists to expose. + +import ( + "context" + "fmt" + "log/slog" + "net/netip" + "sort" + "strings" + "sync" + "time" +) + +const ( + // egTimeout bounds one HTTP echo query. + egTimeout = 5 * time.Second + // egMaxInflight bounds concurrent echo queries. + egMaxInflight = 6 + // egMaxBody caps the echo response read. The services answer with a bare + // IP; anything larger is a portal or an error page. + egMaxBody = 4 << 10 +) + +func egLog(logger *slog.Logger) *slog.Logger { + if logger == nil { + logger = slog.Default() + } + return logger.With(slog.String("from", "netdiag/egress")) +} + +// egTarget is one HTTP echo service, queried over one specific path. +type egTarget struct { + method EgressMethod + url string + region Region + network string // "tcp4", "tcp6" or "" for unforced + useProxy bool +} + +// egTargets lists the echo services. All of them return a bare IP address in +// the body. The CN-hosted ones (ipw.cn) are kept because they stay reachable +// when the international ones are not, and their answer is what a domestic +// peer would see. +func egTargets() []egTarget { + return []egTarget{ + // Forced IPv4, proxy explicitly bypassed. + {method: MethodHTTPv4, url: "https://api.ipify.org", region: RegionIntl, network: "tcp4"}, + {method: MethodHTTPv4, url: "https://icanhazip.com", region: RegionIntl, network: "tcp4"}, + {method: MethodHTTPv4, url: "https://4.ipw.cn", region: RegionCN, network: "tcp4"}, + {method: MethodHTTPv4, url: "https://ipinfo.io/ip", region: RegionIntl, network: "tcp4"}, + + // Forced IPv6, proxy explicitly bypassed. + {method: MethodHTTPv6, url: "https://api6.ipify.org", region: RegionIntl, network: "tcp6"}, + {method: MethodHTTPv6, url: "https://6.ipw.cn", region: RegionCN, network: "tcp6"}, + + // Unforced network, honouring HTTP(S)_PROXY. + {method: MethodHTTPProxy, url: "https://api.ipify.org", region: RegionIntl, useProxy: true}, + {method: MethodHTTPProxy, url: "https://4.ipw.cn", region: RegionCN, useProxy: true}, + } +} + +// ProbeEgress reports every public address this machine appears to use. +// +// stunResults are the already-collected STUN observations; STUN is not re-run +// here. Successful ones become [MethodSTUN] observations and serve as the +// proxy-immune reference the HTTP answers are compared against. +// +// The HTTP echo services are queried concurrently with a ~5s budget each. +// Geo and Countries are deliberately left empty; [AnnotateGeo] fills them so +// the caller can skip the third-party lookups entirely. +func ProbeEgress(ctx context.Context, stunResults []STUNResult, logger *slog.Logger) EgressReport { + log := egLog(logger) + + var ( + mu sync.Mutex + obs []EgressObservation + wg sync.WaitGroup + sem = make(chan struct{}, egMaxInflight) + tgts = egTargets() + ) + + for _, r := range stunResults { + if !r.OK { + continue + } + ip := r.Mapped.Addr().Unmap().WithZone("") + if !ip.IsValid() { + continue + } + obs = append(obs, EgressObservation{ + Method: MethodSTUN, + Source: r.Server, + Region: r.Region, + IP: ip, + RTT: r.RTT, + }) + } + + for _, t := range tgts { + wg.Add(1) + go func(t egTarget) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + o := egQuery(ctx, t, log) + mu.Lock() + obs = append(obs, o) + mu.Unlock() + }(t) + } + wg.Wait() + + rep := EgressReport{Observations: obs} + egSortObservations(rep.Observations) + rep.UniqueIPs = egUniqueIPs(rep.Observations) + rep.Divergent = egDivergent(rep.UniqueIPs) + egFinish(&rep) + + log.With( + slog.Int("observations", len(rep.Observations)), + slog.Int("unique_ips", len(rep.UniqueIPs)), + slog.Bool("divergent", rep.Divergent), + slog.String("status", rep.Status.String()), + ).Debug("finished egress probes") + + return rep +} + +// egQuery asks one echo service for our address. Failures are recorded in the +// observation's Err field rather than returned, so a dead service still shows +// up as a row instead of vanishing. +func egQuery(ctx context.Context, t egTarget, log *slog.Logger) EgressObservation { + o := EgressObservation{ + Method: t.method, + Source: t.url, + Region: t.region, + } + + // Label the row by the path actually taken. Reporting a direct request as + // MethodHTTPProxy would make the egress table claim a proxy was exercised + // when none is configured. + usedProxy := t.useProxy && diagProxyConfigured(t.url) + if t.useProxy && !usedProxy { + o.Source = t.url + "(未配置代理,实际直连)" + } + + qctx, cancel := context.WithTimeout(ctx, egTimeout) + defer cancel() + + client := newDiagClient(t.network, usedProxy, egTimeout) + defer client.CloseIdleConnections() + + code, body, rtt, err := diagGet(qctx, client, t.url, egMaxBody, nil) + o.RTT = rtt + switch { + case err != nil: + o.Err = rchErrText(err) + case code < 200 || code > 299: + o.Err = fmt.Sprintf("unexpected status %d", code) + default: + text := strings.TrimSpace(string(body)) + ip, perr := netip.ParseAddr(text) + if perr != nil { + o.Err = fmt.Sprintf("unparseable response %q", egEllipsis(text, 48)) + break + } + o.IP = ip.Unmap().WithZone("") + } + + log.With( + slog.String("method", string(t.method)), + slog.String("source", t.url), + slog.String("ip", o.IP.String()), + slog.Duration("rtt", o.RTT), + slog.String("error", o.Err), + ).Debug("egress echo query done") + + return o +} + +// egEllipsis truncates s for safe inclusion in an error string, so a hijacked +// response cannot dump a whole HTML page into the UI. +func egEllipsis(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// egSortObservations orders by method, then source, then address, so the table +// does not jitter between refreshes. +func egSortObservations(os []EgressObservation) { + sort.Slice(os, func(i, j int) bool { + x, y := os[i], os[j] + if x.Method != y.Method { + return x.Method < y.Method + } + if x.Source != y.Source { + return x.Source < y.Source + } + return x.IP.Compare(y.IP) < 0 + }) +} + +// 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 + for _, o := range os { + if !o.IP.IsValid() { + continue + } + if _, dup := seen[o.IP]; dup { + continue + } + seen[o.IP] = struct{}{} + out = append(out, o.IP) + } + sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 }) + return out +} + +// egSplitFamilies partitions addresses into IPv4 and IPv6 sets. +func egSplitFamilies(ips []netip.Addr) (v4, v6 []netip.Addr) { + for _, ip := range ips { + if ip.Is4() || ip.Is4In6() { + v4 = append(v4, ip) + } else { + v6 = append(v6, ip) + } + } + return v4, v6 +} + +// egDivergent reports whether the probes disagreed about our public address +// *within* an address family. +// +// A plain dual-stack host answers with one IPv4 and one IPv6 address, which is +// two distinct entries in UniqueIPs and entirely healthy. Treating that as +// disagreement would flag every dual-stack machine as proxied and bury the +// real signal — two different IPv4 addresses — in the noise. +func egDivergent(ips []netip.Addr) bool { + v4, v6 := egSplitFamilies(ips) + return len(v4) > 1 || len(v6) > 1 +} + +// 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. +func egFinish(rep *EgressReport) { + v4, v6 := egSplitFamilies(rep.UniqueIPs) + + switch { + case len(rep.UniqueIPs) == 0: + rep.Status = StatusFail + case rep.Divergent: + rep.Status = StatusWarn + default: + rep.Status = StatusOK + } + + var b strings.Builder + switch { + case len(rep.UniqueIPs) == 0: + b.WriteString("未能取得任何出口 IP:所有探测都失败了") + + case rep.Divergent: + // Name the family that actually diverged, so a dual-stack host with a + // split IPv4 path does not read as "everything is inconsistent". + var parts []string + if len(v4) > 1 { + parts = append(parts, fmt.Sprintf("IPv4 有 %d 个(%s)", len(v4), egJoinAddrs(v4, 4))) + } + if len(v6) > 1 { + parts = append(parts, fmt.Sprintf("IPv6 有 %d 个(%s)", len(v6), egJoinAddrs(v6, 4))) + } + fmt.Fprintf(&b, "出口 IP 不一致:%s,代理、VPN 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路", + strings.Join(parts, ";")) + + default: + var parts []string + if len(v4) == 1 { + parts = append(parts, "IPv4 "+v4[0].String()) + } + if len(v6) == 1 { + parts = append(parts, "IPv6 "+v6[0].String()) + } + fmt.Fprintf(&b, "出口 IP 唯一:%s", strings.Join(parts, ",")) + } + if len(rep.Countries) > 0 { + fmt.Fprintf(&b, ",归属地 %s", strings.Join(rep.Countries, "、")) + } + rep.Summary = b.String() +} + +// egJoinAddrs renders at most limit addresses for a summary line. +func egJoinAddrs(as []netip.Addr, limit int) string { + parts := make([]string, 0, limit+1) + for i, a := range as { + if i >= limit { + parts = append(parts, fmt.Sprintf("等 %d 个", len(as))) + break + } + parts = append(parts, a.String()) + } + return strings.Join(parts, "、") +} diff --git a/netdiag/geo.go b/netdiag/geo.go new file mode 100644 index 0000000..b587b4b --- /dev/null +++ b/netdiag/geo.go @@ -0,0 +1,470 @@ +package netdiag + +// This file resolves public IP addresses to a rough location and network +// operator. +// +// PRIVACY: every lookup here sends the user's own public IP address to a +// third-party API (ipinfo.io, ip-api.com, api.ip.sb). Those services see the +// address, the timestamp and our source IP — which for a direct query is that +// very same address. Nothing else is sent: no hostname, no tailnet identity, +// no credentials. Callers who are not comfortable with that must set +// [Options.SkipGeo], which exists precisely for this reason, and no request in +// this file will be made. The ipinfo token, when configured, is passed as a +// query parameter to that provider only and is never logged or stored in a +// report. +// +// Providers are tried in order and the first usable answer wins. The order is +// not arbitrary: ipinfo.io is the most accurate but rate-limits hard without a +// token, ip-api.com is HTTP-only on the free tier yet stays reachable from +// mainland China, and api.ip.sb is the last resort. + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/netip" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // geoTimeout bounds one provider query. + geoTimeout = 4 * time.Second + // geoMaxBody caps a provider response. Well-behaved answers are a few + // hundred bytes; the cap guards against a hijacked or error page. + geoMaxBody = 64 << 10 + // geoMaxInflight bounds concurrent lookups in [AnnotateGeo]. Kept low + // because the free tiers of these APIs rate-limit per source IP. + geoMaxInflight = 4 +) + +// geoCGNAT is RFC 6598 shared address space. Tailscale also allocates node +// addresses out of it, and either way no geolocation provider can say anything +// useful about such an address. +var geoCGNAT = netip.MustParsePrefix("100.64.0.0/10") + +// geoSkipErr is reported for addresses that are not globally routable. +const geoSkipErr = "私有地址,跳过查询" + +func geoLog(logger *slog.Logger) *slog.Logger { + if logger == nil { + logger = slog.Default() + } + return logger.With(slog.String("from", "netdiag/geo")) +} + +// geoSkippable reports whether ip is not worth (or not safe to) look up: +// loopback, RFC1918/ULA private, link-local, CGNAT, multicast or unspecified. +func geoSkippable(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsValid() { + return true + } + if ip.Is4() && geoCGNAT.Contains(ip) { + return true + } + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsMulticast() || + ip.IsUnspecified() +} + +// geoProvider is one geolocation backend. +type geoProvider struct { + name string + // fetch fills a GeoInfo from the provider, or returns an error so the next + // provider is tried. + fetch func(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error) +} + +// geoProviders returns the backends in the order they are tried. +func geoProviders() []geoProvider { + return []geoProvider{ + {name: "ipinfo.io", fetch: geoFetchIPInfo}, + {name: "ip-api.com", fetch: geoFetchIPAPI}, + {name: "ip.sb", fetch: geoFetchIPSB}, + } +} + +// LookupGeo resolves one address to a location and operator. +// +// Providers are tried in order until one answers; the returned GeoInfo names +// the provider that did in its Provider field. When all of them fail, Provider +// is empty and Err holds the last error. Addresses that are not globally +// routable are never sent anywhere: they come back immediately with Err set to +// "私有地址,跳过查询". +// +// token is an optional ipinfo.io API token; it raises that provider's rate +// limit and is never logged. +// +// Each provider gets its own ~4s budget, so the whole call is bounded even if +// every backend hangs. See the privacy note at the top of this file. +func LookupGeo(ctx context.Context, ip netip.Addr, token string, logger *slog.Logger) GeoInfo { + log := geoLog(logger) + ip = ip.Unmap().WithZone("") + + if geoSkippable(ip) { + return GeoInfo{IP: ip, Err: geoSkipErr} + } + + var lastErr string + for _, p := range geoProviders() { + if ctx.Err() != nil { + return GeoInfo{IP: ip, Err: rchErrText(ctx.Err())} + } + pctx, cancel := context.WithTimeout(ctx, geoTimeout) + info, err := p.fetch(pctx, ip, token) + cancel() + if err != nil { + lastErr = fmt.Sprintf("%s: %s", p.name, rchErrText(err)) + log.With( + slog.String("ip", ip.String()), + slog.String("provider", p.name), + slog.String("error", rchErrText(err)), + ).Debug("geo provider failed") + continue + } + info.IP = ip + info.Provider = p.name + log.With( + slog.String("ip", ip.String()), + slog.String("provider", p.name), + slog.String("country", info.Country), + slog.String("asn", info.ASN), + ).Debug("resolved ip location") + return info + } + + if lastErr == "" { + lastErr = "no geolocation provider answered" + } + return GeoInfo{IP: ip, Err: lastErr} +} + +// AnnotateGeo fills rep.Geo and rep.Countries for every address in +// rep.UniqueIPs and recomputes rep.Summary. It is a no-op when the report has +// no addresses. +// +// Lookups run concurrently but at most [geoMaxInflight] at a time, since the +// free tiers rate-limit per source IP. Results are sorted by address and the +// country list is deduplicated, so repeated runs render identically. +// +// This function performs third-party network requests; see the privacy note at +// the top of this file and [Options.SkipGeo]. +func AnnotateGeo(ctx context.Context, rep *EgressReport, token string, logger *slog.Logger) { + if rep == nil || len(rep.UniqueIPs) == 0 { + return + } + log := geoLog(logger) + + var ( + mu sync.Mutex + out = make([]GeoInfo, 0, len(rep.UniqueIPs)) + wg sync.WaitGroup + sem = make(chan struct{}, geoMaxInflight) + ) + + for _, ip := range rep.UniqueIPs { + wg.Add(1) + go func(ip netip.Addr) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + mu.Lock() + out = append(out, GeoInfo{IP: ip, Err: rchErrText(ctx.Err())}) + mu.Unlock() + return + } + info := LookupGeo(ctx, ip, token, log) + mu.Lock() + out = append(out, info) + mu.Unlock() + }(ip) + } + wg.Wait() + + sort.Slice(out, func(i, j int) bool { return out[i].IP.Compare(out[j].IP) < 0 }) + rep.Geo = out + rep.Countries = geoCountries(out) + egFinish(rep) + + log.With( + slog.Int("addrs", len(rep.Geo)), + slog.String("countries", strings.Join(rep.Countries, ",")), + ).Debug("annotated egress addresses with geolocation") +} + +// geoCountries returns the sorted, deduplicated set of countries seen, +// preferring the ISO code and falling back to the localised name when a +// provider only supplied that. +func geoCountries(gs []GeoInfo) []string { + seen := make(map[string]struct{}, len(gs)) + var out []string + for _, g := range gs { + name := strings.TrimSpace(g.Country) + if name == "" { + name = strings.TrimSpace(g.CountryName) + } + if name == "" { + continue + } + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + sort.Strings(out) + return out +} + +// --------------------------------------------------------------------------- +// Provider implementations +// --------------------------------------------------------------------------- + +// geoGetJSON fetches url and decodes the body into v. The client uses the +// unforced network and honours the environment proxy: unlike the egress +// probes, we do not care which path the query takes, only that it succeeds. +func geoGetJSON(ctx context.Context, target string, v any) error { + client := newDiagClient("", true, geoTimeout) + defer client.CloseIdleConnections() + + hdr := http.Header{} + hdr.Set("Accept", "application/json") + + code, body, _, err := diagGet(ctx, client, target, geoMaxBody, hdr) + if err != nil { + return err + } + if code < 200 || code > 299 { + return fmt.Errorf("unexpected status %d", code) + } + if err := json.Unmarshal(body, v); err != nil { + return fmt.Errorf("bad json: %w", err) + } + return nil +} + +// geoIPInfoResp is the subset of ipinfo.io's answer we use. +type geoIPInfoResp struct { + IP string `json:"ip"` + City string `json:"city"` + Region string `json:"region"` + Country string `json:"country"` + CountryName string `json:"country_name"` // only on paid plans + Loc string `json:"loc"` + Org string `json:"org"` + Timezone string `json:"timezone"` + Bogon bool `json:"bogon"` +} + +// geoFetchIPInfo queries ipinfo.io. The token, when non-empty, only raises the +// rate limit; it is appended as a query parameter and never logged. +func geoFetchIPInfo(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error) { + target := "https://ipinfo.io/" + url.PathEscape(ip.String()) + "/json" + if token != "" { + target += "?token=" + url.QueryEscape(token) + } + + var r geoIPInfoResp + if err := geoGetJSON(ctx, target, &r); err != nil { + return GeoInfo{}, err + } + if r.Bogon { + return GeoInfo{}, fmt.Errorf("provider reports bogon address") + } + if r.Country == "" && r.Org == "" && r.City == "" { + return GeoInfo{}, fmt.Errorf("empty answer") + } + + asn, org := geoSplitOrg(r.Org) + return GeoInfo{ + Country: strings.TrimSpace(r.Country), + CountryName: strings.TrimSpace(r.CountryName), + Region: strings.TrimSpace(r.Region), + City: strings.TrimSpace(r.City), + Org: org, + ASN: asn, + Loc: strings.TrimSpace(r.Loc), + Timezone: strings.TrimSpace(r.Timezone), + }, nil +} + +// geoIPAPIResp is ip-api.com's answer for the field set we request. +type geoIPAPIResp struct { + Status string `json:"status"` + Message string `json:"message"` + Country string `json:"country"` + CountryCode string `json:"countryCode"` + RegionName string `json:"regionName"` + City string `json:"city"` + ISP string `json:"isp"` + Org string `json:"org"` + AS string `json:"as"` + Timezone string `json:"timezone"` +} + +// geoFetchIPAPI queries ip-api.com. The free tier is HTTP-only, which is also +// why it keeps working from mainland China where the HTTPS providers often do +// not. Answers are requested in Chinese to match the rest of the UI. +func geoFetchIPAPI(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) { + target := "http://ip-api.com/json/" + url.PathEscape(ip.String()) + + "?lang=zh-CN&fields=status,message,country,countryCode,regionName,city,isp,org,as,timezone" + + var r geoIPAPIResp + if err := geoGetJSON(ctx, target, &r); err != nil { + return GeoInfo{}, err + } + if !strings.EqualFold(r.Status, "success") { + msg := strings.TrimSpace(r.Message) + if msg == "" { + msg = r.Status + } + return GeoInfo{}, fmt.Errorf("query failed: %s", msg) + } + + asn, asOrg := geoSplitOrg(r.AS) + org := strings.TrimSpace(r.Org) + if org == "" { + org = strings.TrimSpace(r.ISP) + } + if org == "" { + org = asOrg + } + return GeoInfo{ + Country: strings.TrimSpace(r.CountryCode), + CountryName: strings.TrimSpace(r.Country), + Region: strings.TrimSpace(r.RegionName), + City: strings.TrimSpace(r.City), + Org: org, + ASN: asn, + Timezone: strings.TrimSpace(r.Timezone), + }, nil +} + +// geoIPSBResp is api.ip.sb's answer. ASN comes back as a bare number, so it is +// decoded loosely and normalised by [geoASNText]. +type geoIPSBResp struct { + Country string `json:"country"` + CountryCode string `json:"country_code"` + Region string `json:"region"` + City string `json:"city"` + ISP string `json:"isp"` + ASN any `json:"asn"` + ASNOrg string `json:"asn_organization"` + Timezone string `json:"timezone"` + Latitude any `json:"latitude"` + Longitude any `json:"longitude"` +} + +// geoFetchIPSB queries api.ip.sb, the last-resort provider. +func geoFetchIPSB(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) { + target := "https://api.ip.sb/geoip/" + url.PathEscape(ip.String()) + + var r geoIPSBResp + if err := geoGetJSON(ctx, target, &r); err != nil { + return GeoInfo{}, err + } + if r.CountryCode == "" && r.Country == "" && r.ISP == "" { + return GeoInfo{}, fmt.Errorf("empty answer") + } + + org := strings.TrimSpace(r.ISP) + if org == "" { + org = strings.TrimSpace(r.ASNOrg) + } + return GeoInfo{ + Country: strings.TrimSpace(r.CountryCode), + CountryName: strings.TrimSpace(r.Country), + Region: strings.TrimSpace(r.Region), + City: strings.TrimSpace(r.City), + Org: org, + ASN: geoASNText(r.ASN), + Loc: geoLocText(r.Latitude, r.Longitude), + Timezone: strings.TrimSpace(r.Timezone), + }, nil +} + +// geoSplitOrg splits an "AS4134 Chinanet" style string into the ASN and the +// operator name. Either half may be missing, in which case the whole string is +// treated as the operator name. +func geoSplitOrg(s string) (asn, org string) { + s = strings.TrimSpace(s) + if s == "" { + return "", "" + } + head, rest, _ := strings.Cut(s, " ") + if geoLooksLikeASN(head) { + return head, strings.TrimSpace(rest) + } + return "", s +} + +// geoLooksLikeASN reports whether s is an "AS####" token. +func geoLooksLikeASN(s string) bool { + if len(s) < 3 || !strings.EqualFold(s[:2], "AS") { + return false + } + _, err := strconv.ParseUint(s[2:], 10, 32) + return err == nil +} + +// geoASNText normalises a JSON asn field (number or string) to "AS####". +func geoASNText(v any) string { + var s string + switch n := v.(type) { + case nil: + return "" + case float64: + if n <= 0 { + return "" + } + s = strconv.FormatFloat(n, 'f', -1, 64) + case string: + s = strings.TrimSpace(n) + default: + return "" + } + if s == "" || s == "0" { + return "" + } + if geoLooksLikeASN(s) { + return strings.ToUpper(s[:2]) + s[2:] + } + if _, err := strconv.ParseUint(s, 10, 32); err != nil { + return "" + } + return "AS" + s +} + +// geoLocText renders a latitude/longitude pair in ipinfo's "lat,lon" form so +// the Loc field means the same thing whichever provider answered. +func geoLocText(lat, lon any) string { + f := func(v any) (string, bool) { + switch n := v.(type) { + case float64: + return strconv.FormatFloat(n, 'f', -1, 64), true + case string: + s := strings.TrimSpace(n) + return s, s != "" + default: + return "", false + } + } + a, okA := f(lat) + b, okB := f(lon) + if !okA || !okB { + return "" + } + return a + "," + b +} diff --git a/netdiag/iface.go b/netdiag/iface.go new file mode 100644 index 0000000..40c5072 --- /dev/null +++ b/netdiag/iface.go @@ -0,0 +1,381 @@ +package netdiag + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/netip" + "sort" + "strings" + "sync" + "time" +) + +// ifDialTimeout bounds each source-address discovery dial. The dial is to a +// UDP address, so no packet leaves the machine and the kernel answers from its +// routing table immediately; the timeout only guards against a pathological +// resolver or a wedged network stack. +const ifDialTimeout = 2 * time.Second + +// ifMaxInflight bounds how many interfaces are inspected concurrently. +const ifMaxInflight = 8 + +// ifDefaultV4Target and ifDefaultV6Target are well-known anycast resolvers used +// purely as "somewhere on the default route" destinations. +const ( + ifDefaultV4Target = "8.8.8.8:80" + ifDefaultV6Target = "[2001:4860:4860::8888]:80" +) + +// tailscaleV6Prefix is the ULA range Tailscale assigns to every node. +var tailscaleV6Prefix = netip.MustParsePrefix("fd7a:115c:a1e0::/48") + +// cgnatPrefix is RFC 6598 shared address space. Tailscale allocates its IPv4 +// node addresses out of 100.64.0.0/10 as well, which is why an address here +// needs the interface name to be classified precisely; see [classifyOnIface]. +var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") + +var ( + ulaPrefix = netip.MustParsePrefix("fc00::/7") + linkLocalV4Pfx = netip.MustParsePrefix("169.254.0.0/16") + rfc1918Prefixes = []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + } +) + +// ifTailscaleIfaceNames are the interface-name prefixes Tailscale (and the +// wireguard/utun devices it rides on) uses across platforms. +var ifTailscaleIfaceNames = []string{"tailscale", "ts", "utun", "wg"} + +func ifLog(logger *slog.Logger) *slog.Logger { + if logger == nil { + logger = slog.Default() + } + return logger.With(slog.String("from", "netdiag/iface")) +} + +// ClassifyAddr buckets an address by reachable scope. +// +// Addresses in 100.64.0.0/10 are reported as [AddrCGNAT] because the range +// alone cannot distinguish a carrier-grade NAT lease from a Tailscale node +// address. [EnumerateInterfaces] refines that verdict using the interface name. +func ClassifyAddr(a netip.Addr) AddrKind { + a = a.Unmap() + switch { + case !a.IsValid(): + return AddrLinkLocal // degenerate input; never reachable + case a.IsLoopback(): + return AddrLoopback + case a.Is4() && cgnatPrefix.Contains(a): + return AddrCGNAT + case a.Is6() && tailscaleV6Prefix.Contains(a): + return AddrTailscale + case a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || (a.Is4() && linkLocalV4Pfx.Contains(a)): + return AddrLinkLocal + case a.Is4() && isRFC1918(a): + return AddrPrivateV4 + case a.Is6() && ulaPrefix.Contains(a): + return AddrULA + case a.Is4(): + return AddrGlobalV4 + default: + return AddrGlobalV6 + } +} + +func isRFC1918(a netip.Addr) bool { + for _, p := range rfc1918Prefixes { + if p.Contains(a) { + return true + } + } + return false +} + +// classifyOnIface applies [ClassifyAddr] and then corrects the one case the +// address alone cannot decide: Tailscale hands out IPv4 addresses from the +// CGNAT range 100.64.0.0/10, so a 100.x address sitting on an interface named +// tailscale*/ts*/utun*/wg* is a tailnet address rather than a carrier NAT +// lease. The heuristic is name-based because the alternative (asking tailscaled) +// would make this package depend on tailscale.com. +func classifyOnIface(a netip.Addr, iface string) AddrKind { + kind := ClassifyAddr(a) + if kind == AddrCGNAT && isTailscaleIfaceName(iface) { + return AddrTailscale + } + return kind +} + +func isTailscaleIfaceName(name string) bool { + n := strings.ToLower(name) + for _, p := range ifTailscaleIfaceNames { + if strings.HasPrefix(n, p) { + return true + } + } + return false +} + +// EnumerateInterfaces lists every address bound to every local interface and +// determines which source addresses the kernel would use for default routes. +func EnumerateInterfaces(ctx context.Context, logger *slog.Logger) InterfaceReport { + log := ifLog(logger) + var rep InterfaceReport + + ifaces, err := net.Interfaces() + if err != nil { + log.With(slog.String("error", err.Error())).Error("failed to enumerate interfaces") + rep.Err = err.Error() + rep.Status = StatusFail + rep.Summary = "无法枚举本机网络接口" + return rep + } + + var ( + mu sync.Mutex + addrs []LocalAddr + nIface int + ) + + sem := make(chan struct{}, ifMaxInflight) + var wg sync.WaitGroup + + // Source discovery is independent of enumeration, so run both in parallel. + var v4Src, v6Src netip.Addr + wg.Add(2) + go func() { + defer wg.Done() + v4Src = defaultSource(ctx, "udp4", ifDefaultV4Target, log) + }() + go func() { + defer wg.Done() + v6Src = defaultSource(ctx, "udp6", ifDefaultV6Target, log) + }() + + for _, iface := range ifaces { + if ctx.Err() != nil { + break + } + wg.Add(1) + go func(iface net.Interface) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + got := ifaceAddrs(iface, log) + mu.Lock() + if len(got) > 0 { + nIface++ + } + addrs = append(addrs, got...) + mu.Unlock() + }(iface) + } + wg.Wait() + + rep.Addrs = addrs + rep.DefaultV4Src = v4Src + rep.DefaultV6Src = v6Src + + sortLocalAddrs(rep.Addrs) + + hasGlobalV6Addr := false + for i := range rep.Addrs { + a := &rep.Addrs[i] + if a.Kind == AddrGlobalV6 { + hasGlobalV6Addr = true + } + if (v4Src.IsValid() && a.Addr == v4Src) || (v6Src.IsValid() && a.Addr == v6Src) { + a.IsDefaultSrc = true + } + } + rep.HasGlobalV6 = hasGlobalV6Addr && v6Src.IsValid() && ClassifyAddr(v6Src) == AddrGlobalV6 + + finishInterfaceReport(&rep, nIface) + + log.With( + slog.Int("interfaces", nIface), + slog.Int("addrs", len(rep.Addrs)), + slog.String("v4_src", addrText(rep.DefaultV4Src)), + slog.String("v6_src", addrText(rep.DefaultV6Src)), + slog.String("status", rep.Status.String()), + ).Debug("enumerated local interfaces") + + return rep +} + +// ifaceAddrs converts one interface's bound addresses into [LocalAddr] entries. +// Errors are logged and swallowed: one unreadable interface must not blank the +// whole panel. +func ifaceAddrs(iface net.Interface, log *slog.Logger) []LocalAddr { + raw, err := iface.Addrs() + if err != nil { + log.With( + slog.String("iface", iface.Name), + slog.String("error", err.Error()), + ).Debug("failed to read interface addresses") + return nil + } + + hw := "" + if len(iface.HardwareAddr) > 0 { + hw = strings.ToLower(iface.HardwareAddr.String()) + } + up := iface.Flags&net.FlagUp != 0 + + out := make([]LocalAddr, 0, len(raw)) + for _, a := range raw { + pfx, ok := toPrefix(a) + if !ok { + continue + } + addr := pfx.Addr().Unmap() + // Keep the zone off the reported address so equality against the + // default-source lookup and the sort order stay stable. + addr = addr.WithZone("") + out = append(out, LocalAddr{ + Iface: iface.Name, + Addr: addr, + Prefix: netip.PrefixFrom(addr, pfx.Bits()), + Kind: classifyOnIface(addr, iface.Name), + Up: up, + MTU: iface.MTU, + Hardware: hw, + }) + } + return out +} + +// toPrefix normalises the net.Addr values iface.Addrs returns (*net.IPNet on +// every supported platform, *net.IPAddr on a few). +func toPrefix(a net.Addr) (netip.Prefix, bool) { + switch v := a.(type) { + case *net.IPNet: + addr, ok := netip.AddrFromSlice(v.IP) + if !ok { + return netip.Prefix{}, false + } + addr = addr.Unmap() + ones, _ := v.Mask.Size() + if ones <= 0 || ones > addr.BitLen() { + ones = addr.BitLen() + } + return netip.PrefixFrom(addr, ones), true + case *net.IPAddr: + addr, ok := netip.AddrFromSlice(v.IP) + if !ok { + return netip.Prefix{}, false + } + addr = addr.Unmap() + return netip.PrefixFrom(addr, addr.BitLen()), true + default: + addr, err := netip.ParsePrefix(a.String()) + if err != nil { + return netip.Prefix{}, false + } + return addr, true + } +} + +// defaultSource asks the kernel which local address it would use to reach a +// destination on the default route. Dialling a UDP address only installs a +// route lookup on the socket; nothing is transmitted. Failure is expected and +// normal (notably for udp6 on IPv4-only hosts) and never populates Err. +func defaultSource(ctx context.Context, network, target string, log *slog.Logger) netip.Addr { + dctx, cancel := context.WithTimeout(ctx, ifDialTimeout) + defer cancel() + + var d net.Dialer + conn, err := d.DialContext(dctx, network, target) + if err != nil { + log.With( + slog.String("network", network), + slog.String("error", err.Error()), + ).Debug("no default source address") + return netip.Addr{} + } + defer conn.Close() + + ua, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return netip.Addr{} + } + addr, ok := netip.AddrFromSlice(ua.IP) + if !ok { + return netip.Addr{} + } + return addr.Unmap().WithZone("") +} + +// sortLocalAddrs orders entries by interface name, then IPv4 before IPv6, then +// by address, so repeated refreshes render identically. +func sortLocalAddrs(as []LocalAddr) { + sort.Slice(as, func(i, j int) bool { + x, y := as[i], as[j] + if x.Iface != y.Iface { + return x.Iface < y.Iface + } + if x.Addr.Is4() != y.Addr.Is4() { + return x.Addr.Is4() + } + return x.Addr.Compare(y.Addr) < 0 + }) +} + +// finishInterfaceReport derives Status and Summary from the collected data. +func finishInterfaceReport(rep *InterfaceReport, nIface int) { + if len(rep.Addrs) == 0 { + rep.Status = StatusFail + if rep.Err == "" { + rep.Err = "no local addresses found" + } + rep.Summary = "未发现任何本机地址" + return + } + + // A private v4 address still routes out through NAT, but only if the kernel + // actually picked a default source for it. + var globalCapable bool + for _, a := range rep.Addrs { + switch a.Kind { + case AddrGlobalV4, AddrGlobalV6, AddrCGNAT, AddrTailscale: + globalCapable = true + case AddrPrivateV4: + globalCapable = globalCapable || rep.DefaultV4Src.IsValid() + } + } + if globalCapable { + rep.Status = StatusOK + } else { + rep.Status = StatusWarn + } + + var b strings.Builder + fmt.Fprintf(&b, "%d 个接口 / %d 个地址", nIface, len(rep.Addrs)) + if rep.DefaultV4Src.IsValid() { + fmt.Fprintf(&b, ",IPv4 出口 %s", rep.DefaultV4Src) + } else { + b.WriteString(",无 IPv4 出口") + } + if rep.DefaultV6Src.IsValid() { + fmt.Fprintf(&b, ",IPv6 出口 %s", rep.DefaultV6Src) + } else { + b.WriteString(",无 IPv6 出口") + } + rep.Summary = b.String() +} + +// addrText renders an address for logging, using "-" for the invalid zero +// value so log lines stay readable. +func addrText(a netip.Addr) string { + if !a.IsValid() { + return "-" + } + return a.String() +} diff --git a/netdiag/paste.go b/netdiag/paste.go new file mode 100644 index 0000000..1fe2d27 --- /dev/null +++ b/netdiag/paste.go @@ -0,0 +1,389 @@ +package netdiag + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// pasteUserAgent identifies tslink to the paste services. 0x0.st rejects the +// Go default user agent with 403, so this is not merely cosmetic. +const pasteUserAgent = "tslink/1.0 (+diagnostics)" + +// pasteTimeout bounds a single upload attempt, including connect, write and +// the read of the response body. +const pasteTimeout = 20 * time.Second + +// MaxPasteBytes is the largest payload accepted by [Upload]. Bigger dumps are +// rejected rather than truncated: the tail of a log is usually the part the +// helper needs, and silently dropping it wastes everyone's time. +const MaxPasteBytes = 1 << 20 + +// pasteReadLimit caps how much of a response body is read back. A URL is a +// couple hundred bytes; anything larger is an error page. +const pasteReadLimit = 64 << 10 + +// PasteTarget is one supported paste service. +type PasteTarget struct { + Key string // stable id used by the UI + Name string // human label + Note string // short caveat: retention, region reachability +} + +// pasteService is a target plus the code that performs the upload. +type pasteService struct { + PasteTarget + upload func(ctx context.Context, text string) (string, error) +} + +// pasteServices is the internal registry, in preference order. +var pasteServices = []pasteService{ + { + PasteTarget: PasteTarget{ + Key: "0x0", + Name: "0x0.st", + Note: "保留 30 天以上(按大小递减),境内访问可能较慢", + }, + upload: uploadNullPointer, + }, + { + PasteTarget: PasteTarget{ + Key: "paste_rs", + Name: "paste.rs", + Note: "无固定保留期,容量满后自动淘汰旧内容", + }, + upload: uploadPasteRS, + }, + { + PasteTarget: PasteTarget{ + Key: "dpaste", + Name: "dpaste.org", + Note: "保留 7 天后自动删除", + }, + upload: uploadDpaste, + }, + { + PasteTarget: PasteTarget{ + Key: "termbin", + Name: "termbin.com", + Note: "纯 TCP (9999),HTTPS 被墙时仍可用;保留约 1 个月", + }, + upload: uploadTermbin, + }, +} + +// PasteTargets lists the supported services in preference order. +func PasteTargets() []PasteTarget { + out := make([]PasteTarget, 0, len(pasteServices)) + for _, s := range pasteServices { + out = append(out, s.PasteTarget) + } + return out +} + +// PasteResult is a successful upload. +type PasteResult struct { + URL string + Target string + Bytes int + Uploaded time.Time +} + +// ErrPasteEmpty is returned when there is nothing to upload. +var ErrPasteEmpty = errors.New("netdiag: refusing to upload empty text") + +// ErrPasteTooLarge is returned when the payload exceeds [MaxPasteBytes]. +var ErrPasteTooLarge = fmt.Errorf("netdiag: text exceeds the %d byte paste limit", MaxPasteBytes) + +// Upload sends text to the named target and returns the resulting public URL. +// An empty targetKey tries every target in [PasteTargets] order and returns the +// first success; when all of them fail the returned error names each failure. +// +// The caller MUST redact the text before calling: uploading is an outbound +// publication of user data to a third party. core.LogBuffer.ExportText performs +// that redaction (leave ExportOptions.NoRedact false). The resulting paste is +// PUBLIC — anyone holding the URL can read it, and most of these services offer +// no way to delete it afterwards. +// +// Every attempt is bounded by a ~20s timeout and honours ctx. +func Upload(ctx context.Context, targetKey, text string, logger *slog.Logger) (*PasteResult, error) { + if logger == nil { + logger = slog.Default() + } + logger = logger.With(slog.String("from", "paste")) + + if strings.TrimSpace(text) == "" { + return nil, ErrPasteEmpty + } + if len(text) > MaxPasteBytes { + return nil, fmt.Errorf("%w (got %d bytes); filter the log before sharing", ErrPasteTooLarge, len(text)) + } + + candidates := pasteServices + if targetKey != "" { + svc, ok := lookupPasteService(targetKey) + if !ok { + return nil, fmt.Errorf("netdiag: unknown paste target %q", targetKey) + } + candidates = []pasteService{svc} + } + + var failures []string + for _, svc := range candidates { + if err := ctx.Err(); err != nil { + return nil, err + } + res, err := attemptPaste(ctx, svc, text) + if err != nil { + logger.With( + slog.String("target", svc.Key), + slog.String("error", err.Error()), + ).Debug("paste upload failed") + failures = append(failures, fmt.Sprintf("%s: %v", svc.Key, err)) + continue + } + logger.With( + slog.String("target", res.Target), + slog.String("url", res.URL), + slog.Int("bytes", res.Bytes), + ).Info("uploaded diagnostic paste") + return res, nil + } + + if len(candidates) == 1 { + return nil, fmt.Errorf("netdiag: upload to %s failed: %s", candidates[0].Key, strings.TrimPrefix(failures[0], candidates[0].Key+": ")) + } + return nil, fmt.Errorf("netdiag: every paste target failed: %s", strings.Join(failures, "; ")) +} + +// attemptPaste runs one upload under its own timeout and validates the URL the +// service handed back. +func attemptPaste(ctx context.Context, svc pasteService, text string) (*PasteResult, error) { + ctx, cancel := context.WithTimeout(ctx, pasteTimeout) + defer cancel() + + raw, err := svc.upload(ctx, text) + if err != nil { + return nil, err + } + clean, err := normalisePasteURL(raw) + if err != nil { + return nil, err + } + return &PasteResult{ + URL: clean, + Target: svc.Key, + Bytes: len(text), + Uploaded: time.Now(), + }, nil +} + +// lookupPasteService finds a service by its stable key. +func lookupPasteService(key string) (pasteService, bool) { + for _, s := range pasteServices { + if s.Key == key { + return s, true + } + } + return pasteService{}, false +} + +// --------------------------------------------------------------------------- +// Response validation +// --------------------------------------------------------------------------- + +// normalisePasteURL trims a service response down to a single http(s) URL. +// termbin answers with a bare host such as "termbin.com/abcd", so a missing +// scheme is tolerated and upgraded to https. Anything that smells like an HTML +// error page is rejected outright. +func normalisePasteURL(raw string) (string, error) { + s := strings.TrimSpace(raw) + // termbin pads its reply with NULs and terminal escapes. + s = strings.Trim(s, "\x00\r\n\t ") + if s == "" { + return "", errors.New("empty response") + } + if i := strings.IndexAny(s, "\r\n"); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + if looksLikeHTML(s) { + return "", fmt.Errorf("service returned an error page: %s", snippet(s)) + } + if len(s) > 512 { + return "", fmt.Errorf("response is not a URL: %s", snippet(s)) + } + if !strings.Contains(s, "://") { + s = "https://" + s + } + + u, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("response is not a URL: %s", snippet(raw)) + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("response has unexpected scheme %q", u.Scheme) + } + if u.Host == "" || !strings.Contains(u.Host, ".") { + return "", fmt.Errorf("response has no usable host: %s", snippet(s)) + } + return u.String(), nil +} + +// looksLikeHTML reports whether s is the beginning of an HTML document rather +// than a URL. +func looksLikeHTML(s string) bool { + lower := strings.ToLower(strings.TrimSpace(s)) + return strings.HasPrefix(lower, "<") || + strings.Contains(lower, " 120 { + return s[:120] + "…" + } + return s +} + +// --------------------------------------------------------------------------- +// HTTP plumbing +// --------------------------------------------------------------------------- + +// pasteHTTPClient is shared by the HTTP-based targets. The per-attempt context +// timeout is the real deadline; the client timeout is a backstop. +var pasteHTTPClient = &http.Client{ + Timeout: pasteTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + return nil + }, +} + +// doPaste issues one request and returns the (size-limited) response body. +func doPaste(ctx context.Context, method, endpoint, contentType string, body []byte) (string, error) { + req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", pasteUserAgent) + req.Header.Set("Accept", "text/plain, */*") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.ContentLength = int64(len(body)) + + resp, err := pasteHTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, pasteReadLimit)) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("http %d: %s", resp.StatusCode, snippet(string(data))) + } + return string(data), nil +} + +// uploadNullPointer posts to 0x0.st as multipart/form-data. +func uploadNullPointer(ctx context.Context, text string) (string, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file", "tslink-log.txt") + if err != nil { + return "", err + } + if _, err := io.WriteString(part, text); err != nil { + return "", err + } + if err := mw.Close(); err != nil { + return "", err + } + return doPaste(ctx, http.MethodPost, "https://0x0.st/", mw.FormDataContentType(), buf.Bytes()) +} + +// uploadPasteRS posts the raw text to paste.rs. +func uploadPasteRS(ctx context.Context, text string) (string, error) { + return doPaste(ctx, http.MethodPost, "https://paste.rs/", "text/plain; charset=utf-8", []byte(text)) +} + +// uploadDpaste posts a urlencoded form to dpaste.org and asks for a bare URL +// back rather than the JSON representation. +func uploadDpaste(ctx context.Context, text string) (string, error) { + form := url.Values{ + "content": {text}, + "lexer": {"text"}, + "format": {"url"}, + "expires": {"604800"}, + } + return doPaste(ctx, http.MethodPost, "https://dpaste.org/api/", + "application/x-www-form-urlencoded", []byte(form.Encode())) +} + +// --------------------------------------------------------------------------- +// termbin (raw TCP) +// --------------------------------------------------------------------------- + +// termbinAddr is the netcat-style endpoint termbin.com exposes. +const termbinAddr = "termbin.com:9999" + +// uploadTermbin writes the text over a plain TCP connection, half-closes the +// write side so the server knows the paste is complete, then reads the URL it +// replies with. No TLS is involved, which is exactly why this target survives +// environments where the HTTPS paste sites are unreachable. +func uploadTermbin(ctx context.Context, text string) (string, error) { + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", termbinAddr) + if err != nil { + return "", err + } + defer conn.Close() + + // Bound the whole exchange, and make sure a hung read is interrupted when + // the caller cancels: a blocked socket must never freeze the GUI. + if dl, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(dl) + } else { + _ = conn.SetDeadline(time.Now().Add(pasteTimeout)) + } + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + + tcp, ok := conn.(*net.TCPConn) + if !ok { + return "", errors.New("termbin: connection is not tcp") + } + if _, err := io.WriteString(tcp, text); err != nil { + return "", fmt.Errorf("termbin: write: %w", err) + } + // Half-close: termbin only answers once it sees EOF on its read side. + if err := tcp.CloseWrite(); err != nil { + return "", fmt.Errorf("termbin: close write: %w", err) + } + + data, err := io.ReadAll(io.LimitReader(tcp, pasteReadLimit)) + if err != nil { + return "", fmt.Errorf("termbin: read: %w", err) + } + if ctxErr := ctx.Err(); ctxErr != nil { + return "", ctxErr + } + return string(data), nil +} diff --git a/netdiag/portmap.go b/netdiag/portmap.go new file mode 100644 index 0000000..de12d70 --- /dev/null +++ b/netdiag/portmap.go @@ -0,0 +1,1294 @@ +package netdiag + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// --------------------------------------------------------------------------- +// tunables +// --------------------------------------------------------------------------- + +const ( + // pmPort is shared by NAT-PMP (RFC 6886) and PCP (RFC 6887). + pmPort = 5351 + // pmSSDPTarget is the SSDP multicast rendezvous address. + pmSSDPTarget = "239.255.255.250:1900" + + // pmBudget bounds the whole [ProbePortMapping] call. + pmBudget = 6 * time.Second + // pmProtoBudget bounds one protocol probe across every gateway candidate. + pmProtoBudget = 4500 * time.Millisecond + // pmUnicastTimeout is the per-attempt wait for a NAT-PMP/PCP reply. + pmUnicastTimeout = 750 * time.Millisecond + // pmRetries is the number of *extra* attempts after the first one. + pmRetries = 2 + // pmSSDPMX is the MX header we advertise: devices answer after a random + // delay in [0, MX] seconds. + pmSSDPMX = 2 + // pmSSDPCollect is how long we listen for SSDP replies, measured from the + // moment the M-SEARCH goes out. It must exceed pmSSDPMX, otherwise devices + // that happen to draw a delay near the top of the range are cut off and + // the IGD is intermittently reported as absent. + pmSSDPCollect = 3 * time.Second + // pmHTTPTimeout bounds one description fetch or SOAP call. + pmHTTPTimeout = 2 * time.Second + // pmMaxDescBody caps a device description body. + pmMaxDescBody = 256 << 10 + // pmMaxGateways caps how many gateway candidates we are willing to poke. + pmMaxGateways = 4 + // pmMaxSSDPSockets bounds the in-flight SSDP goroutines. + pmMaxSSDPSockets = 8 + // pmMaxLocations caps how many distinct SSDP LOCATIONs we fetch. + pmMaxLocations = 4 +) + +// pmWANServices are the IGD service types that expose GetExternalIPAddress, +// in preference order. +var pmWANServices = []string{ + "urn:schemas-upnp-org:service:WANIPConnection:2", + "urn:schemas-upnp-org:service:WANIPConnection:1", + "urn:schemas-upnp-org:service:WANPPPConnection:1", +} + +// pmLog returns a logger tagged for this subsystem, tolerating a nil logger. +func pmLog(logger *slog.Logger) *slog.Logger { + if logger == nil { + logger = slog.Default() + } + return logger.With(slog.String("from", "netdiag/portmap")) +} + +// pmDeadline returns the shorter of ctx's deadline and now+d. +func pmDeadline(ctx context.Context, d time.Duration) time.Time { + t := time.Now().Add(d) + if dl, ok := ctx.Deadline(); ok && dl.Before(t) { + return dl + } + return t +} + +// --------------------------------------------------------------------------- +// gateway discovery +// --------------------------------------------------------------------------- + +// DiscoverGateways returns candidate default-gateway addresses, best first. +// +// On Linux the kernel routing tables are parsed directly; everywhere else (and +// as a fallback when the tables yield nothing) the conventional first and last +// host addresses of every private IPv4 prefix on an up interface are offered. +// The result is deduplicated, ordered deterministically and capped at four +// entries. It never shells out and never blocks on the network. +func DiscoverGateways(ctx context.Context, logger *slog.Logger) []netip.Addr { + gws, _ := pmGateways(ctx, logger) + return gws +} + +// pmGateways is [DiscoverGateways] plus the interface enumeration error, which +// [ProbePortMapping] needs to tell "no router" apart from "no network stack". +func pmGateways(ctx context.Context, logger *slog.Logger) ([]netip.Addr, error) { + log := pmLog(logger) + if err := ctx.Err(); err != nil { + return nil, err + } + + var out []netip.Addr + out = append(out, pmProcRoutes(log)...) + + ifaces, ifErr := net.Interfaces() + if ifErr != nil { + log.Debug("cannot enumerate interfaces", slog.String("error", ifErr.Error())) + } else { + out = append(out, pmGuessedGateways(ifaces)...) + } + + out = pmDedupAddrs(out) + if len(out) > pmMaxGateways { + out = out[:pmMaxGateways] + } + if len(out) == 0 && ifErr != nil { + return nil, ifErr + } + log.Debug("gateway candidates", slog.Int("count", len(out)), slog.String("addrs", pmJoinAddrs(out))) + return out, nil +} + +// pmProcRoutes reads the Linux routing tables. It returns nil on any other +// platform, or when the files are unreadable. +func pmProcRoutes(log *slog.Logger) []netip.Addr { + var out []netip.Addr + out = append(out, pmProcRoute4(log)...) + out = append(out, pmProcRoute6(log)...) + return out +} + +// pmRouteCandidate is a parsed routing-table row, kept so rows can be ordered +// by metric before their gateways are handed out. +type pmRouteCandidate struct { + addr netip.Addr + metric int + iface string +} + +// pmSortRoutes orders routes by metric, then interface, then address, so the +// candidate list does not jitter between refreshes. +// +// Link-local IPv6 next hops get their interface zone reattached. A Linux IPv6 +// default route almost always points at an fe80:: address, and dialling one +// without a zone fails outright — so without this the PCP-over-IPv6 probe +// never reaches the router, and the unusable candidates still consume slots +// against pmMaxGateways, crowding out the IPv4 guesses. +func pmSortRoutes(rs []pmRouteCandidate) []netip.Addr { + sort.SliceStable(rs, func(i, j int) bool { + if rs[i].metric != rs[j].metric { + return rs[i].metric < rs[j].metric + } + if rs[i].iface != rs[j].iface { + return rs[i].iface < rs[j].iface + } + return rs[i].addr.Compare(rs[j].addr) < 0 + }) + out := make([]netip.Addr, 0, len(rs)) + for _, r := range rs { + addr := r.addr + if addr.Is6() && addr.IsLinkLocalUnicast() { + if r.iface == "" { + continue // unusable without a zone + } + addr = addr.WithZone(r.iface) + } + out = append(out, addr) + } + return out +} + +const ( + pmRTFUp = 0x0001 + pmRTFGateway = 0x0002 +) + +// pmProcRoute4 parses /proc/net/route. Every numeric column is hex; the +// address columns are little-endian, so 0102A8C0 is 192.168.2.1. The default +// route is the row with a zero destination and the RTF_GATEWAY flag. +func pmProcRoute4(log *slog.Logger) []netip.Addr { + data, err := os.ReadFile("/proc/net/route") + if err != nil { + log.Debug("no /proc/net/route", slog.String("error", err.Error())) + return nil + } + var rows []pmRouteCandidate + for i, line := range strings.Split(string(data), "\n") { + if i == 0 { // header + continue + } + f := strings.Fields(line) + if len(f) < 8 { + continue + } + dest, err1 := strconv.ParseUint(f[1], 16, 32) + gw, err2 := strconv.ParseUint(f[2], 16, 32) + flags, err3 := strconv.ParseUint(f[3], 16, 32) + if err1 != nil || err2 != nil || err3 != nil { + continue + } + if dest != 0 || flags&pmRTFGateway == 0 || flags&pmRTFUp == 0 || gw == 0 { + continue + } + metric := 0 + if len(f) >= 7 { + if m, err := strconv.Atoi(f[6]); err == nil { + metric = m + } + } + v := uint32(gw) + addr := netip.AddrFrom4([4]byte{ + byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24), + }) + rows = append(rows, pmRouteCandidate{addr: addr, metric: metric, iface: f[0]}) + } + return pmSortRoutes(rows) +} + +// pmProcRoute6 parses /proc/net/ipv6_route best-effort. Columns are +// dest/plen/src/srcplen/nexthop/metric/refcnt/use/flags/iface, all hex, with +// addresses written big-endian as 32 hex digits. +func pmProcRoute6(log *slog.Logger) []netip.Addr { + data, err := os.ReadFile("/proc/net/ipv6_route") + if err != nil { + log.Debug("no /proc/net/ipv6_route", slog.String("error", err.Error())) + return nil + } + var rows []pmRouteCandidate + for _, line := range strings.Split(string(data), "\n") { + f := strings.Fields(line) + if len(f) < 10 { + continue + } + plen, err := strconv.ParseUint(f[1], 16, 8) + if err != nil || plen != 0 { + continue + } + if strings.Trim(f[0], "0") != "" { // destination must be :: + continue + } + flags, err := strconv.ParseUint(f[8], 16, 64) + if err != nil || flags&pmRTFGateway == 0 { + continue + } + nh, ok := pmHexAddr16(f[4]) + if !ok || !nh.IsValid() || nh.IsUnspecified() { + continue + } + metric := 0 + if m, err := strconv.ParseUint(f[5], 16, 32); err == nil { + metric = int(m) + } + rows = append(rows, pmRouteCandidate{addr: nh, metric: metric, iface: f[9]}) + } + return pmSortRoutes(rows) +} + +// pmHexAddr16 decodes 32 hex digits into an IPv6 address. +func pmHexAddr16(s string) (netip.Addr, bool) { + if len(s) != 32 { + return netip.Addr{}, false + } + raw, err := hex.DecodeString(s) + if err != nil { + return netip.Addr{}, false + } + var b [16]byte + copy(b[:], raw) + return netip.AddrFrom16(b), true +} + +// pmGuessedGateways offers x.x.x.1 and x.x.x.254 for every private IPv4 prefix +// on an up, non-loopback interface. Interfaces are visited in name order for +// determinism. +func pmGuessedGateways(ifaces []net.Interface) []netip.Addr { + sorted := make([]net.Interface, len(ifaces)) + copy(sorted, ifaces) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + + var out []netip.Addr + for _, ifc := range sorted { + if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifc.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ipn, ok := a.(*net.IPNet) + if !ok { + continue + } + pfx, ok := pmPrefixOf(ipn) + if !ok || !pfx.Addr().Is4() || !pfx.Addr().IsPrivate() { + continue + } + if bits := pfx.Bits(); bits < 8 || bits > 30 { + continue + } + base := pfx.Masked().Addr() + out = append(out, base.Next()) // x.x.x.1 + if last, ok := pmLastHost(pfx); ok { + out = append(out, last) + } + } + } + return out +} + +// pmPrefixOf converts a net.IPNet to a netip.Prefix. +func pmPrefixOf(ipn *net.IPNet) (netip.Prefix, bool) { + addr, ok := netip.AddrFromSlice(ipn.IP) + if !ok { + return netip.Prefix{}, false + } + addr = addr.Unmap() + ones, _ := ipn.Mask.Size() + if ones == 0 && len(ipn.Mask) == 0 { + return netip.Prefix{}, false + } + pfx, err := addr.Prefix(ones) + if err != nil { + return netip.Prefix{}, false + } + return pfx, true +} + +// pmLastHost returns the conventional "high" gateway of an IPv4 prefix: +// x.x.x.254 for prefixes at least as large as a /24, otherwise the last usable +// address before the broadcast address. +func pmLastHost(pfx netip.Prefix) (netip.Addr, bool) { + base := pfx.Masked().Addr() + if !base.Is4() { + return netip.Addr{}, false + } + b := base.As4() + if pfx.Bits() <= 24 { + b[3] = 254 + return netip.AddrFrom4(b), true + } + // Broadcast address of the prefix, minus one. + host := uint32(1)<<(32-uint(pfx.Bits())) - 1 + v := binary.BigEndian.Uint32(b[:]) | host + if v == 0 { + return netip.Addr{}, false + } + var out [4]byte + binary.BigEndian.PutUint32(out[:], v-1) + return netip.AddrFrom4(out), true +} + +// pmDedupAddrs removes duplicates and invalid entries, preserving order. +func pmDedupAddrs(in []netip.Addr) []netip.Addr { + seen := make(map[netip.Addr]bool, len(in)) + out := make([]netip.Addr, 0, len(in)) + for _, a := range in { + a = a.Unmap() + if !a.IsValid() || a.IsUnspecified() || a.IsLoopback() || seen[a] { + continue + } + seen[a] = true + out = append(out, a) + } + return out +} + +func pmJoinAddrs(in []netip.Addr) string { + parts := make([]string, 0, len(in)) + for _, a := range in { + parts = append(parts, a.String()) + } + return strings.Join(parts, ",") +} + +// --------------------------------------------------------------------------- +// the public probe +// --------------------------------------------------------------------------- + +// ProbePortMapping probes UPnP IGD, NAT-PMP and PCP concurrently and reports +// what the router is willing to do for us. +// +// The three protocols are independent, so a hang in one cannot delay the +// others; the whole call is bounded both by ctx and by an internal six second +// budget. A router that answers nothing is [StatusWarn], not an error: it is a +// real (and common) finding for peer-to-peer traffic. [StatusFail] is reserved +// for the case where the local network stack could not even be enumerated. +func ProbePortMapping(ctx context.Context, logger *slog.Logger) PortMapReport { + log := pmLog(logger) + + ctx, cancel := context.WithTimeout(ctx, pmBudget) + defer cancel() + + var rep PortMapReport + + gws, err := pmGateways(ctx, log) + if err != nil && len(gws) == 0 { + rep.Status = StatusFail + rep.Summary = "无法枚举本机网络接口,端口映射检测已跳过" + rep.UPnP.Err = err.Error() + rep.NATPMP.Err = err.Error() + rep.PCP.Err = err.Error() + log.Warn("port mapping probe aborted", slog.String("error", err.Error())) + return rep + } + if len(gws) > 0 { + rep.Gateway = gws[0] + } + + var ( + mu sync.Mutex + answered netip.Addr + wg sync.WaitGroup + ) + note := func(gw netip.Addr) { + mu.Lock() + if !answered.IsValid() && gw.IsValid() { + answered = gw + } + mu.Unlock() + } + + wg.Add(3) + go func() { + defer wg.Done() + probe, gw := pmProbeNATPMP(ctx, gws, log) + mu.Lock() + rep.NATPMP = probe + mu.Unlock() + if probe.Available { + note(gw) + } + }() + go func() { + defer wg.Done() + probe, gw := pmProbePCP(ctx, gws, log) + mu.Lock() + rep.PCP = probe + mu.Unlock() + if probe.Available { + note(gw) + } + }() + go func() { + defer wg.Done() + probe := pmProbeUPnP(ctx, log) + mu.Lock() + rep.UPnP = probe + mu.Unlock() + }() + wg.Wait() + + if answered.IsValid() { + rep.Gateway = answered + } + pmSummarize(&rep) + log.Info("port mapping probe done", + slog.String("gateway", rep.Gateway.String()), + slog.Bool("upnp", rep.UPnP.Available), + slog.Bool("natpmp", rep.NATPMP.Available), + slog.Bool("pcp", rep.PCP.Available), + slog.String("status", rep.Status.String())) + return rep +} + +// pmSummarize fills Status and the one-line Chinese summary. +func pmSummarize(rep *PortMapReport) { + var ok []string + if rep.UPnP.Available { + ok = append(ok, "UPnP") + } + if rep.NATPMP.Available { + ok = append(ok, "NAT-PMP") + } + if rep.PCP.Available { + ok = append(ok, "PCP") + } + + gw := "未知网关" + if rep.Gateway.IsValid() { + gw = rep.Gateway.String() + } + ext := rep.UPnP.ExternalIP + if !ext.IsValid() { + ext = rep.NATPMP.ExternalIP + } + + if len(ok) == 0 { + rep.Status = StatusWarn + rep.Summary = fmt.Sprintf("路由器 %s 未响应 UPnP / NAT-PMP / PCP,需要手动端口转发", gw) + return + } + rep.Status = StatusOK + if ext.IsValid() { + rep.Summary = fmt.Sprintf("路由器 %s 支持 %s,可自动映射端口,外网地址 %s", + gw, strings.Join(ok, " / "), ext) + return + } + rep.Summary = fmt.Sprintf("路由器 %s 支持 %s,可自动映射端口", gw, strings.Join(ok, " / ")) +} + +// --------------------------------------------------------------------------- +// NAT-PMP (RFC 6886) +// --------------------------------------------------------------------------- + +// pmProbeNATPMP asks each gateway candidate in turn for its external address +// and returns the first answer, along with the gateway that gave it. +func pmProbeNATPMP(ctx context.Context, gws []netip.Addr, log *slog.Logger) (ServiceProbe, netip.Addr) { + var probe ServiceProbe + if len(gws) == 0 { + probe.Err = "no gateway candidate" + return probe, netip.Addr{} + } + deadline := pmDeadline(ctx, pmProtoBudget) + var lastErr error + for _, gw := range gws { + if !gw.Is4() { // NAT-PMP is IPv4-only + continue + } + if time.Now().After(deadline) || ctx.Err() != nil { + break + } + p, err := pmNATPMPOnce(ctx, gw, deadline, log) + if err != nil { + lastErr = err + continue + } + return p, gw + } + if lastErr != nil { + probe.Err = lastErr.Error() + } else { + probe.Err = "no IPv4 gateway candidate" + } + return probe, netip.Addr{} +} + +// pmNATPMPOnce runs one external-address transaction against gw, retrying +// pmRetries times as RFC 6886 prescribes. +func pmNATPMPOnce(ctx context.Context, gw netip.Addr, deadline time.Time, log *slog.Logger) (ServiceProbe, error) { + var probe ServiceProbe + + conn, err := pmDialUDP(ctx, gw) + if err != nil { + return probe, err + } + defer conn.Close() + + req := []byte{0x00, 0x00} // version 0, opcode 0 = public address request + buf := make([]byte, 64) + + // start is per attempt, not per probe: timing from before the retry loop + // would add every prior attempt's full timeout to the reported round trip, + // so a router that answers on the third try would look ~1.5s away. + var start time.Time + + for attempt := 0; attempt <= pmRetries; attempt++ { + if ctx.Err() != nil { + return probe, ctx.Err() + } + until := time.Now().Add(pmUnicastTimeout) + if until.After(deadline) { + until = deadline + } + if !until.After(time.Now()) { + break + } + start = time.Now() + if _, err = conn.Write(req); err != nil { + return probe, err + } + _ = conn.SetReadDeadline(until) + n, rerr := conn.Read(buf) + if rerr != nil { + err = rerr + log.Debug("nat-pmp attempt timed out", + slog.String("gateway", gw.String()), slog.Int("attempt", attempt+1)) + continue + } + rtt := time.Since(start) + if n < 12 { + err = fmt.Errorf("short nat-pmp response: %d bytes", n) + continue + } + if buf[0] != 0 || buf[1] != 0x80 { + err = fmt.Errorf("unexpected nat-pmp header %#x/%#x", buf[0], buf[1]) + continue + } + code := binary.BigEndian.Uint16(buf[2:4]) + epoch := binary.BigEndian.Uint32(buf[4:8]) + if code != 0 { + probe.RTT = rtt + probe.Err = fmt.Sprintf("NAT-PMP v0 result code %d (%s)", code, pmPMPResultName(code)) + probe.Detail = fmt.Sprintf("NAT-PMP v0 @ %s, epoch %ds", gw, epoch) + return probe, nil + } + var ip4 [4]byte + copy(ip4[:], buf[8:12]) + probe.Available = true + probe.RTT = rtt + probe.ExternalIP = netip.AddrFrom4(ip4) + probe.Detail = fmt.Sprintf("NAT-PMP v0 @ %s, epoch %ds (路由器已运行 %s)", + gw, epoch, pmHuman(time.Duration(epoch)*time.Second)) + return probe, nil + } + if err == nil { + err = errors.New("no nat-pmp response") + } + return probe, err +} + +// pmPMPResultName names the RFC 6886 result codes. +func pmPMPResultName(code uint16) string { + switch code { + case 1: + return "unsupported version" + case 2: + return "not authorized / refused" + case 3: + return "network failure" + case 4: + return "out of resources" + case 5: + return "unsupported opcode" + default: + return "unknown" + } +} + +// --------------------------------------------------------------------------- +// PCP (RFC 6887) +// --------------------------------------------------------------------------- + +// pmProbePCP sends a PCP ANNOUNCE to each gateway candidate and returns the +// first conclusive answer. +// +// PCP shares UDP/5351 with NAT-PMP, so a version-0 reply (or a version-2 +// UNSUPP_VERSION result) means "this router speaks NAT-PMP but not PCP" rather +// than "nothing is there"; that distinction is recorded in Detail. +func pmProbePCP(ctx context.Context, gws []netip.Addr, log *slog.Logger) (ServiceProbe, netip.Addr) { + var probe ServiceProbe + if len(gws) == 0 { + probe.Err = "no gateway candidate" + return probe, netip.Addr{} + } + deadline := pmDeadline(ctx, pmProtoBudget) + var ( + lastErr error + fallback *ServiceProbe + ) + for _, gw := range gws { + if time.Now().After(deadline) || ctx.Err() != nil { + break + } + p, err := pmPCPOnce(ctx, gw, deadline, log) + if err != nil { + lastErr = err + continue + } + if p.Available { + return p, gw + } + if fallback == nil { + cp := p + fallback = &cp + } + } + if fallback != nil { + return *fallback, netip.Addr{} + } + if lastErr != nil { + probe.Err = lastErr.Error() + } else { + probe.Err = "no pcp response" + } + return probe, netip.Addr{} +} + +// pmPCPOnce runs one ANNOUNCE transaction against gw. +func pmPCPOnce(ctx context.Context, gw netip.Addr, deadline time.Time, log *slog.Logger) (ServiceProbe, error) { + var probe ServiceProbe + + conn, err := pmDialUDP(ctx, gw) + if err != nil { + return probe, err + } + defer conn.Close() + + local, ok := netip.AddrFromSlice(conn.LocalAddr().(*net.UDPAddr).IP) + if !ok { + return probe, errors.New("cannot determine local address for pcp") + } + client := local.Unmap().As16() // IPv4-mapped IPv6 for v4 sources + + req := make([]byte, 24) + req[0] = 2 // version + req[1] = 0x00 // R=0 (request), opcode 0 = ANNOUNCE + // req[2:4] reserved, req[4:8] requested lifetime = 0 + copy(req[8:24], client[:]) + + buf := make([]byte, 1100) + + // Per attempt; see the note in pmNATPMPOnce. + var start time.Time + + for attempt := 0; attempt <= pmRetries; attempt++ { + if ctx.Err() != nil { + return probe, ctx.Err() + } + until := time.Now().Add(pmUnicastTimeout) + if until.After(deadline) { + until = deadline + } + if !until.After(time.Now()) { + break + } + start = time.Now() + if _, err = conn.Write(req); err != nil { + return probe, err + } + _ = conn.SetReadDeadline(until) + n, rerr := conn.Read(buf) + if rerr != nil { + err = rerr + log.Debug("pcp attempt timed out", + slog.String("gateway", gw.String()), slog.Int("attempt", attempt+1)) + continue + } + rtt := time.Since(start) + + // A NAT-PMP-speaking router answers a PCP request with version 0. + if n >= 4 && buf[0] == 0 { + probe.RTT = rtt + probe.Detail = fmt.Sprintf("%s 回应 NAT-PMP v0,不支持 PCP", gw) + probe.Err = "gateway answered NAT-PMP v0, PCP unsupported" + return probe, nil + } + if n < 24 { + err = fmt.Errorf("short pcp response: %d bytes", n) + continue + } + if buf[0] != 2 || buf[1] != 0x80 { + err = fmt.Errorf("unexpected pcp header %#x/%#x", buf[0], buf[1]) + continue + } + code := buf[3] + epoch := binary.BigEndian.Uint32(buf[8:12]) + if code == 1 { // UNSUPP_VERSION + probe.RTT = rtt + probe.Detail = fmt.Sprintf("%s 返回 UNSUPP_VERSION,仅支持 NAT-PMP,不支持 PCP v2", gw) + probe.Err = "pcp result code 1 (unsupported version); NAT-PMP present" + return probe, nil + } + if code != 0 { + probe.RTT = rtt + probe.Detail = fmt.Sprintf("PCP v2 @ %s, epoch %ds", gw, epoch) + probe.Err = fmt.Sprintf("PCP v2 result code %d (%s)", code, pmPCPResultName(code)) + return probe, nil + } + probe.Available = true + probe.RTT = rtt + probe.Detail = fmt.Sprintf("PCP v2 @ %s, epoch %ds (路由器已运行 %s)", + gw, epoch, pmHuman(time.Duration(epoch)*time.Second)) + return probe, nil + } + if err == nil { + err = errors.New("no pcp response") + } + return probe, err +} + +// pmPCPResultName names the RFC 6887 result codes we are likely to see. +func pmPCPResultName(code byte) string { + switch code { + case 1: + return "unsupported version" + case 2: + return "not authorized" + case 3: + return "malformed request" + case 4: + return "unsupported opcode" + case 5: + return "unsupported option" + case 6: + return "malformed option" + case 7: + return "network failure" + case 8: + return "no resources" + case 9: + return "unsupported protocol" + case 10: + return "user exceeded quota" + case 11: + return "cannot provide external" + case 12: + return "address mismatch" + case 13: + return "excessive remote peers" + default: + return "unknown" + } +} + +// pmDialUDP opens a connected UDP socket towards gw:5351, honouring ctx. +func pmDialUDP(ctx context.Context, gw netip.Addr) (net.Conn, error) { + d := net.Dialer{Timeout: pmUnicastTimeout} + network := "udp4" + if gw.Is6() { + network = "udp6" + } + return d.DialContext(ctx, network, netip.AddrPortFrom(gw, pmPort).String()) +} + +// pmHuman renders a duration the way a router uptime reads. +func pmHuman(d time.Duration) string { + d = d.Round(time.Minute) + days := int(d.Hours()) / 24 + h := int(d.Hours()) % 24 + m := int(d.Minutes()) % 60 + if days > 0 { + return fmt.Sprintf("%dd%dh", days, h) + } + if h > 0 { + return fmt.Sprintf("%dh%dm", h, m) + } + return fmt.Sprintf("%dm", m) +} + +// --------------------------------------------------------------------------- +// UPnP IGD +// --------------------------------------------------------------------------- + +// pmProbeUPnP runs SSDP discovery, fetches the device description and asks the +// WAN connection service for the external address. +// +// Available flips to true as soon as an IGD answered SSDP and its description +// parsed; a failing SOAP call only fills Err, because a discoverable IGD is +// still a useful finding. +func pmProbeUPnP(ctx context.Context, log *slog.Logger) ServiceProbe { + var probe ServiceProbe + start := time.Now() + + locations := pmSSDPDiscover(ctx, "urn:schemas-upnp-org:device:InternetGatewayDevice:1", log) + if len(locations) == 0 && ctx.Err() == nil { + log.Debug("ssdp igd search empty, retrying with ssdp:all") + locations = pmSSDPDiscover(ctx, "ssdp:all", log) + } + if len(locations) == 0 { + probe.Err = "no ssdp response" + return probe + } + if len(locations) > pmMaxLocations { + locations = locations[:pmMaxLocations] + } + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: nil, // the router is local; never go through a proxy + DisableKeepAlives: true, + TLSHandshakeTimeout: pmHTTPTimeout, + }, + } + + var lastErr error + for _, loc := range locations { + if ctx.Err() != nil { + break + } + dev, base, err := pmFetchDescription(ctx, client, loc, log) + if err != nil { + lastErr = err + continue + } + probe.Available = true + probe.RTT = time.Since(start) + probe.Detail = pmDeviceLabel(dev, loc) + + svcType, ctrlURL, ok := pmFindWANService(dev, base) + if !ok { + probe.Err = "no WANIPConnection/WANPPPConnection service in device description" + return probe + } + probe.Detail = pmDeviceLabel(dev, ctrlURL) + + ip, err := pmSOAPExternalIP(ctx, client, ctrlURL, svcType) + if err != nil { + probe.Err = "GetExternalIPAddress: " + err.Error() + return probe + } + probe.ExternalIP = ip + probe.RTT = time.Since(start) + return probe + } + if lastErr != nil { + probe.Err = lastErr.Error() + } else { + probe.Err = "no usable igd description" + } + return probe +} + +// pmDeviceLabel renders " ()" when known, falling +// back to the manufacturer or the URL. +func pmDeviceLabel(dev *pmUPnPDevice, fallback string) string { + name := strings.TrimSpace(dev.FriendlyName) + model := strings.TrimSpace(dev.ModelName) + switch { + case name != "" && model != "": + return fmt.Sprintf("%s (%s)", name, model) + case name != "": + return name + case model != "": + return model + case strings.TrimSpace(dev.Manufacturer) != "": + return strings.TrimSpace(dev.Manufacturer) + default: + return fallback + } +} + +// pmSSDPDiscover sends an M-SEARCH from every usable IPv4 interface address and +// collects the LOCATION headers of the replies for roughly two seconds. The +// returned list is deduplicated and sorted so the UI does not jitter. +func pmSSDPDiscover(ctx context.Context, st string, log *slog.Logger) []string { + srcs := pmSSDPSources(log) + if len(srcs) == 0 { + return nil + } + body := "M-SEARCH * HTTP/1.1\r\n" + + "HOST: 239.255.255.250:1900\r\n" + + "MAN: \"ssdp:discover\"\r\n" + + "MX: " + strconv.Itoa(pmSSDPMX) + "\r\n" + + "ST: " + st + "\r\n" + + "\r\n" + + target, err := net.ResolveUDPAddr("udp4", pmSSDPTarget) + if err != nil { + log.Debug("cannot resolve ssdp target", slog.String("error", err.Error())) + return nil + } + deadline := pmDeadline(ctx, pmSSDPCollect) + + var ( + mu sync.Mutex + locs = map[string]bool{} + wg sync.WaitGroup + sem = make(chan struct{}, pmMaxSSDPSockets) + ) + for _, src := range srcs { + wg.Add(1) + go func(src netip.Addr) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + for _, l := range pmSSDPOne(src, target, body, deadline, log) { + mu.Lock() + locs[l] = true + mu.Unlock() + } + }(src) + } + wg.Wait() + + out := make([]string, 0, len(locs)) + for l := range locs { + out = append(out, l) + } + sort.Strings(out) + return out +} + +// pmSSDPOne performs the M-SEARCH from a single source address. Interfaces +// without multicast support fail here routinely; that is a debug-level event, +// not an error worth surfacing. +func pmSSDPOne(src netip.Addr, target *net.UDPAddr, body string, hardDeadline time.Time, log *slog.Logger) []string { + if !hardDeadline.After(time.Now()) { + return nil + } + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(src.AsSlice())}) + if err != nil { + log.Debug("ssdp socket unavailable", + slog.String("src", src.String()), slog.String("error", err.Error())) + return nil + } + defer conn.Close() + + if _, err := conn.WriteToUDP([]byte(body), target); err != nil { + log.Debug("ssdp multicast send failed", + slog.String("src", src.String()), slog.String("error", err.Error())) + return nil + } + + // Give devices the full MX window measured from the send, not from when + // the caller computed a shared deadline: socket setup and goroutine + // scheduling happen in between, and every millisecond of that came out of + // the reply window. + deadline := time.Now().Add(pmSSDPCollect) + if deadline.After(hardDeadline) { + deadline = hardDeadline + } + + var out []string + buf := make([]byte, 4096) + for { + if !deadline.After(time.Now()) { + break + } + _ = conn.SetReadDeadline(deadline) + n, from, err := conn.ReadFromUDP(buf) + if err != nil { + break + } + if loc := pmSSDPLocation(buf[:n]); loc != "" { + log.Debug("ssdp reply", + slog.String("from", from.String()), slog.String("location", loc)) + out = append(out, loc) + } + } + return out +} + +// pmSSDPLocation extracts the LOCATION header from an SSDP reply, +// case-insensitively. +func pmSSDPLocation(b []byte) string { + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimRight(line, "\r") + k, v, ok := strings.Cut(line, ":") + if !ok || !strings.EqualFold(strings.TrimSpace(k), "location") { + continue + } + v = strings.TrimSpace(v) + u, err := url.Parse(v) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return "" + } + return v + } + return "" +} + +// pmSSDPSources lists the IPv4 addresses worth sending an M-SEARCH from. +func pmSSDPSources(log *slog.Logger) []netip.Addr { + ifaces, err := net.Interfaces() + if err != nil { + log.Debug("cannot enumerate interfaces for ssdp", slog.String("error", err.Error())) + return nil + } + sort.Slice(ifaces, func(i, j int) bool { return ifaces[i].Name < ifaces[j].Name }) + + var out []netip.Addr + for _, ifc := range ifaces { + if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifc.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ipn, ok := a.(*net.IPNet) + if !ok { + continue + } + addr, ok := netip.AddrFromSlice(ipn.IP) + if !ok { + continue + } + addr = addr.Unmap() + if !addr.Is4() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { + continue + } + out = append(out, addr) + } + } + return pmDedupAddrs(out) +} + +// --------------------------------------------------------------------------- +// device description +// --------------------------------------------------------------------------- + +// pmUPnPService is one service entry of a UPnP device description. +type pmUPnPService struct { + ServiceType string `xml:"serviceType"` + ControlURL string `xml:"controlURL"` +} + +// pmUPnPDevice is one (possibly nested) device entry of a UPnP description. +type pmUPnPDevice struct { + DeviceType string `xml:"deviceType"` + FriendlyName string `xml:"friendlyName"` + Manufacturer string `xml:"manufacturer"` + ModelName string `xml:"modelName"` + Services []pmUPnPService `xml:"serviceList>service"` + Devices []pmUPnPDevice `xml:"deviceList>device"` +} + +// pmUPnPRoot is the root element of a UPnP device description. +type pmUPnPRoot struct { + URLBase string `xml:"URLBase"` + Device pmUPnPDevice `xml:"device"` +} + +// pmFetchDescription GETs a LOCATION and parses the device description. It +// returns the root device and the base URL that relative control URLs resolve +// against. +func pmFetchDescription(ctx context.Context, client *http.Client, loc string, log *slog.Logger) (*pmUPnPDevice, *url.URL, error) { + rctx, cancel := context.WithTimeout(ctx, pmHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(rctx, http.MethodGet, loc, nil) + if err != nil { + return nil, nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + defer func() { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("description %s: http %d", loc, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, pmMaxDescBody)) + if err != nil { + return nil, nil, err + } + + var root pmUPnPRoot + dec := xml.NewDecoder(bytes.NewReader(body)) + dec.CharsetReader = pmPassthroughCharset + dec.Strict = false + if err := dec.Decode(&root); err != nil { + return nil, nil, fmt.Errorf("parse description %s: %w", loc, err) + } + + base, err := url.Parse(loc) + if err != nil { + return nil, nil, err + } + // URLBase comes from a device description fetched after an unauthenticated + // multicast handshake, i.e. it is attacker-controlled on a hostile LAN. + // Accepting it unchecked would let any device on the network redirect the + // SOAP POST below to a host of its choosing — including internal services + // the router itself cannot reach. Only honour a rebase that stays on the + // same origin we already decided to talk to. + if b := strings.TrimSpace(root.URLBase); b != "" { + if u, err := url.Parse(b); err == nil && pmSameOrigin(base, u) { + base = u + } else { + log.Debug("ignoring off-origin URLBase", + slog.String("location", loc), slog.String("urlbase", b)) + } + } + log.Debug("igd description parsed", + slog.String("location", loc), slog.String("device", root.Device.FriendlyName)) + return &root.Device, base, nil +} + +// pmSameOrigin reports whether u is an http(s) URL on the same host as base. +// The port may differ — IGDs routinely serve the description and the control +// endpoint on different ports — but the host may not. +func pmSameOrigin(base, u *url.URL) bool { + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + if u.Host == "" { + return false + } + return strings.EqualFold(u.Hostname(), base.Hostname()) +} + +// pmPassthroughCharset accepts non-UTF-8 declarations rather than failing the +// parse; router descriptions are ASCII in practice. +func pmPassthroughCharset(_ string, input io.Reader) (io.Reader, error) { + return input, nil +} + +// pmFindWANService walks the nested device tree for a WAN connection service +// and resolves its control URL against base. +func pmFindWANService(dev *pmUPnPDevice, base *url.URL) (svcType, ctrlURL string, ok bool) { + for _, want := range pmWANServices { + if st, cu, found := pmFindService(dev, want); found { + ref, err := url.Parse(strings.TrimSpace(cu)) + if err != nil { + continue + } + return st, base.ResolveReference(ref).String(), true + } + } + return "", "", false +} + +// pmFindService searches dev and its children for an exact serviceType match. +func pmFindService(dev *pmUPnPDevice, want string) (svcType, ctrlURL string, ok bool) { + for _, s := range dev.Services { + if strings.EqualFold(strings.TrimSpace(s.ServiceType), want) && strings.TrimSpace(s.ControlURL) != "" { + return strings.TrimSpace(s.ServiceType), s.ControlURL, true + } + } + for i := range dev.Devices { + if st, cu, found := pmFindService(&dev.Devices[i], want); found { + return st, cu, true + } + } + return "", "", false +} + +// --------------------------------------------------------------------------- +// SOAP +// --------------------------------------------------------------------------- + +// pmSOAPExternalIP invokes GetExternalIPAddress on an IGD control URL. +func pmSOAPExternalIP(ctx context.Context, client *http.Client, ctrlURL, svcType string) (netip.Addr, error) { + envelope := `` + "\r\n" + + `` + + `` + + `` + "\r\n" + + rctx, cancel := context.WithTimeout(ctx, pmHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(rctx, http.MethodPost, ctrlURL, strings.NewReader(envelope)) + if err != nil { + return netip.Addr{}, err + } + req.Header.Set("Content-Type", `text/xml; charset="utf-8"`) + req.Header.Set("SOAPAction", `"`+svcType+`#GetExternalIPAddress"`) + + resp, err := client.Do(req) + if err != nil { + return netip.Addr{}, err + } + defer func() { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, pmMaxDescBody)) + if err != nil { + return netip.Addr{}, err + } + if resp.StatusCode != http.StatusOK { + return netip.Addr{}, fmt.Errorf("http %d", resp.StatusCode) + } + raw := pmXMLValue(body, "NewExternalIPAddress") + if raw == "" { + return netip.Addr{}, errors.New("no NewExternalIPAddress in soap response") + } + addr, err := netip.ParseAddr(strings.TrimSpace(raw)) + if err != nil { + return netip.Addr{}, fmt.Errorf("bad external address %q", raw) + } + return addr.Unmap(), nil +} + +// pmXMLValue returns the character data of the first element with the given +// local name, ignoring namespaces. +func pmXMLValue(body []byte, local string) string { + dec := xml.NewDecoder(bytes.NewReader(body)) + dec.CharsetReader = pmPassthroughCharset + dec.Strict = false + for { + tok, err := dec.Token() + if err != nil { + return "" + } + se, ok := tok.(xml.StartElement) + if !ok || se.Name.Local != local { + continue + } + var v string + if err := dec.DecodeElement(&v, &se); err != nil { + return "" + } + return v + } +} diff --git a/netdiag/reach.go b/netdiag/reach.go new file mode 100644 index 0000000..8579cd0 --- /dev/null +++ b/netdiag/reach.go @@ -0,0 +1,457 @@ +package netdiag + +// This file answers one question: can traffic from this machine reach the +// wider internet, and does the answer change depending on how it leaves? +// +// Every probe is run twice-ish over deliberately different paths — forced +// IPv4, forced IPv6, and through whatever HTTP proxy the environment +// advertises. The divergence between those paths is the signal: a user running +// a proxy tool wants to see that the direct path is dead and the proxied one +// works (or the reverse), not have the two averaged into one green tick. +// +// The mainland-China targets are baselines. They separate "this machine has no +// internet at all" from "this machine has internet but cannot leave the +// country", which are two completely different things to fix. + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" +) + +// diagUserAgent identifies our probes to the servers we poke. Some captive +// portals and CDNs behave differently for an empty UA, and an honest one makes +// the traffic recognisable in a packet capture. +const diagUserAgent = "tslink-netdiag/1.0" + +// diagMaxRedirects is the hard cap on redirects followed by any diagnostic +// client. A redirect chain is usually a portal bouncing us around; three hops +// is enough to land on it and few enough to stay inside the probe timeout. +const diagMaxRedirects = 3 + +const ( + // rchTimeout bounds a single reachability probe end to end. + rchTimeout = 5 * time.Second + // rchMaxInflight bounds concurrent reachability probes. + rchMaxInflight = 6 + // rchMaxBody caps how much of a response body we read. The targets answer + // 204 with no body at all; the cap only exists so a hijacking portal + // serving a huge page cannot stall the probe. + rchMaxBody = 64 << 10 +) + +func rchLog(logger *slog.Logger) *slog.Logger { + if logger == nil { + logger = slog.Default() + } + return logger.With(slog.String("from", "netdiag/reach")) +} + +// --------------------------------------------------------------------------- +// Shared HTTP plumbing (used by reach.go, egress.go and geo.go) +// --------------------------------------------------------------------------- + +// uaTransport stamps [diagUserAgent] onto every request that does not already +// carry one. RoundTrippers must not mutate the request they are handed, so the +// request is cloned first. +type uaTransport struct { + base http.RoundTripper +} + +func (t uaTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") != "" { + return t.base.RoundTrip(req) + } + clone := req.Clone(req.Context()) + clone.Header.Set("User-Agent", diagUserAgent) + return t.base.RoundTrip(clone) +} + +// newDiagClient builds a single-use HTTP client for one diagnostic probe. +// +// network forces the dial family: "tcp4", "tcp6", or "" to let the resolver +// and the kernel pick. Forcing the family is what makes an IPv4-only failure +// distinguishable from an IPv6-only one. +// +// useProxy selects [http.ProxyFromEnvironment] when true and no proxy at all +// when false. The false case is an explicit bypass, not a default: running the +// same target both ways is how proxy interference becomes visible. +// +// timeout bounds the whole request, including dial, TLS handshake and body +// read. Redirects are capped at [diagMaxRedirects] and the User-Agent is set +// to [diagUserAgent]. +// +// The client keeps no idle connections; callers may still call +// CloseIdleConnections when they are done with it. +func newDiagClient(network string, useProxy bool, timeout time.Duration) *http.Client { + dialer := &net.Dialer{Timeout: timeout} + + var proxy func(*http.Request) (*url.URL, error) + if useProxy { + proxy = http.ProxyFromEnvironment + } + + tr := &http.Transport{ + Proxy: proxy, + DialContext: func(ctx context.Context, defaultNetwork, addr string) (net.Conn, error) { + if network != "" { + defaultNetwork = network + } + return dialer.DialContext(ctx, defaultNetwork, addr) + }, + DisableKeepAlives: true, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: timeout, + ResponseHeaderTimeout: timeout, + ExpectContinueTimeout: time.Second, + } + + return &http.Client{ + Transport: uaTransport{base: tr}, + Timeout: timeout, + CheckRedirect: func(_ *http.Request, via []*http.Request) error { + if len(via) >= diagMaxRedirects { + return fmt.Errorf("stopped after %d redirects", diagMaxRedirects) + } + return nil + }, + } +} + +// diagGet performs one GET and returns the status code, at most maxBody bytes +// of the body, and the time to a complete response. ctx must already carry the +// caller's deadline; nothing here blocks past it. +func diagGet(ctx context.Context, client *http.Client, target string, maxBody int64, header http.Header) (int, []byte, time.Duration, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return 0, nil, 0, err + } + for k, vs := range header { + for _, v := range vs { + req.Header.Add(k, v) + } + } + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + return 0, nil, time.Since(start), err + } + defer resp.Body.Close() + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + rtt := time.Since(start) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return resp.StatusCode, body, rtt, readErr + } + return resp.StatusCode, body, rtt, nil +} + +// diagProxyConfigured reports whether the environment advertises a proxy for +// target. Only the boolean is ever surfaced: a proxy URL may embed credentials +// and must never reach a log line or a report field. +func diagProxyConfigured(target string) bool { + u, err := url.Parse(target) + if err != nil { + return false + } + req := &http.Request{URL: u, Header: http.Header{}} + p, err := http.ProxyFromEnvironment(req) + return err == nil && p != nil +} + +// --------------------------------------------------------------------------- +// Overseas reachability +// --------------------------------------------------------------------------- + +// rchTarget is one reachability probe definition. +type rchTarget struct { + name string + url string + region Region + network string // "tcp4", "tcp6" or "" for unforced + viaProxy bool + want int // expected status code +} + +// rchTargets is the probe list. Cloudflare's generate_204 is hit four ways +// because the paths fail independently. +// +// The direct and proxied Cloudflare probes deliberately use the same scheme: +// the point of running one target both ways is that the proxy setting is the +// only variable. Comparing plaintext-direct against TLS-proxied would let a +// middlebox that hijacks HTTP but passes HTTPS masquerade as "only the proxy +// works". The plaintext probe is kept separately, because that is exactly the +// signal a captive portal produces. +// +// The last two entries are mainland-China baselines. +func rchTargets() []rchTarget { + return []rchTarget{ + {name: "Cloudflare 204 (IPv4)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent}, + {name: "Cloudflare 204 (IPv6)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp6", want: http.StatusNoContent}, + {name: "Cloudflare 204 (代理)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, viaProxy: true, want: http.StatusNoContent}, + {name: "Cloudflare 204 (明文/门户检测)", url: "http://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent}, + {name: "Gstatic 204", url: "http://www.gstatic.com/generate_204", region: RegionIntl, want: http.StatusNoContent}, + {name: "Google 204", url: "https://www.google.com/generate_204", region: RegionIntl, want: http.StatusNoContent}, + {name: "小米 204(国内基准)", url: "http://connect.rom.miui.com/generate_204", region: RegionCN, want: http.StatusNoContent}, + {name: "百度(国内基准)", url: "https://www.baidu.com", region: RegionCN, want: http.StatusOK}, + } +} + +// ProbeOverseas checks whether traffic can leave for the wider internet. +// +// Every target is probed concurrently with its own ~5s budget, so the whole +// section finishes in about that time no matter how many probes hang. Probes +// against mainland-China targets act as a baseline: when they succeed and the +// international ones do not, the line is up but egress is filtered, which is a +// warning rather than a failure. +// +// A response that arrives with an unexpected status is recorded, not +// discarded — a captive portal or an injected block page is precisely what the +// user needs to see. +func ProbeOverseas(ctx context.Context, logger *slog.Logger) OverseasReport { + log := rchLog(logger) + targets := rchTargets() + + var ( + mu sync.Mutex + probes = make([]ReachProbe, 0, len(targets)) + wg sync.WaitGroup + sem = make(chan struct{}, rchMaxInflight) + ) + + for _, t := range targets { + wg.Add(1) + go func(t rchTarget) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + mu.Lock() + probes = append(probes, rchCancelled(t, ctx.Err())) + mu.Unlock() + return + } + p := rchProbeOne(ctx, t, log) + mu.Lock() + probes = append(probes, p) + mu.Unlock() + }(t) + } + wg.Wait() + + rchSortProbes(probes) + rep := OverseasReport{Probes: probes} + rchSummarize(&rep) + + log.With( + slog.Int("probes", len(rep.Probes)), + slog.String("status", rep.Status.String()), + ).Debug("finished overseas reachability probes") + + return rep +} + +// rchCancelled builds the placeholder entry for a probe that never started +// because the run was cancelled. The row still renders, which is better than a +// silently shorter table. +func rchCancelled(t rchTarget, err error) ReachProbe { + msg := "cancelled" + if err != nil { + msg = err.Error() + } + return ReachProbe{ + Name: t.name, + URL: t.url, + Region: t.region, + ViaProxy: t.viaProxy, + Network: t.network, + Err: msg, + } +} + +// rchProbeOne runs a single probe. It never returns an error: a failure is a +// datapoint, recorded in the probe's Err field. +func rchProbeOne(ctx context.Context, t rchTarget, log *slog.Logger) ReachProbe { + // ViaProxy must record what happened, not what was intended. A client + // built with http.ProxyFromEnvironment sends the request direct when no + // proxy is configured, and counting that as proof the proxy path works is + // how the summary ends up asserting "only the proxy link is usable" on a + // machine with no proxy at all. + usedProxy := t.viaProxy && diagProxyConfigured(t.url) + + p := ReachProbe{ + Name: t.name, + URL: t.url, + Region: t.region, + ViaProxy: usedProxy, + Network: t.network, + } + if t.viaProxy && !usedProxy { + p.Name = t.name + "(环境未配置代理,实际直连)" + } + + pctx, cancel := context.WithTimeout(ctx, rchTimeout) + defer cancel() + + client := newDiagClient(t.network, usedProxy, rchTimeout) + defer client.CloseIdleConnections() + + code, _, rtt, err := diagGet(pctx, client, t.url, rchMaxBody, nil) + p.RTT = rtt + p.StatusCode = code + switch { + case err != nil: + p.Err = rchErrText(err) + case code == t.want: + p.OK = true + default: + // Reachable, but something answered on the target's behalf. + p.Err = fmt.Sprintf("unexpected status %d (want %d), 可能存在门户劫持或内容注入", code, t.want) + } + + log.With( + slog.String("name", t.name), + slog.String("network", rchNetworkText(t.network)), + slog.Bool("via_proxy", t.viaProxy), + slog.Int("status", p.StatusCode), + slog.Duration("rtt", p.RTT), + slog.Bool("ok", p.OK), + ).Debug("reachability probe done") + + return p +} + +// rchErrText flattens a transport error into a short message. The URL is +// stripped because url.Error embeds the full target (and, for a proxied +// request, potentially proxy credentials) into its Error string. +func rchErrText(err error) string { + var ue *url.Error + if errors.As(err, &ue) && ue.Err != nil { + err = ue.Err + } + msg := err.Error() + switch { + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, context.Canceled): + return "cancelled" + } + if i := strings.IndexByte(msg, '\n'); i >= 0 { + msg = msg[:i] + } + return msg +} + +func rchNetworkText(n string) string { + if n == "" { + return "auto" + } + return n +} + +// rchSortProbes orders international probes before the CN baselines and is +// otherwise stable on URL/network/proxy, so consecutive refreshes render in +// exactly the same order. +func rchSortProbes(ps []ReachProbe) { + rank := func(r Region) int { + if r == RegionIntl { + return 0 + } + return 1 + } + sort.Slice(ps, func(i, j int) bool { + x, y := ps[i], ps[j] + if rank(x.Region) != rank(y.Region) { + return rank(x.Region) < rank(y.Region) + } + if x.URL != y.URL { + return x.URL < y.URL + } + if x.Network != y.Network { + return x.Network < y.Network + } + if x.ViaProxy != y.ViaProxy { + return !x.ViaProxy + } + return x.Name < y.Name + }) +} + +// rchSummarize derives Status and a one-line Chinese Summary. +// +// Any single successful international probe is enough for [StatusOK]: hosts +// without IPv6 are the norm, so a failed v6 probe alongside a working v4 one +// must not drag the verdict down. Only the CN baselines succeeding means the +// local network is fine but the wider internet is not reachable +// ([StatusWarn]); nothing succeeding at all is [StatusFail]. +func rchSummarize(rep *OverseasReport) { + var ( + intlOK, intlTotal int + cnOK, cnTotal int + proxyOK bool + directIntlOK bool + v6OK bool + hijacked int + ) + for _, p := range rep.Probes { + if p.Region == RegionIntl { + intlTotal++ + if p.OK { + intlOK++ + if p.ViaProxy { + proxyOK = true + } else { + directIntlOK = true + } + if p.Network == "tcp6" { + v6OK = true + } + } + } else { + cnTotal++ + if p.OK { + cnOK++ + } + } + if !p.OK && p.StatusCode > 0 { + hijacked++ + } + } + + var b strings.Builder + switch { + case intlOK > 0: + rep.Status = StatusOK + fmt.Fprintf(&b, "境外可达(%d/%d 个境外目标成功)", intlOK, intlTotal) + switch { + case proxyOK && !directIntlOK: + b.WriteString(",仅代理链路可用,直连被阻断") + case directIntlOK && !proxyOK && diagProxyConfigured("https://cp.cloudflare.com/generate_204"): + b.WriteString(",直连可用但代理链路失败") + } + if !v6OK { + b.WriteString(",IPv6 不可用(不影响判定)") + } + case cnOK > 0: + rep.Status = StatusWarn + fmt.Fprintf(&b, "境外不可达(0/%d),但本地网络正常:国内基准 %d/%d 通过,问题在跨境链路而非本机网络", intlTotal, cnOK, cnTotal) + default: + rep.Status = StatusFail + fmt.Fprintf(&b, "境内外目标均无法访问(0/%d),本机可能完全没有网络", intlTotal+cnTotal) + } + if hijacked > 0 { + fmt.Fprintf(&b, ";%d 个目标返回了非预期状态码,疑似门户或注入", hijacked) + } + rep.Summary = b.String() +} diff --git a/netdiag/run.go b/netdiag/run.go new file mode 100644 index 0000000..0e48d98 --- /dev/null +++ b/netdiag/run.go @@ -0,0 +1,484 @@ +package netdiag + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// Run executes the full diagnostic suite and returns a populated report. +// +// The phases are deliberately not all parallel. Interface enumeration, port +// mapping, overseas reachability and tailscale's own netcheck are independent +// and run together. The STUN-derived phases are staged: a burst of binding +// requests first, then NAT classification on its own socket, then egress +// discovery reusing the results we already have. Running all of them at once +// would triple the load on a handful of public STUN servers and make the +// mapping tests race each other's sockets. +// +// Run always returns a report, even when everything failed; partial results +// are the normal case on a broken network and are exactly what the user needs +// to see. +func Run(ctx context.Context, opt Options) *Report { + timeout := opt.Timeout + if timeout <= 0 { + timeout = DefaultTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + logger := opt.logger() + servers := opt.STUNServers + if len(servers) == 0 { + servers = DefaultSTUNServers() + } + + rep := &Report{StartedAt: time.Now()} + total := len(Steps) + if opt.Tailscale == nil { + total-- + } + if opt.SkipGeo { + total-- + } + + // Phases run concurrently, so each one's index has to be captured when it + // starts. Re-reading the shared counter on completion would make the + // "step N of M" label jump around and even count backwards. + type stepStart struct { + idx int + at time.Time + } + var ( + mu sync.Mutex + index int + started = make(map[string]stepStart) + ) + begin := func(key string) { + mu.Lock() + index++ + s := stepStart{idx: index, at: time.Now()} + started[key] = s + mu.Unlock() + opt.progress(Progress{Key: key, Title: stepTitle(key), Index: s.idx, Total: total}) + } + finish := func(key, errText string) { + mu.Lock() + s := started[key] + mu.Unlock() + opt.progress(Progress{ + Key: key, Title: stepTitle(key), Index: s.idx, Total: total, + Done: true, Err: errText, Elapsed: time.Since(s.at), + }) + } + + var wg sync.WaitGroup + + // --- independent probes ------------------------------------------------- + wg.Add(1) + go func() { + defer wg.Done() + begin("iface") + r := EnumerateInterfaces(ctx, logger) + mu.Lock() + rep.Interfaces = r + mu.Unlock() + finish("iface", r.Err) + }() + + wg.Add(1) + go func() { + defer wg.Done() + begin("portmap") + r := ProbePortMapping(ctx, logger) + mu.Lock() + rep.PortMap = r + mu.Unlock() + finish("portmap", "") + }() + + wg.Add(1) + go func() { + defer wg.Done() + begin("overseas") + r := ProbeOverseas(ctx, logger) + mu.Lock() + rep.Overseas = r + mu.Unlock() + finish("overseas", "") + }() + + if opt.Tailscale != nil { + wg.Add(1) + go func() { + defer wg.Done() + begin("tailscale") + tsCtx, tsCancel := context.WithTimeout(ctx, 20*time.Second) + defer tsCancel() + r, err := opt.Tailscale.Netcheck(tsCtx) + errText := "" + mu.Lock() + switch { + case r != nil: + rep.Tailscale = *r + case err != nil: + rep.Tailscale = TailscaleReport{Status: StatusSkipped, Err: err.Error()} + default: + rep.Tailscale = TailscaleReport{Status: StatusSkipped} + } + if err != nil { + errText = err.Error() + } + mu.Unlock() + finish("tailscale", errText) + }() + } else { + rep.Tailscale = TailscaleReport{ + Status: StatusSkipped, + Summary: "Tailscale 未运行,跳过内部状态检查", + } + } + + // --- STUN-derived chain ------------------------------------------------- + wg.Add(1) + go func() { + defer wg.Done() + + begin("udp") + var ( + stunResults []STUNResult + udpReport UDPReport + inner sync.WaitGroup + ) + inner.Add(2) + go func() { + defer inner.Done() + stunResults = ProbeSTUN(ctx, servers, logger) + }() + go func() { + defer inner.Done() + udpReport = ProbeUDP(ctx, servers, logger) + }() + inner.Wait() + + mu.Lock() + rep.UDP = udpReport + mu.Unlock() + finish("udp", "") + + begin("nat") + nat := ClassifyNAT(ctx, servers, logger) + mu.Lock() + rep.NAT = nat + mu.Unlock() + finish("nat", "") + + begin("egress") + egress := ProbeEgress(ctx, stunResults, logger) + mu.Lock() + rep.Egress = egress + mu.Unlock() + finish("egress", "") + + if opt.SkipGeo { + mu.Lock() + rep.Egress.Summary = strings.TrimSpace(rep.Egress.Summary + " 已跳过归属地查询。") + mu.Unlock() + return + } + + begin("geo") + mu.Lock() + target := rep.Egress + mu.Unlock() + AnnotateGeo(ctx, &target, opt.IPInfoToken, logger) + mu.Lock() + rep.Egress = target + mu.Unlock() + finish("geo", "") + }() + + wg.Wait() + + rep.FinishedAt = time.Now() + rep.Duration = rep.FinishedAt.Sub(rep.StartedAt) + rep.Status = worstStatus( + rep.Interfaces.Status, + rep.UDP.Status, + rep.NAT.Status, + rep.PortMap.Status, + rep.Overseas.Status, + rep.Egress.Status, + rep.Tailscale.Status, + ) + rep.Headline = headline(rep) + logger.Info("diagnostics finished", + "took", rep.Duration.Round(time.Millisecond), + "status", rep.Status.String(), + "headline", rep.Headline, + ) + return rep +} + +func stepTitle(key string) string { + for _, s := range Steps { + if s.Key == key { + return s.Title + } + } + 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 { + switch { + case r.NAT.Type == NATUDPBlocked: + return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继" + case !r.UDP.V4OK && !r.UDP.V6OK: + return "UDP 探测全部失败,请检查防火墙或网络策略" + case r.NAT.Type == NATSymmetric: + return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继" + case r.Overseas.Status == StatusFail: + return "无法访问任何外部网络" + case r.Overseas.Status == StatusWarn: + return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响" + case r.Egress.Divergent: + return "检测到多个出口 IP,代理或分流工具正在影响连接" + case r.PortMap.Status == StatusWarn && r.NAT.Type == NATPortRestrict: + return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般" + case r.Status == StatusOK: + return "网络状况良好,具备直连条件" + default: + return "诊断完成,存在若干需要注意的项目" + } +} + +// --------------------------------------------------------------------------- +// Text report +// --------------------------------------------------------------------------- + +// Text renders the report as a plain-text block suitable for pasting into an +// issue or a paste service. It contains no credentials, but it does contain +// the machine's public and private addresses, which is unavoidable for a +// network diagnostic and worth telling the user before they share it. +func (r *Report) Text() string { + if r == nil { + return "" + } + var b strings.Builder + w := func(format string, args ...any) { fmt.Fprintf(&b, format, args...) } + + w("=== tslink 网络诊断报告 ===\n") + w("时间: %s\n", r.StartedAt.Format(time.RFC3339)) + w("耗时: %s\n", r.Duration.Round(time.Millisecond)) + w("总评: [%s] %s\n\n", strings.ToUpper(r.Status.String()), r.Headline) + + // --- interfaces -------------------------------------------------------- + w("--- 本机地址 [%s] ---\n", r.Interfaces.Status) + if r.Interfaces.Summary != "" { + w("%s\n", r.Interfaces.Summary) + } + if r.Interfaces.DefaultV4Src.IsValid() { + w("默认 IPv4 源: %s\n", r.Interfaces.DefaultV4Src) + } + if r.Interfaces.DefaultV6Src.IsValid() { + w("默认 IPv6 源: %s\n", r.Interfaces.DefaultV6Src) + } + for _, a := range r.Interfaces.Addrs { + flag := "" + if a.IsDefaultSrc { + flag = " *默认出口" + } + w(" %-14s %-40s %-10s%s\n", a.Iface, a.Addr.String(), a.Kind, flag) + } + if r.Interfaces.Err != "" { + w("错误: %s\n", r.Interfaces.Err) + } + b.WriteByte('\n') + + // --- udp --------------------------------------------------------------- + w("--- UDP 连通性 [%s] ---\n", r.UDP.Status) + if r.UDP.Summary != "" { + w("%s\n", r.UDP.Summary) + } + w("IPv4: %v IPv6: %v 国内 %d/%d 国外 %d/%d\n", + r.UDP.V4OK, r.UDP.V6OK, + r.UDP.CNReachable, r.UDP.CNTotal, r.UDP.IntlReachabl, r.UDP.IntlTotal) + if len(r.UDP.BlockedPorts) > 0 { + w("疑似被封端口: %v\n", r.UDP.BlockedPorts) + } + for _, p := range r.UDP.Probes { + status := "FAIL" + detail := p.Err + if p.OK { + status = "OK" + detail = p.Mapped.String() + " " + p.RTT.Round(time.Millisecond).String() + } + w(" %-4s %-34s %-5s %s\n", status, p.Target, p.Region, detail) + } + b.WriteByte('\n') + + // --- nat --------------------------------------------------------------- + w("--- NAT 类型 [%s] ---\n", r.NAT.Status) + w("类型: %s\n", r.NAT.Type) + w("映射行为: %s\n", r.NAT.Mapping) + w("过滤行为: %s\n", r.NAT.Filtering) + w("发夹回环: %s\n", triState(r.NAT.Hairpin)) + w("端口保持: %s\n", triState(r.NAT.PortPreserving)) + if len(r.NAT.MappedAddrs) > 0 { + addrs := make([]string, 0, len(r.NAT.MappedAddrs)) + for _, a := range r.NAT.MappedAddrs { + addrs = append(addrs, a.String()) + } + w("观测到的映射地址: %s\n", strings.Join(addrs, ", ")) + } + if r.NAT.Summary != "" { + w("%s\n", r.NAT.Summary) + } + for _, n := range r.NAT.Notes { + w("注: %s\n", n) + } + b.WriteByte('\n') + + // --- port mapping ------------------------------------------------------ + w("--- 端口映射 [%s] ---\n", r.PortMap.Status) + if r.PortMap.Gateway.IsValid() { + w("网关: %s\n", r.PortMap.Gateway) + } + writeService(&b, "UPnP IGD", r.PortMap.UPnP) + writeService(&b, "NAT-PMP ", r.PortMap.NATPMP) + writeService(&b, "PCP ", r.PortMap.PCP) + if r.PortMap.Summary != "" { + w("%s\n", r.PortMap.Summary) + } + b.WriteByte('\n') + + // --- overseas ---------------------------------------------------------- + w("--- 境外连通性 [%s] ---\n", r.Overseas.Status) + if r.Overseas.Summary != "" { + w("%s\n", r.Overseas.Summary) + } + for _, p := range r.Overseas.Probes { + status := "FAIL" + if p.OK { + status = "OK" + } + via := "direct" + if p.ViaProxy { + via = "proxy" + } + net := p.Network + if net == "" { + net = "auto" + } + detail := p.RTT.Round(time.Millisecond).String() + if p.Err != "" { + detail = p.Err + } + w(" %-4s %-3d %-6s %-5s %-46s %s\n", status, p.StatusCode, via, net, p.URL, detail) + } + b.WriteByte('\n') + + // --- egress ------------------------------------------------------------ + w("--- 出口 IP [%s] ---\n", r.Egress.Status) + if r.Egress.Summary != "" { + w("%s\n", r.Egress.Summary) + } + if r.Egress.Divergent { + w("!! 不同探测方式得到了不同的公网 IP,通常说明有代理或分流在生效\n") + } + for _, o := range r.Egress.Observations { + val := o.IP.String() + if !o.IP.IsValid() { + val = "(" + o.Err + ")" + } + w(" %-11s %-5s %-40s %s\n", o.Method, o.Region, o.Source, val) + } + for _, g := range r.Egress.Geo { + if g.Err != "" && g.Provider == "" { + w(" %-40s %s\n", g.IP.String(), g.Err) + continue + } + parts := []string{} + for _, p := range []string{g.CountryName, g.Country, g.Region, g.City} { + if p != "" { + parts = append(parts, p) + } + } + w(" %-40s %s | %s %s (via %s)\n", + g.IP.String(), strings.Join(parts, " "), g.ASN, g.Org, g.Provider) + } + b.WriteByte('\n') + + // --- tailscale --------------------------------------------------------- + w("--- Tailscale 内部状态 [%s] ---\n", r.Tailscale.Status) + if r.Tailscale.Summary != "" { + w("%s\n", r.Tailscale.Summary) + } + if r.Tailscale.Available { + w("UDP: %v IPv4: %v IPv6: %v ICMPv4: %v\n", + r.Tailscale.UDP, r.Tailscale.IPv4, r.Tailscale.IPv6, r.Tailscale.ICMPv4) + w("UPnP: %s PMP: %s PCP: %s\n", + triState(r.Tailscale.UPnP), triState(r.Tailscale.PMP), triState(r.Tailscale.PCP)) + w("映射随目标变化: %s 门户劫持: %s\n", + triState(r.Tailscale.MappingVariesByDestIP), triState(r.Tailscale.CaptivePortal)) + if r.Tailscale.GlobalV4 != "" { + w("GlobalV4: %s\n", r.Tailscale.GlobalV4) + } + if r.Tailscale.GlobalV6 != "" { + w("GlobalV6: %s\n", r.Tailscale.GlobalV6) + } + w("首选 DERP: %s\n", r.Tailscale.PreferredDERP) + derp := append([]DERPLatency(nil), r.Tailscale.DERP...) + sort.Slice(derp, func(i, j int) bool { return derp[i].Latency < derp[j].Latency }) + for i, d := range derp { + if i >= 8 { + break + } + mark := " " + if d.Preferred { + mark = "*" + } + w(" %s %-6s %-24s %s\n", mark, d.RegionCode, d.Name, + d.Latency.Round(time.Millisecond)) + } + } + if r.Tailscale.Err != "" { + w("错误: %s\n", r.Tailscale.Err) + } + + return b.String() +} + +func writeService(b *strings.Builder, name string, s ServiceProbe) { + status := "不支持" + if s.Available { + status = "支持" + } + line := " " + name + ": " + status + if s.ExternalIP.IsValid() { + line += " 外部地址 " + s.ExternalIP.String() + } + if s.Detail != "" { + line += " " + s.Detail + } + if s.Err != "" { + line += " (" + s.Err + ")" + } + b.WriteString(line + "\n") +} + +func triState(v *bool) string { + if v == nil { + return "未知" + } + if *v { + return "是" + } + return "否" +} diff --git a/netdiag/servers.go b/netdiag/servers.go new file mode 100644 index 0000000..4201d99 --- /dev/null +++ b/netdiag/servers.go @@ -0,0 +1,55 @@ +package netdiag + +// This file holds the STUN probe target lists. The default list deliberately +// mixes mainland-China and international servers: when a proxy, a split tunnel +// or the GFW is in play the two groups disagree, and that disagreement is the +// diagnostic signal we are after. Probing only one side would hide it. + +// DefaultSTUNServers returns the built-in probe list, covering both mainland +// China (RegionCN) and international (RegionIntl) targets. +// +// A fresh slice is returned on every call so callers may reorder or trim it +// without affecting anyone else. +func DefaultSTUNServers() []STUNServer { + return []STUNServer{ + // Mainland China. These answer fast from inside the country and are the + // baseline for "does UDP work at all on this line". + {Host: "stun.miwifi.com:3478", Name: "小米", Region: RegionCN}, + {Host: "stun.chat.bilibili.com:3478", Name: "哔哩哔哩", Region: RegionCN}, + {Host: "stun.qq.com:3478", Name: "腾讯", Region: RegionCN}, + {Host: "stun.hitv.com:3478", Name: "芒果TV", Region: RegionCN}, + // Anycast: usually lands on an in-country PoP, so it is grouped with CN + // even though the operator is not Chinese. + {Host: "turn.cloudflare.com:3478", Name: "Cloudflare(任播)", Region: RegionCN}, + + // International. Failures here while the CN group succeeds mean egress + // to the wider internet is filtered rather than UDP being dead. + {Host: "stun.l.google.com:19302", Name: "Google", Region: RegionIntl}, + {Host: "stun.cloudflare.com:3478", Name: "Cloudflare", Region: RegionIntl}, + {Host: "stun.nextcloud.com:3478", Name: "Nextcloud", Region: RegionIntl}, + {Host: "stun.voip.blackberry.com:3478", Name: "BlackBerry", Region: RegionIntl}, + {Host: "stun.sipnet.net:3478", Name: "SipNet", Region: RegionIntl}, + {Host: "stun.stunprotocol.org:3478", Name: "StunProtocol", Region: RegionIntl}, + {Host: "stun.voipgate.com:3478", Name: "VoIPGate", Region: RegionIntl}, + } +} + +// RFC5780Servers returns the subset of targets known to implement RFC 5780 +// behaviour discovery, i.e. they advertise OTHER-ADDRESS and actually honour +// CHANGE-REQUEST by answering from a second IP and/or port. +// +// Only these servers can drive the filtering-behaviour test in [ClassifyNAT]. +// Most large providers — Google and Cloudflare among them — answer plain +// binding requests perfectly well but silently ignore CHANGE-REQUEST and never +// send OTHER-ADDRESS, so a probe against them looks identical to a firewall +// dropping the reply. Classification therefore has to degrade gracefully: when +// none of these servers answers, filtering behaviour stays +// [BehaviorUnknown] and the NAT type is reported as [NATUnknown] with an +// explanatory note rather than being guessed. +func RFC5780Servers() []STUNServer { + return []STUNServer{ + {Host: "stun.stunprotocol.org:3478", Name: "StunProtocol", Region: RegionIntl}, + {Host: "stun.sipnet.net:3478", Name: "SipNet", Region: RegionIntl}, + {Host: "stun.voipgate.com:3478", Name: "VoIPGate", Region: RegionIntl}, + } +} diff --git a/netdiag/stun.go b/netdiag/stun.go new file mode 100644 index 0000000..9c65564 --- /dev/null +++ b/netdiag/stun.go @@ -0,0 +1,1367 @@ +package netdiag + +// A minimal STUN implementation (RFC 5389) plus the behaviour-discovery bits of +// RFC 5780. tailscale.com/net/stun is deliberately not used: it parses binding +// responses only and has no CHANGE-REQUEST or OTHER-ADDRESS support, which are +// exactly what NAT filtering classification needs. +// +// Everything here parses hostile input from the open internet, so the parser +// skips attributes it does not know and returns errors — never panics — on +// truncated ones. + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "log/slog" + "net" + "net/netip" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// --------------------------------------------------------------------------- +// wire format +// --------------------------------------------------------------------------- + +const ( + stunHeaderSize = 20 + stunMaxMessage = 1500 + // stunMagicCookie is the fixed cookie every RFC 5389 message carries; it is + // also the XOR key for XOR-MAPPED-ADDRESS. + stunMagicCookie uint32 = 0x2112A442 +) + +// message types +const ( + stunBindingRequest uint16 = 0x0001 + stunBindingSuccess uint16 = 0x0101 + stunBindingError uint16 = 0x0111 +) + +// attribute types +const ( + stunAttrMappedAddress uint16 = 0x0001 + stunAttrChangeRequest uint16 = 0x0003 + stunAttrSourceAddress uint16 = 0x0004 + stunAttrChangedAddress uint16 = 0x0005 + stunAttrErrorCode uint16 = 0x0009 + stunAttrXORMappedAddress uint16 = 0x0020 + stunAttrXORMappedAddrAlt uint16 = 0x8020 // legacy/vendor duplicate + stunAttrSoftware uint16 = 0x8022 + stunAttrFingerprint uint16 = 0x8028 + stunAttrResponseOrigin uint16 = 0x802b + stunAttrOtherAddress uint16 = 0x802c +) + +// CHANGE-REQUEST flags. +const ( + stunChangeIP byte = 0x04 + stunChangePort byte = 0x02 +) + +// address families used by address attributes +const ( + stunFamilyV4 byte = 0x01 + stunFamilyV6 byte = 0x02 +) + +// timing and concurrency limits. Nothing here may block a GUI panel, so every +// wait is bounded twice: by these constants and by the caller's context. +const ( + stunAttempts = 3 + stunInterval = 700 * time.Millisecond + stunQuickAttempts = 2 + stunQuickInterval = 600 * time.Millisecond + stunProbeTimeout = 3 * time.Second + stunResolveTimeout = 3 * time.Second + stunHairpinTimeout = 1500 * time.Millisecond + stunMaxInFlight = 8 + natTotalBudget = 20 * time.Second +) + +var ( + errSTUNTruncated = errors.New("stun: truncated message") + errSTUNBadCookie = errors.New("stun: bad magic cookie") + errSTUNTimeout = errors.New("stun: no response") + errSTUNFamily = errors.New("stun: unsupported address family") + errSTUNNoAddr = errors.New("stun: host resolved to no addresses") +) + +// stunAttr is one type-length-value attribute. Value excludes the padding that +// aligns the attribute to a 4-byte boundary on the wire. +type stunAttr struct { + Type uint16 + Value []byte +} + +// stunMessage is a decoded STUN message. +type stunMessage struct { + Type uint16 + TxID [12]byte + Attrs []stunAttr +} + +// encode serialises the message, padding every attribute to a 4-byte boundary +// with zeroes while keeping the declared length free of that padding. +func (m *stunMessage) encode() []byte { + body := make([]byte, 0, 64) + var hdr [4]byte + for _, a := range m.Attrs { + binary.BigEndian.PutUint16(hdr[0:2], a.Type) + binary.BigEndian.PutUint16(hdr[2:4], uint16(len(a.Value))) + body = append(body, hdr[:]...) + body = append(body, a.Value...) + if pad := (4 - len(a.Value)%4) % 4; pad > 0 { + body = append(body, make([]byte, pad)...) + } + } + out := make([]byte, stunHeaderSize, stunHeaderSize+len(body)) + binary.BigEndian.PutUint16(out[0:2], m.Type) + binary.BigEndian.PutUint16(out[2:4], uint16(len(body))) + binary.BigEndian.PutUint32(out[4:8], stunMagicCookie) + copy(out[8:20], m.TxID[:]) + return append(out, body...) +} + +// parseSTUNMessage decodes raw. Unknown attributes are kept but never +// interpreted; a truncated header or attribute is an error rather than a panic, +// because this runs on unauthenticated packets from the internet. +func parseSTUNMessage(raw []byte) (*stunMessage, error) { + if len(raw) < stunHeaderSize { + return nil, errSTUNTruncated + } + msgLen := int(binary.BigEndian.Uint16(raw[2:4])) + if len(raw)-stunHeaderSize < msgLen { + return nil, errSTUNTruncated + } + // RFC 5389 §6 requires the magic cookie. Without this check any 20-byte + // datagram whose bytes 8..20 happen to match our transaction ID would be + // accepted, and its XOR-MAPPED-ADDRESS would be de-XORed with a key the + // sender never used — producing a silently wrong reflexive address rather + // than an error. + if binary.BigEndian.Uint32(raw[4:8]) != stunMagicCookie { + return nil, errSTUNBadCookie + } + m := &stunMessage{Type: binary.BigEndian.Uint16(raw[0:2])} + copy(m.TxID[:], raw[8:20]) + + body := raw[stunHeaderSize : stunHeaderSize+msgLen] + for off := 0; off < len(body); { + if len(body)-off < 4 { + return nil, errSTUNTruncated + } + typ := binary.BigEndian.Uint16(body[off : off+2]) + vlen := int(binary.BigEndian.Uint16(body[off+2 : off+4])) + off += 4 + if len(body)-off < vlen { + return nil, errSTUNTruncated + } + val := make([]byte, vlen) + copy(val, body[off:off+vlen]) + m.Attrs = append(m.Attrs, stunAttr{Type: typ, Value: val}) + off += vlen + // Missing trailing padding on the last attribute is tolerated: some + // servers omit it and the message is still perfectly usable. + if pad := (4 - vlen%4) % 4; pad > 0 { + if len(body)-off < pad { + break + } + off += pad + } + } + return m, nil +} + +// attr returns the value of the first attribute of type t. +func (m *stunMessage) attr(t uint16) ([]byte, bool) { + for _, a := range m.Attrs { + if a.Type == t { + return a.Value, true + } + } + return nil, false +} + +// mappedAddr returns the server-reflexive address, preferring the XOR forms +// (which survive NATs that rewrite payloads) over the plain one. +func (m *stunMessage) mappedAddr() (netip.AddrPort, bool) { + for _, t := range []uint16{stunAttrXORMappedAddress, stunAttrXORMappedAddrAlt} { + if v, ok := m.attr(t); ok { + if ap, err := stunDecodeAddr(v, true, m.TxID); err == nil { + return ap, true + } + } + } + if v, ok := m.attr(stunAttrMappedAddress); ok { + if ap, err := stunDecodeAddr(v, false, m.TxID); err == nil { + return ap, true + } + } + return netip.AddrPort{}, false +} + +// otherAddr returns the alternate transport address the server advertises: +// OTHER-ADDRESS (RFC 5780) or, for older servers, CHANGED-ADDRESS (RFC 3489). +func (m *stunMessage) otherAddr() (netip.AddrPort, bool) { + for _, t := range []uint16{stunAttrOtherAddress, stunAttrChangedAddress} { + if v, ok := m.attr(t); ok { + if ap, err := stunDecodeAddr(v, false, m.TxID); err == nil && ap.IsValid() { + return ap, true + } + } + } + return netip.AddrPort{}, false +} + +// software returns the SOFTWARE attribute, trimmed, or "". +func (m *stunMessage) software() string { + v, ok := m.attr(stunAttrSoftware) + if !ok { + return "" + } + return strings.TrimSpace(strings.ToValidUTF8(string(v), "")) +} + +// errorCode decodes ERROR-CODE from an error response. +func (m *stunMessage) errorCode() (int, string, bool) { + v, ok := m.attr(stunAttrErrorCode) + if !ok || len(v) < 4 { + return 0, "", false + } + code := int(v[2]&0x07)*100 + int(v[3]) + return code, strings.TrimSpace(strings.ToValidUTF8(string(v[4:]), "")), true +} + +// stunDecodeAddr decodes an address attribute payload: +// reserved byte, family, port, address. When xor is set the port is XORed with +// the high half of the magic cookie and the address with the cookie (IPv4) or +// the cookie followed by the transaction ID (IPv6). +func stunDecodeAddr(v []byte, xor bool, txid [12]byte) (netip.AddrPort, error) { + if len(v) < 4 { + return netip.AddrPort{}, errSTUNTruncated + } + var n int + switch v[1] { + case stunFamilyV4: + n = 4 + case stunFamilyV6: + n = 16 + default: + return netip.AddrPort{}, errSTUNFamily + } + if len(v) < 4+n { + return netip.AddrPort{}, errSTUNTruncated + } + port := binary.BigEndian.Uint16(v[2:4]) + raw := make([]byte, n) + copy(raw, v[4:4+n]) + if xor { + port ^= uint16(stunMagicCookie >> 16) + var mask [16]byte + binary.BigEndian.PutUint32(mask[0:4], stunMagicCookie) + copy(mask[4:], txid[:]) + for i := range raw { + raw[i] ^= mask[i] + } + } + addr, ok := netip.AddrFromSlice(raw) + if !ok { + return netip.AddrPort{}, errSTUNFamily + } + return netip.AddrPortFrom(addr.Unmap(), port), nil +} + +// stunEncodeAddr is the inverse of [stunDecodeAddr]. +func stunEncodeAddr(ap netip.AddrPort, xor bool, txid [12]byte) []byte { + addr := ap.Addr().Unmap() + raw := addr.AsSlice() + fam := stunFamilyV6 + if addr.Is4() { + fam = stunFamilyV4 + } + port := ap.Port() + if xor { + port ^= uint16(stunMagicCookie >> 16) + var mask [16]byte + binary.BigEndian.PutUint32(mask[0:4], stunMagicCookie) + copy(mask[4:], txid[:]) + for i := range raw { + raw[i] ^= mask[i] + } + } + out := make([]byte, 4, 4+len(raw)) + out[1] = fam + binary.BigEndian.PutUint16(out[2:4], port) + return append(out, raw...) +} + +// stunBindingRequestMsg builds a binding request with a fresh transaction ID, +// optionally carrying a CHANGE-REQUEST attribute built from the given flags. +func stunBindingRequestMsg(change byte) *stunMessage { + m := &stunMessage{Type: stunBindingRequest} + // crypto/rand.Read is documented never to fail. + _, _ = rand.Read(m.TxID[:]) + if change != 0 { + v := make([]byte, 4) + v[3] = change + m.Attrs = append(m.Attrs, stunAttr{Type: stunAttrChangeRequest, Value: v}) + } + return m +} + +// stunResponseFor parses raw and reports whether it is a binding response +// belonging to txid. Anything else — garbage, a stray packet, or a response to +// a different transaction — is rejected so the transactor keeps waiting. +func stunResponseFor(raw []byte, txid [12]byte) (*stunMessage, bool) { + msg, err := parseSTUNMessage(raw) + if err != nil { + return nil, false + } + if msg.TxID != txid { + return nil, false + } + if msg.Type != stunBindingSuccess && msg.Type != stunBindingError { + return nil, false + } + return msg, true +} + +// --------------------------------------------------------------------------- +// transport +// --------------------------------------------------------------------------- + +// stunLog falls back to the default logger when the caller passed nil. +func stunLog(l *slog.Logger) *slog.Logger { + if l == nil { + return slog.Default() + } + return l +} + +// stunListen binds an unconnected wildcard UDP socket of the requested family. +func stunListen(v4 bool) (*net.UDPConn, error) { + if v4 { + return net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + } + return net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6unspecified, Port: 0}) +} + +func stunUnmap(ap netip.AddrPort) netip.AddrPort { + return netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port()) +} + +// stunTransact sends req on conn and waits for the matching response, +// retransmitting up to attempts times roughly interval apart. Read deadlines +// are clamped to the context deadline and the context also aborts an in-flight +// read, so this can never outlive its caller. +func stunTransact(ctx context.Context, conn *net.UDPConn, dst netip.AddrPort, req *stunMessage, attempts int, interval time.Duration) (*stunMessage, netip.AddrPort, time.Duration, error) { + if attempts <= 0 { + attempts = 1 + } + if interval <= 0 { + interval = stunInterval + } + raw := req.encode() + buf := make([]byte, stunMaxMessage) + + stop := context.AfterFunc(ctx, func() { _ = conn.SetReadDeadline(time.Now()) }) + defer stop() + defer func() { _ = conn.SetReadDeadline(time.Time{}) }() + + for i := 0; i < attempts; i++ { + if err := ctx.Err(); err != nil { + return nil, netip.AddrPort{}, 0, err + } + sent := time.Now() + if _, err := conn.WriteToUDPAddrPort(raw, dst); err != nil { + return nil, netip.AddrPort{}, 0, err + } + deadline := sent.Add(interval) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + for time.Now().Before(deadline) { + if err := conn.SetReadDeadline(deadline); err != nil { + return nil, netip.AddrPort{}, 0, err + } + n, from, err := conn.ReadFromUDPAddrPort(buf) + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + break + } + return nil, netip.AddrPort{}, 0, err + } + msg, ok := stunResponseFor(buf[:n], req.TxID) + if !ok { + continue // stray packet or foreign transaction id + } + return msg, stunUnmap(from), time.Since(sent), nil + } + if ctx.Err() != nil { + break + } + } + if err := ctx.Err(); err != nil { + return nil, netip.AddrPort{}, 0, err + } + return nil, netip.AddrPort{}, 0, errSTUNTimeout +} + +// stunQuery runs one transaction against dst on a throwaway socket of the +// destination's own family. +func stunQuery(ctx context.Context, dst netip.AddrPort, change byte, attempts int, interval time.Duration) (*stunMessage, netip.AddrPort, time.Duration, error) { + conn, err := stunListen(dst.Addr().Is4()) + if err != nil { + return nil, netip.AddrPort{}, 0, err + } + defer conn.Close() + return stunTransact(ctx, conn, dst, stunBindingRequestMsg(change), attempts, interval) +} + +// stunResolve splits a "host:port" target and resolves the host to IP +// addresses, IPv4 first. Both families are returned so a dead AAAA record can +// never mask a working A record, and so callers can report which family worked. +func stunResolve(ctx context.Context, hostport string) ([]netip.Addr, uint16, error) { + host, portStr, err := net.SplitHostPort(hostport) + if err != nil { + return nil, 0, err + } + portNum, err := strconv.Atoi(portStr) + if err != nil || portNum <= 0 || portNum > 65535 { + return nil, 0, fmt.Errorf("stun: invalid port %q", portStr) + } + port := uint16(portNum) + if a, err := netip.ParseAddr(host); err == nil { + return []netip.Addr{a.Unmap()}, port, nil + } + rctx, cancel := context.WithTimeout(ctx, stunResolveTimeout) + defer cancel() + addrs, err := net.DefaultResolver.LookupNetIP(rctx, "ip", host) + if err != nil { + return nil, port, err + } + var v4, v6 []netip.Addr + for _, a := range addrs { + a = a.Unmap() + if a.Is4() { + v4 = append(v4, a) + } else { + v6 = append(v6, a) + } + } + // Sort so repeated runs probe the same address despite DNS round-robin. + sort.Slice(v4, func(i, j int) bool { return v4[i].Compare(v4[j]) < 0 }) + sort.Slice(v6, func(i, j int) bool { return v6[i].Compare(v6[j]) < 0 }) + out := append(v4, v6...) + if len(out) == 0 { + return nil, port, errSTUNNoAddr + } + return out, port, nil +} + +// stunHasLocalIP reports whether a is bound to one of this machine's +// interfaces, i.e. the reflexive address is not translated at all. +func stunHasLocalIP(a netip.Addr) bool { + if !a.IsValid() { + return false + } + addrs, err := net.InterfaceAddrs() + if err != nil { + return false + } + for _, ia := range addrs { + var ip net.IP + switch v := ia.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + default: + continue + } + if got, ok := netip.AddrFromSlice(ip); ok && got.Unmap() == a { + return true + } + } + return false +} + +func sortAddrPorts(s []netip.AddrPort) { + sort.Slice(s, func(i, j int) bool { + if c := s[i].Addr().Compare(s[j].Addr()); c != 0 { + return c < 0 + } + return s[i].Port() < s[j].Port() + }) +} + +// --------------------------------------------------------------------------- +// ProbeSTUN +// --------------------------------------------------------------------------- + +// ProbeSTUN runs one binding transaction against each server, in parallel. +// +// Results are returned in the same order as servers so the UI does not jitter +// between refreshes. An empty servers list falls back to [DefaultSTUNServers]. +func ProbeSTUN(ctx context.Context, servers []STUNServer, logger *slog.Logger) []STUNResult { + log := stunLog(logger).With(slog.String("from", "netdiag/stun")) + if len(servers) == 0 { + servers = DefaultSTUNServers() + } + out := make([]STUNResult, len(servers)) + sem := make(chan struct{}, stunMaxInFlight) + var mu sync.Mutex + var wg sync.WaitGroup + for i, srv := range servers { + wg.Add(1) + go func() { + defer wg.Done() + res := STUNResult{Server: srv.Host, Name: srv.Name, Region: srv.Region} + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + res.Err = ctx.Err().Error() + mu.Lock() + out[i] = res + mu.Unlock() + return + } + res = stunProbeServer(ctx, srv, log) + mu.Lock() + out[i] = res + mu.Unlock() + }() + } + wg.Wait() + return out +} + +// stunProbeServer probes one server, trying every resolved address (IPv4 first) +// until one answers. +func stunProbeServer(ctx context.Context, srv STUNServer, log *slog.Logger) STUNResult { + res := STUNResult{Server: srv.Host, Name: srv.Name, Region: srv.Region} + addrs, port, err := stunResolve(ctx, srv.Host) + if err != nil { + res.Err = err.Error() + log.With(slog.String("server", srv.Host), slog.String("error", err.Error())).Debug("stun resolve failed") + return res + } + for _, a := range addrs { + dst := netip.AddrPortFrom(a, port) + pctx, cancel := context.WithTimeout(ctx, stunProbeTimeout) + msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval) + cancel() + if err != nil { + res.Err = err.Error() + continue + } + if code, reason, ok := msg.errorCode(); ok { + res.Err = fmt.Sprintf("stun error %d %s", code, reason) + continue + } + mapped, hasMapped := msg.mappedAddr() + if !hasMapped { + res.Err = "stun: response without mapped address" + continue + } + res.OK = true + res.Err = "" + res.RTT = rtt + res.Mapped = mapped + res.Software = msg.software() + if other, ok := msg.otherAddr(); ok { + res.Other = other + // Only worth asking for a CHANGE-REQUEST when the server has an + // alternate address to answer from; a silent drop otherwise costs a + // full timeout and tells us nothing. + cctx, ccancel := context.WithTimeout(ctx, stunQuickInterval*time.Duration(stunQuickAttempts+1)) + crMsg, crFrom, _, crErr := stunQuery(cctx, dst, stunChangeIP|stunChangePort, stunQuickAttempts, stunQuickInterval) + ccancel() + res.SupportsChangeReq = crErr == nil && crMsg != nil && crFrom != dst + } + log.With( + slog.String("server", srv.Host), + slog.String("mapped", mapped.String()), + slog.Duration("rtt", rtt), + ).Debug("stun binding ok") + break + } + return res +} + +// --------------------------------------------------------------------------- +// ProbeUDP +// --------------------------------------------------------------------------- + +// udpAttempt pairs a probe with the address family it used; the contract's +// UDPProbe has no family field, so it is tracked alongside. +type udpAttempt struct { + probe UDPProbe + v6 bool + // resolveFailed marks a probe that never reached the send stage because + // the hostname would not resolve. Its port must not count towards the + // blocked-port analysis: a failed A-record lookup says nothing about + // whether UDP on that port can leave the machine. + resolveFailed bool +} + +// ProbeUDP reports whether UDP can leave the machine at all, over v4 and v6, +// and on which destination ports. +// +// Each server is resolved to both A and AAAA records and probed once per +// family, so a broken IPv6 path is visible instead of being hidden behind a +// working IPv4 one. Probes run in parallel; the report is ordered by the input +// server list. +func ProbeUDP(ctx context.Context, servers []STUNServer, logger *slog.Logger) UDPReport { + log := stunLog(logger).With(slog.String("from", "netdiag/udp")) + if len(servers) == 0 { + servers = DefaultSTUNServers() + } + var rep UDPReport + + per := make([][]udpAttempt, len(servers)) + sem := make(chan struct{}, stunMaxInFlight) + var mu sync.Mutex + var wg sync.WaitGroup + for i, srv := range servers { + wg.Add(1) + go func() { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + // Record the cancellation instead of dropping the server: + // leaving per[i] nil would still count it towards the + // CN/international totals while producing no evidence row, so + // the report would claim servers were unreachable that were + // never contacted. + mu.Lock() + per[i] = []udpAttempt{{ + probe: UDPProbe{ + Target: srv.Host, + Name: srv.Name, + Region: srv.Region, + Err: ctx.Err().Error(), + }, + resolveFailed: true, + }} + mu.Unlock() + return + } + got := stunProbeUDPServer(ctx, srv, log) + mu.Lock() + per[i] = got + mu.Unlock() + }() + } + wg.Wait() + + portOK := map[int]bool{} + portSeen := map[int]bool{} + for i, srv := range servers { + serverOK := false + for _, at := range per[i] { + rep.Probes = append(rep.Probes, at.probe) + if at.probe.Port > 0 && !at.resolveFailed { + portSeen[at.probe.Port] = true + } + if !at.probe.OK { + continue + } + serverOK = true + if at.probe.Port > 0 { + portOK[at.probe.Port] = true + } + if at.v6 { + rep.V6OK = true + } else { + rep.V4OK = true + } + } + switch srv.Region { + case RegionCN: + rep.CNTotal++ + if serverOK { + rep.CNReachable++ + } + default: + rep.IntlTotal++ + if serverOK { + rep.IntlReachabl++ + } + } + } + + for p := range portSeen { + if portOK[p] { + rep.OKPorts = append(rep.OKPorts, p) + } + } + // A port only counts as blocked when some other port worked; otherwise UDP + // as a whole is down and singling out ports would be misleading. + if len(rep.OKPorts) > 0 { + for p := range portSeen { + if !portOK[p] { + rep.BlockedPorts = append(rep.BlockedPorts, p) + } + } + } + sort.Ints(rep.OKPorts) + sort.Ints(rep.BlockedPorts) + + rep.Status, rep.Summary = stunUDPVerdict(&rep) + log.With( + slog.Bool("v4", rep.V4OK), + slog.Bool("v6", rep.V6OK), + slog.Int("cn", rep.CNReachable), + slog.Int("intl", rep.IntlReachabl), + ).Info("udp reachability probed") + return rep +} + +// stunProbeUDPServer probes one server once per resolved address family. +func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) []udpAttempt { + addrs, port, err := stunResolve(ctx, srv.Host) + if err != nil { + return []udpAttempt{{ + probe: UDPProbe{ + Target: srv.Host, + Name: srv.Name, + Region: srv.Region, + Port: int(port), + Err: err.Error(), + }, + resolveFailed: true, + }} + } + var out []udpAttempt + var doneV4, doneV6 bool + for _, a := range addrs { + v6 := !a.Is4() + if (v6 && doneV6) || (!v6 && doneV4) { + continue + } + if v6 { + doneV6 = true + } else { + doneV4 = true + } + dst := netip.AddrPortFrom(a, port) + p := UDPProbe{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() + switch { + case err != nil: + p.Err = err.Error() + default: + p.OK = true + p.RTT = rtt + if m, ok := msg.mappedAddr(); ok { + p.Mapped = m + } + } + out = append(out, udpAttempt{probe: p, v6: v6}) + } + return out +} + +// stunUDPVerdict turns the counters into a traffic light and one Chinese line. +func stunUDPVerdict(rep *UDPReport) (Status, string) { + if !rep.V4OK && !rep.V6OK { + return StatusFail, fmt.Sprintf("UDP 完全不通:%d 次探测全部失败,P2P 打洞不可用,只能走 TCP/DERP 中继", len(rep.Probes)) + } + var b strings.Builder + fmt.Fprintf(&b, "UDP 可用(IPv4 %s,IPv6 %s);境内 %d/%d,境外 %d/%d", + stunOKText(rep.V4OK), stunOKText(rep.V6OK), + rep.CNReachable, rep.CNTotal, rep.IntlReachabl, rep.IntlTotal) + + status := StatusOK + if len(rep.BlockedPorts) > 0 { + parts := make([]string, 0, len(rep.BlockedPorts)) + for _, p := range rep.BlockedPorts { + parts = append(parts, strconv.Itoa(p)) + } + fmt.Fprintf(&b, ";端口 %s 疑似被封锁", strings.Join(parts, "/")) + status = StatusWarn + } + if !rep.V4OK { + b.WriteString(";IPv4 UDP 不通,多数对端将无法直连") + status = StatusWarn + } + if rep.IntlTotal > 0 && rep.IntlReachabl == 0 { + b.WriteString(";境外 STUN 全部不可达,出境 UDP 可能被拦截") + status = StatusWarn + } + return status, b.String() +} + +func stunOKText(ok bool) string { + if ok { + return "通" + } + return "不通" +} + +// --------------------------------------------------------------------------- +// ClassifyNAT +// --------------------------------------------------------------------------- + +// stunTarget is one resolved probe endpoint. +type stunTarget struct { + srv STUNServer + dst netip.AddrPort +} + +// natClassifier holds the state of one classification run. Every mapping test +// reuses the same socket, because the mapping is a property of that socket. +type natClassifier struct { + ctx context.Context + log *slog.Logger + conn *net.UDPConn + localPort uint16 + rep *NATReport + seen map[netip.AddrPort]bool +} + +// ClassifyNAT performs RFC 5780 behaviour discovery and maps the result onto +// the classic RFC 3489 NAT names. +// +// The whole run is bounded by the caller's context and by an internal budget, +// and every individual failure degrades into a note instead of aborting: a +// partial classification still renders. +func ClassifyNAT(ctx context.Context, servers []STUNServer, logger *slog.Logger) NATReport { + log := stunLog(logger).With(slog.String("from", "netdiag/nat")) + if len(servers) == 0 { + servers = DefaultSTUNServers() + } + rep := NATReport{Type: NATUnknown, Status: StatusUnknown} + + ctx, cancel := context.WithTimeout(ctx, natTotalBudget) + defer cancel() + + // 1. one socket for every mapping test. + conn, err := stunListen(true) + if err != nil { + rep.Status = StatusFail + rep.Summary = "无法创建 UDP 套接字,NAT 检测中止:" + err.Error() + rep.Notes = append(rep.Notes, err.Error()) + return rep + } + defer conn.Close() + + c := &natClassifier{ctx: ctx, log: log, conn: conn, rep: &rep, seen: map[netip.AddrPort]bool{}} + if la, ok := conn.LocalAddr().(*net.UDPAddr); ok { + c.localPort = uint16(la.Port) + } + + targets := stunResolveTargets(ctx, servers, log) + if len(targets) == 0 { + rep.Status = StatusFail + rep.Summary = "没有可用的 STUN 服务器地址(DNS 解析全部失败)" + rep.Notes = append(rep.Notes, "所有 STUN 服务器的 IPv4 地址解析失败") + return rep + } + + // 2. test I: first server that answers at all. + primary, firstMsg, ok := c.firstAnswer(targets, nil) + if !ok { + rep.Type = NATUDPBlocked + rep.Status = StatusFail + rep.Summary = "UDP 出站被完全阻断:所有 STUN 服务器均无响应,P2P 打洞不可用,连接将全程回退到 DERP 中继" + rep.Notes = append(rep.Notes, "检查防火墙是否放行 UDP 出站,或运营商是否封锁 UDP") + rep.MappedAddrs = c.mappedList() + log.Warn("no stun server answered, udp appears blocked") + return rep + } + firstMapped, _ := firstMsg.mappedAddr() + rep.PortPreserving = boolPtr(firstMapped.Port() == c.localPort) + + // 3. is there a NAT at all? + noNAT := firstMapped.Port() == c.localPort && stunHasLocalIP(firstMapped.Addr()) + if noNAT { + rep.Mapping = BehaviorEndpointIndependent + rep.Notes = append(rep.Notes, "反射地址与本机地址一致,链路上没有 NAT") + } else { + // 4. mapping behaviour, from the same socket to a different server IP. + rep.Mapping = c.mappingBehavior(targets, primary, firstMsg, firstMapped) + } + + // 5. filtering behaviour; needs a real RFC 5780 server. + rep.Filtering = c.filteringBehavior() + if rep.Filtering == BehaviorUnknown { + rep.Notes = append(rep.Notes, + "没有 STUN 服务器响应 CHANGE-REQUEST(Google/Cloudflare 等只支持基本绑定请求),无法判定过滤行为") + } + + // 6. legacy name. + rep.Type = stunLegacyNATType(noNAT, rep.Mapping, rep.Filtering) + if rep.Type == NATUnknown && rep.Mapping == BehaviorEndpointIndependent { + rep.Notes = append(rep.Notes, + "映射行为为端点无关,但过滤行为未知,无法安全地断言为端口限制锥型 NAT") + } + + // 7. hairpinning, best effort. Left nil when the test could not run. + if firstMapped.IsValid() { + if got, ok := c.hairpin(firstMapped); ok { + rep.Hairpin = boolPtr(got) + } else { + rep.Notes = append(rep.Notes, "发夹回环测试未能执行(时间预算已用尽或无法创建探测套接字)") + } + } + + // 9. distinct reflexive addresses, sorted. + rep.MappedAddrs = c.mappedList() + rep.Status, rep.Summary = stunNATVerdict(rep.Type, rep.Mapping, rep.Filtering) + + log.With( + slog.String("type", string(rep.Type)), + slog.String("mapping", rep.Mapping.String()), + slog.String("filtering", rep.Filtering.String()), + slog.Int("mapped", len(rep.MappedAddrs)), + ).Info("nat classified") + return rep +} + +// stunResolveTargets resolves each server to a single IPv4 endpoint, in +// parallel. IPv6 is skipped here: the classifier binds one IPv4 socket so the +// mapping under test is well defined. +func stunResolveTargets(ctx context.Context, servers []STUNServer, log *slog.Logger) []stunTarget { + out := make([]stunTarget, len(servers)) + found := make([]bool, len(servers)) + sem := make(chan struct{}, stunMaxInFlight) + var mu sync.Mutex + var wg sync.WaitGroup + for i, srv := range servers { + wg.Add(1) + go func() { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + addrs, port, err := stunResolve(ctx, srv.Host) + if err != nil { + log.With(slog.String("server", srv.Host), slog.String("error", err.Error())).Debug("resolve failed") + return + } + for _, a := range addrs { + if !a.Is4() { + continue + } + mu.Lock() + out[i] = stunTarget{srv: srv, dst: netip.AddrPortFrom(a, port)} + found[i] = true + mu.Unlock() + return + } + }() + } + wg.Wait() + + res := make([]stunTarget, 0, len(servers)) + for i := range out { + if found[i] { + res = append(res, out[i]) + } + } + return res +} + +// query runs one transaction on the shared socket and records a STUNResult, so +// even failed steps show up in the report. +func (c *natClassifier) query(t stunTarget, change byte, attempts int, interval time.Duration) (*stunMessage, netip.AddrPort, bool) { + res := STUNResult{Server: t.dst.String(), Name: t.srv.Name, Region: t.srv.Region} + msg, from, rtt, err := stunTransact(c.ctx, c.conn, t.dst, stunBindingRequestMsg(change), attempts, interval) + if err != nil { + res.Err = err.Error() + c.rep.Results = append(c.rep.Results, res) + return nil, netip.AddrPort{}, false + } + res.RTT = rtt + res.Software = msg.software() + if code, reason, ok := msg.errorCode(); ok { + res.Err = fmt.Sprintf("stun error %d %s", code, reason) + c.rep.Results = append(c.rep.Results, res) + return nil, from, false + } + res.OK = true + if m, ok := msg.mappedAddr(); ok { + res.Mapped = m + c.seen[m] = true + } + if o, ok := msg.otherAddr(); ok { + res.Other = o + } + // A CHANGE-REQUEST only counts as honoured when the answer really came back + // from another transport address. + res.SupportsChangeReq = change != 0 && from != t.dst + c.rep.Results = append(c.rep.Results, res) + return msg, from, true +} + +// firstAnswer queries targets in order, skipping any whose IP is in skip, and +// returns the first one that answers with a mapped address. +func (c *natClassifier) firstAnswer(targets []stunTarget, skip []netip.Addr) (stunTarget, *stunMessage, bool) { + for _, t := range targets { + if c.ctx.Err() != nil { + break + } + skipped := false + for _, s := range skip { + if t.dst.Addr() == s { + skipped = true + break + } + } + if skipped { + continue + } + msg, _, ok := c.query(t, 0, stunAttempts, stunInterval) + if !ok { + continue + } + if _, has := msg.mappedAddr(); !has { + continue + } + return t, msg, true + } + return stunTarget{}, nil, false +} + +// mappingBehavior implements RFC 5780 §4.3 over the shared socket. +// +// The three tests must vary exactly one thing at a time: +// +// Test I primaryIP:primaryPort -> mapped1 +// Test II alternateIP:primaryPort -> mapped2 (destination IP changed) +// Test III alternateIP:alternatePort -> mapped3 (destination port changed) +// +// mapped2 == mapped1 means endpoint-independent. Otherwise mapped3 == mapped2 +// means address-dependent, and a third distinct mapping means +// address-and-port-dependent. +// +// That sequence needs one server advertising an OTHER-ADDRESS, because an +// RFC 5780 server listens on all four combinations of its primary/alternate +// address and port. Using two *different* servers for tests II and III would +// change the IP and the port at once, which makes address-dependent +// unreachable — every address-dependent NAT would be reported as +// address-and-port-dependent. +func (c *natClassifier) mappingBehavior(targets []stunTarget, primary stunTarget, firstMsg *stunMessage, firstMapped netip.AddrPort) Behavior { + // Preferred: run the full sequence against a server that advertises an + // alternate transport address. Try the primary first, then the others. + if b, ok := c.mappingViaAlternate(primary, firstMsg, firstMapped); ok { + return b + } + for _, t := range targets { + if c.ctx.Err() != nil { + break + } + if t.dst == primary.dst { + continue + } + msg, _, ok := c.query(t, 0, stunQuickAttempts, stunInterval) + if !ok { + continue + } + mapped, has := msg.mappedAddr() + if !has { + continue + } + if b, ok := c.mappingViaAlternate(t, msg, mapped); ok { + return b + } + } + + // No server offered an alternate address. A second server still separates + // endpoint-independent from the rest, which is the distinction that + // actually decides whether hole punching can work. + second, secondMsg, ok := c.firstAnswer(targets, []netip.Addr{primary.dst.Addr()}) + if !ok { + c.rep.Notes = append(c.rep.Notes, "只有一台 STUN 服务器可达,无法判定映射行为") + return BehaviorUnknown + } + secondMapped, _ := secondMsg.mappedAddr() + if secondMapped == firstMapped { + return BehaviorEndpointIndependent + } + _ = second + c.rep.Notes = append(c.rep.Notes, + "映射随目标地址变化,但没有 STUN 服务器提供备用端口,无法区分地址相关与地址端口相关映射") + return BehaviorUnknown +} + +// mappingViaAlternate runs RFC 5780 tests II and III against one server's +// alternate transport address. It reports ok=false when the server advertises +// no usable OTHER-ADDRESS or stops answering, so the caller can try another +// server rather than record a guess. +func (c *natClassifier) mappingViaAlternate(base stunTarget, baseMsg *stunMessage, baseMapped netip.AddrPort) (Behavior, bool) { + alt, has := baseMsg.otherAddr() + if !has || !alt.Addr().Is4() { + return BehaviorUnknown, false + } + // Both coordinates must actually differ, otherwise there is no second + // dimension to test. + if alt.Addr() == base.dst.Addr() || alt.Port() == base.dst.Port() { + return BehaviorUnknown, false + } + + name := func(suffix string) STUNServer { + srv := base.srv + srv.Name = srv.Name + suffix + return srv + } + + // Test II: alternate IP, same port. Only the destination IP changed. + t2 := stunTarget{ + srv: name("(备用地址)"), + dst: netip.AddrPortFrom(alt.Addr(), base.dst.Port()), + } + msg2, _, ok := c.query(t2, 0, stunQuickAttempts, stunInterval) + if !ok { + return BehaviorUnknown, false + } + mapped2, has := msg2.mappedAddr() + if !has { + return BehaviorUnknown, false + } + if mapped2 == baseMapped { + return BehaviorEndpointIndependent, true + } + + // Test III: same alternate IP, alternate port. Only the port changed + // relative to test II. + t3 := stunTarget{srv: name("(备用地址+端口)"), dst: alt} + msg3, _, ok := c.query(t3, 0, stunQuickAttempts, stunInterval) + if !ok { + return BehaviorUnknown, false + } + mapped3, has := msg3.mappedAddr() + if !has { + return BehaviorUnknown, false + } + if mapped3 == mapped2 { + return BehaviorAddressDependent, true + } + return BehaviorAddressAndPortDependent, true +} + +// filteringBehavior runs RFC 5780 tests II and III (CHANGE-REQUEST) against the +// first server that both answers a plain binding request and advertises +// OTHER-ADDRESS. Servers that ignore CHANGE-REQUEST are skipped rather than +// interpreted, because a silent drop is indistinguishable from filtering. +func (c *natClassifier) filteringBehavior() Behavior { + var ( + ignoring []string // servers that answered but ignored CHANGE-REQUEST + silent string // first server that answered plain bindings but no change requests + ) + for _, srv := range RFC5780Servers() { + if c.ctx.Err() != nil { + break + } + addrs, port, err := stunResolve(c.ctx, srv.Host) + if err != nil { + continue + } + var dst netip.AddrPort + for _, a := range addrs { + if a.Is4() { + dst = netip.AddrPortFrom(a, port) + break + } + } + if !dst.IsValid() { + continue + } + t := stunTarget{srv: srv, dst: dst} + msg, _, ok := c.query(t, 0, stunQuickAttempts, stunInterval) + if !ok { + continue + } + if _, has := msg.otherAddr(); !has { + continue // no alternate address: cannot answer a CHANGE-REQUEST + } + + // Test II: ask for a reply from another IP *and* port. + // + // Three outcomes have to be told apart. A reply from a different + // transport address proves the server honoured the request and that + // nothing filtered it. A reply from the address we asked proves the + // server ignored the attribute, which says nothing about filtering — + // treating it as "filtered" is how a full-cone NAT ends up reported as + // port-restricted. No reply at all is only meaningful once we know the + // server honours CHANGE-REQUEST. + _, from, ok := c.query(t, stunChangeIP|stunChangePort, stunQuickAttempts, stunQuickInterval) + if ok && from != dst { + return BehaviorEndpointIndependent + } + if ok { + ignoring = append(ignoring, srv.Host) + continue + } + + // Test III: another port on the same IP. A reply here also proves the + // server implements CHANGE-REQUEST, which retroactively makes the + // silence in test II real evidence of address-dependent filtering. + _, from, ok = c.query(t, stunChangePort, stunQuickAttempts, stunQuickInterval) + if ok && from != dst { + return BehaviorAddressDependent + } + if ok { + ignoring = append(ignoring, srv.Host) + continue + } + + // Silence on both. This is what a port-restricted NAT looks like, but + // it is also what a server that silently drops CHANGE-REQUEST looks + // like. Remember it and keep looking for a server that demonstrably + // honours the attribute; only fall back to this if none does. + if silent == "" { + silent = srv.Host + } + } + + if silent != "" { + c.rep.Notes = append(c.rep.Notes, fmt.Sprintf( + "过滤行为依据 %s 对 CHANGE-REQUEST 的静默推断:该服务器通告了备用地址且能回应普通绑定请求,"+ + "但两次改址请求均无回应,最可能是本地 NAT 拦截", silent)) + return BehaviorAddressAndPortDependent + } + if len(ignoring) > 0 { + c.rep.Notes = append(c.rep.Notes, fmt.Sprintf( + "%s 忽略了 CHANGE-REQUEST(仍从原地址回包),无法判定过滤行为", + strings.Join(ignoring, "、"))) + } else { + c.rep.Notes = append(c.rep.Notes, + "没有可用的 RFC 5780 服务器,无法判定过滤行为") + } + return BehaviorUnknown +} + +// hairpin sends a binding request to our own reflexive address from a second +// socket and checks whether the first socket sees it. Best effort: any failure +// simply means "no hairpinning observed" and never fails the classification. +// It returns ok=false when the test could not be performed at all, so the +// caller can leave NATReport.Hairpin nil. That distinction matters: this test +// runs last, after up to 20s of serial STUN transactions, so an exhausted +// budget is the common case — and reporting "hairpinning not supported" for a +// probe that never sent a packet is a wrong answer, not a cautious one. +func (c *natClassifier) hairpin(mapped netip.AddrPort) (result bool, ok bool) { + if !mapped.IsValid() || !mapped.Addr().Is4() { + return false, false + } + deadline := time.Now().Add(stunHairpinTimeout) + if d, dok := c.ctx.Deadline(); dok && d.Before(deadline) { + deadline = d + } + if !time.Now().Before(deadline) { + return false, false // no budget left to send anything + } + + probe, err := stunListen(true) + if err != nil { + return false, false + } + defer probe.Close() + + req := stunBindingRequestMsg(0) + if _, err := probe.WriteToUDPAddrPort(req.encode(), mapped); err != nil { + return false, false + } + + // From here on the probe was actually sent, so silence is a real answer. + defer func() { _ = c.conn.SetReadDeadline(time.Time{}) }() + buf := make([]byte, stunMaxMessage) + for time.Now().Before(deadline) { + if err := c.conn.SetReadDeadline(deadline); err != nil { + return false, true + } + n, _, err := c.conn.ReadFromUDPAddrPort(buf) + if err != nil { + return false, true + } + msg, err := parseSTUNMessage(buf[:n]) + if err != nil { + continue + } + if msg.Type == stunBindingRequest && msg.TxID == req.TxID { + return true, true + } + } + return false, true +} + +// mappedList returns every distinct reflexive address observed, sorted. +func (c *natClassifier) mappedList() []netip.AddrPort { + out := make([]netip.AddrPort, 0, len(c.seen)) + for ap := range c.seen { + out = append(out, ap) + } + sortAddrPorts(out) + return out +} + +// stunLegacyNATType maps RFC 5780 behaviours onto the RFC 3489 names users +// recognise. Unknown filtering never gets guessed away. +func stunLegacyNATType(noNAT bool, mapping, filtering Behavior) NATType { + switch { + case mapping == BehaviorAddressDependent || mapping == BehaviorAddressAndPortDependent: + return NATSymmetric + case noNAT: + switch filtering { + case BehaviorEndpointIndependent: + return NATOpen + case BehaviorUnknown: + return NATUnknown + default: + return NATSymmetricFW + } + case mapping == BehaviorEndpointIndependent: + switch filtering { + case BehaviorEndpointIndependent: + return NATFullCone + case BehaviorAddressDependent: + return NATRestricted + case BehaviorAddressAndPortDependent: + return NATPortRestrict + default: + return NATUnknown + } + default: + return NATUnknown + } +} + +// stunNATVerdict turns the NAT type into a traffic light plus a one-line +// Chinese explanation of what it means for P2P. +func stunNATVerdict(t NATType, mapping, filtering Behavior) (Status, string) { + switch t { + case NATOpen: + return StatusOK, "公网直连,没有 NAT,P2P 打洞不受限制" + case NATFullCone: + return StatusOK, "全锥型 NAT,打洞成功率很高,通常可以直连" + case NATRestricted: + return StatusOK, "地址限制锥型 NAT,双方同时发包即可打洞,直连通常成功" + case NATPortRestrict: + return StatusWarn, "端口限制锥型 NAT,多数情况下能打洞成功,偶尔会回退到 DERP 中继" + case NATSymmetric: + return StatusFail, "对称型 NAT 会导致打洞失败,连接将回退到 DERP 中继,延迟和带宽都会变差" + case NATUDPBlocked: + return StatusFail, "UDP 被阻断,无法打洞,连接将全程走 DERP 中继" + case NATSymmetricFW: + return StatusWarn, "没有 NAT 但存在有状态防火墙,需要本机先发包,对端才能回连" + default: + return StatusWarn, fmt.Sprintf("无法确定 NAT 类型(映射行为:%s,过滤行为:%s),打洞结果不可预测", mapping, filtering) + } +} diff --git a/netdiag/stun_test.go b/netdiag/stun_test.go new file mode 100644 index 0000000..69e588e --- /dev/null +++ b/netdiag/stun_test.go @@ -0,0 +1,347 @@ +package netdiag + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "net/netip" + "testing" +) + +// rfc5769TxID is the transaction ID from the RFC 5769 sample messages; the +// hand-computed XOR-MAPPED-ADDRESS vectors below are derived from it. +var rfc5769TxID = [12]byte{0xb7, 0xe7, 0xa7, 0x01, 0xbc, 0x34, 0xd6, 0x86, 0xfa, 0x87, 0xdf, 0xae} + +// stunTestTLV encodes one attribute with its 4-byte alignment padding. +func stunTestTLV(typ uint16, val []byte) []byte { + out := make([]byte, 4, 4+len(val)+3) + binary.BigEndian.PutUint16(out[0:2], typ) + binary.BigEndian.PutUint16(out[2:4], uint16(len(val))) + out = append(out, val...) + if pad := (4 - len(val)%4) % 4; pad > 0 { + out = append(out, make([]byte, pad)...) + } + return out +} + +// stunTestRaw frames body as a STUN message with a correct length field. +func stunTestRaw(typ uint16, txid [12]byte, body []byte) []byte { + out := make([]byte, stunHeaderSize, stunHeaderSize+len(body)) + binary.BigEndian.PutUint16(out[0:2], typ) + binary.BigEndian.PutUint16(out[2:4], uint16(len(body))) + binary.BigEndian.PutUint32(out[4:8], stunMagicCookie) + copy(out[8:20], txid[:]) + return append(out, body...) +} + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("bad hex %q: %v", s, err) + } + return b +} + +func TestSTUNEncodeParseRoundTrip(t *testing.T) { + tests := []struct { + name string + msg stunMessage + attrs int + }{ + { + name: "bare request", + msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID}, + attrs: 0, + }, + { + name: "change request", + msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID, Attrs: []stunAttr{ + {Type: stunAttrChangeRequest, Value: []byte{0, 0, 0, stunChangeIP | stunChangePort}}, + }}, + attrs: 1, + }, + { + name: "response with odd-length software", + msg: stunMessage{Type: stunBindingSuccess, TxID: rfc5769TxID, Attrs: []stunAttr{ + {Type: stunAttrSoftware, Value: []byte("tslink/1")}, + {Type: stunAttrXORMappedAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("192.0.2.1:32853"), true, rfc5769TxID)}, + {Type: stunAttrOtherAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("198.51.100.7:3479"), false, rfc5769TxID)}, + {Type: 0x7fff, Value: []byte{1, 2, 3, 4, 5}}, // unknown, needs padding + }}, + attrs: 4, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw := tc.msg.encode() + if len(raw)%4 != 0 { + t.Fatalf("encoded message is not 4-byte aligned: %d", len(raw)) + } + if got := binary.BigEndian.Uint32(raw[4:8]); got != stunMagicCookie { + t.Fatalf("magic cookie = %#x", got) + } + got, err := parseSTUNMessage(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got.Type != tc.msg.Type || got.TxID != tc.msg.TxID { + t.Fatalf("header mismatch: got %#x/%x", got.Type, got.TxID) + } + if len(got.Attrs) != tc.attrs { + t.Fatalf("attrs = %d, want %d", len(got.Attrs), tc.attrs) + } + for i, a := range tc.msg.Attrs { + if got.Attrs[i].Type != a.Type { + t.Errorf("attr %d type = %#x, want %#x", i, got.Attrs[i].Type, a.Type) + } + if !bytes.Equal(got.Attrs[i].Value, a.Value) { + t.Errorf("attr %d value = %x, want %x", i, got.Attrs[i].Value, a.Value) + } + } + }) + } +} + +func TestSTUNDecodeXORMappedAddress(t *testing.T) { + tests := []struct { + name string + attr uint16 + // hand-computed payload: reserved, family, xor-port, xor-address + payload string + want string + }{ + { + // 192.0.2.1 ^ 2112a442 = e112a643, port 32853 ^ 0x2112 = 0xa147 + name: "v4", + attr: stunAttrXORMappedAddress, + payload: "0001a147e112a643", + want: "192.0.2.1:32853", + }, + { + // same, delivered under the legacy 0x8020 attribute type + name: "v4 legacy attr", + attr: stunAttrXORMappedAddrAlt, + payload: "0001a147e112a643", + want: "192.0.2.1:32853", + }, + { + // 2001:db8:1234:5678:11:2233:4455:6677 ^ (cookie || txid) + name: "v6", + attr: stunAttrXORMappedAddress, + payload: "0002a1470113a9faa5d3f179bc25f4b5bed2b9d9", + want: "[2001:db8:1234:5678:11:2233:4455:6677]:32853", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + payload := mustHex(t, tc.payload) + raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(tc.attr, payload)) + msg, err := parseSTUNMessage(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + got, ok := msg.mappedAddr() + if !ok { + t.Fatal("no mapped address decoded") + } + if got.String() != tc.want { + t.Fatalf("mapped = %s, want %s", got, tc.want) + } + // encoding it again must reproduce the same bytes + if back := stunEncodeAddr(got, true, rfc5769TxID); !bytes.Equal(back, payload) { + t.Fatalf("re-encoded = %x, want %x", back, payload) + } + }) + } +} + +func TestSTUNDecodePlainMappedAddress(t *testing.T) { + payload := mustHex(t, "00010d96c0000201") // 192.0.2.1:3478, no XOR + raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrMappedAddress, payload)) + msg, err := parseSTUNMessage(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + got, ok := msg.mappedAddr() + if !ok || got.String() != "192.0.2.1:3478" { + t.Fatalf("mapped = %v (ok=%v), want 192.0.2.1:3478", got, ok) + } +} + +func TestSTUNParseTolerance(t *testing.T) { + good := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643")) + + tests := []struct { + name string + raw []byte + wantErr bool + wantAttrs int + wantMap string + }{ + { + name: "unknown attributes are skipped", + raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(stunTestTLV(0x7f01, []byte{9}), good, stunTestTLV(0xfffe, []byte("xyz")))), + wantAttrs: 3, + wantMap: "192.0.2.1:32853", + }, + { + name: "missing trailing padding tolerated", + raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, []byte{0x80, 0x22, 0x00, 0x03, 'a', 'b', 'c'})), + wantAttrs: 2, + wantMap: "192.0.2.1:32853", + }, + { + name: "fingerprint after mapped address", + raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, stunTestTLV(stunAttrFingerprint, []byte{1, 2, 3, 4}))), + wantAttrs: 2, + wantMap: "192.0.2.1:32853", + }, + { + name: "header shorter than 20 bytes", + raw: []byte{0x01, 0x01, 0x00, 0x00}, + wantErr: true, + }, + { + name: "trailing bytes beyond declared length ignored", + raw: append(stunTestRaw(stunBindingSuccess, rfc5769TxID, nil), 0x00), + wantErr: false, + }, + { + name: "truncated attribute value", + raw: func() []byte { + b := stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00, 0x10, 0x00, 0x01}) + return b + }(), + wantErr: true, + }, + { + name: "truncated attribute header", + raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00}), + wantErr: true, + }, + { + name: "address attribute shorter than its family requires", + raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0002a1470113a9fa"))), + wantAttrs: 1, + wantMap: "", // v6 payload truncated: reported as absent, not fatal + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + msg, err := parseSTUNMessage(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error, got none") + } + return + } + if err != nil { + t.Fatalf("parse: %v", err) + } + if tc.wantAttrs != 0 && len(msg.Attrs) != tc.wantAttrs { + t.Fatalf("attrs = %d, want %d", len(msg.Attrs), tc.wantAttrs) + } + got, ok := msg.mappedAddr() + if tc.wantMap == "" { + if ok { + t.Fatalf("expected no mapped address, got %s", got) + } + return + } + if !ok || got.String() != tc.wantMap { + t.Fatalf("mapped = %v (ok=%v), want %s", got, ok, tc.wantMap) + } + }) + } +} + +func TestSTUNResponseFor(t *testing.T) { + other := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + body := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643")) + + tests := []struct { + name string + raw []byte + txid [12]byte + want bool + }{ + {"matching success", stunTestRaw(stunBindingSuccess, rfc5769TxID, body), rfc5769TxID, true}, + {"matching error response", stunTestRaw(stunBindingError, rfc5769TxID, nil), rfc5769TxID, true}, + {"txid mismatch", stunTestRaw(stunBindingSuccess, other, body), rfc5769TxID, false}, + {"request is not a response", stunTestRaw(stunBindingRequest, rfc5769TxID, nil), rfc5769TxID, false}, + {"garbage", []byte("not a stun packet"), rfc5769TxID, false}, + {"empty", nil, rfc5769TxID, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + msg, ok := stunResponseFor(tc.raw, tc.txid) + if ok != tc.want { + t.Fatalf("ok = %v, want %v", ok, tc.want) + } + if ok && msg == nil { + t.Fatal("accepted response but returned nil message") + } + }) + } +} + +func TestSTUNBindingRequestMsg(t *testing.T) { + plain := stunBindingRequestMsg(0) + if len(plain.Attrs) != 0 { + t.Fatalf("plain request carries %d attributes", len(plain.Attrs)) + } + if plain.TxID == ([12]byte{}) { + t.Fatal("transaction id was not randomised") + } + if other := stunBindingRequestMsg(0); other.TxID == plain.TxID { + t.Fatal("two requests share a transaction id") + } + cr := stunBindingRequestMsg(stunChangeIP | stunChangePort) + v, ok := cr.attr(stunAttrChangeRequest) + if !ok || len(v) != 4 || v[3] != 0x06 { + t.Fatalf("change-request attribute = %x (ok=%v)", v, ok) + } +} + +func TestSTUNServerLists(t *testing.T) { + var cn, intl int + hosts := map[string]bool{} + for _, s := range DefaultSTUNServers() { + if hosts[s.Host] { + t.Errorf("duplicate host %s", s.Host) + } + hosts[s.Host] = true + if s.Name == "" { + t.Errorf("%s has no name", s.Host) + } + switch s.Region { + case RegionCN: + cn++ + case RegionIntl: + intl++ + default: + t.Errorf("%s has unknown region %q", s.Host, s.Region) + } + } + if cn == 0 || intl == 0 { + t.Fatalf("default list must span both regions, got cn=%d intl=%d", cn, intl) + } + for _, s := range RFC5780Servers() { + if !hosts[s.Host] { + t.Errorf("rfc5780 server %s missing from the default list", s.Host) + } + } +} + +func concat(parts ...[]byte) []byte { + var out []byte + for _, p := range parts { + out = append(out, p...) + } + return out +} diff --git a/netdiag/types.go b/netdiag/types.go new file mode 100644 index 0000000..b5909c1 --- /dev/null +++ b/netdiag/types.go @@ -0,0 +1,477 @@ +// Package netdiag runs network diagnostics: NAT classification via STUN, UDP +// reachability, local address enumeration, router port-mapping support +// (UPnP/NAT-PMP/PCP), overseas reachability, and public egress IP discovery +// with geolocation. +// +// The package deliberately avoids depending on tailscale.com so it stays +// usable (and testable) on its own. Tailscale's own view of the network is +// injected through the [TailscaleSource] interface. +package netdiag + +import ( + "context" + "log/slog" + "net/netip" + "time" +) + +// Status is a coarse traffic-light verdict attached to each section of a +// [Report] so the UI can rank what deserves the user's attention. +type Status int + +const ( + StatusUnknown Status = iota + StatusOK + StatusWarn + StatusFail + StatusSkipped +) + +func (s Status) String() string { + switch s { + case StatusOK: + return "ok" + case StatusWarn: + return "warn" + case StatusFail: + return "fail" + case StatusSkipped: + return "skipped" + default: + return "unknown" + } +} + +// Region distinguishes probe targets inside mainland China from targets +// outside it. Egress results routinely differ between the two when a proxy is +// in play, and that difference is itself a diagnostic signal. +type Region string + +const ( + RegionCN Region = "cn" + RegionIntl Region = "intl" +) + +func (r Region) String() string { return string(r) } + +// --------------------------------------------------------------------------- +// Local addresses +// --------------------------------------------------------------------------- + +// AddrKind classifies a local address by the scope it can reach. +type AddrKind string + +const ( + AddrGlobalV4 AddrKind = "global4" + AddrPrivateV4 AddrKind = "private4" + AddrCGNAT AddrKind = "cgnat" + AddrGlobalV6 AddrKind = "global6" + AddrULA AddrKind = "ula" + AddrLinkLocal AddrKind = "link-local" + AddrLoopback AddrKind = "loopback" + AddrTailscale AddrKind = "tailscale" +) + +// LocalAddr is one address bound to one local interface. +type LocalAddr struct { + Iface string + Addr netip.Addr + Prefix netip.Prefix + Kind AddrKind + Up bool + MTU int + Hardware string // MAC, empty for virtual interfaces + // IsDefaultSrc reports whether the kernel picks this address as the source + // for a default-route destination. + IsDefaultSrc bool +} + +// InterfaceReport enumerates every local address, so the user can see all +// IPv4/IPv6 exits the machine has. +type InterfaceReport struct { + Addrs []LocalAddr + DefaultV4Src netip.Addr + DefaultV6Src netip.Addr + HasGlobalV6 bool + Status Status + Summary string + Err string +} + +// --------------------------------------------------------------------------- +// STUN / UDP / NAT +// --------------------------------------------------------------------------- + +// STUNServer is one probe target. +type STUNServer struct { + Host string // "stun.miwifi.com:3478" + Name string // human label, e.g. "小米" + Region Region +} + +// STUNResult records the outcome of a single binding transaction. +type STUNResult struct { + Server string + Name string + Region Region + OK bool + RTT time.Duration + // Mapped is the server-reflexive address the server saw. + Mapped netip.AddrPort + // Other is the OTHER-ADDRESS (RFC 5780) or CHANGED-ADDRESS (RFC 3489) + // alternate transport address, when advertised. + Other netip.AddrPort + // SupportsChangeReq reports whether the server honoured a CHANGE-REQUEST, + // which is required for filtering-behaviour discovery. + SupportsChangeReq bool + Software string + Err string +} + +// UDPProbe is a plain "can I send and receive UDP here" datapoint. +type UDPProbe struct { + Target string + Name string + Region Region + Port int + OK bool + RTT time.Duration + Mapped netip.AddrPort + Err string +} + +// UDPReport summarises UDP reachability across regions and ports. +type UDPReport struct { + V4OK bool + V6OK bool + Probes []UDPProbe + OKPorts []int + // BlockedPorts are ports where every probe failed while some other port + // succeeded — a strong hint of egress filtering rather than no UDP at all. + BlockedPorts []int + CNReachable int + CNTotal int + IntlReachabl int + IntlTotal int + Status Status + Summary string +} + +// Behavior is the RFC 5780 mapping/filtering behaviour classification. +type Behavior int + +const ( + BehaviorUnknown Behavior = iota + BehaviorEndpointIndependent + BehaviorAddressDependent + BehaviorAddressAndPortDependent +) + +func (b Behavior) String() string { + switch b { + case BehaviorEndpointIndependent: + return "endpoint-independent" + case BehaviorAddressDependent: + return "address-dependent" + case BehaviorAddressAndPortDependent: + return "address-and-port-dependent" + default: + return "unknown" + } +} + +// NATType is the classic RFC 3489 name for the detected NAT, kept because it +// is what users recognise (and what game/P2P docs talk about). +type NATType string + +const ( + NATUnknown NATType = "unknown" + NATOpen NATType = "open" // no NAT, reflexive == local + NATFullCone NATType = "full-cone" // NAT type 1-ish + NATRestricted NATType = "restricted" // address-restricted cone + NATPortRestrict NATType = "port-restricted" + NATSymmetric NATType = "symmetric" // worst case for P2P + NATUDPBlocked NATType = "udp-blocked" + NATSymmetricFW NATType = "symmetric-firewall" // no NAT but stateful firewall +) + +// NATReport is the NAT classification result. +type NATReport struct { + Type NATType + Mapping Behavior + Filtering Behavior + // Hairpin reports whether the NAT loops packets sent to its own external + // address back inside. nil when untested. + Hairpin *bool + // PortPreserving reports whether the external port equals the local port. + PortPreserving *bool + // MappedAddrs is every distinct reflexive address observed. More than one + // means the mapping varies by destination (symmetric). + MappedAddrs []netip.AddrPort + Results []STUNResult + Status Status + Summary string + Notes []string +} + +// --------------------------------------------------------------------------- +// Router port mapping +// --------------------------------------------------------------------------- + +// ServiceProbe is the result of probing one port-mapping protocol. +type ServiceProbe struct { + Available bool + Detail string // device name / protocol version / control URL + ExternalIP netip.Addr + RTT time.Duration + Err string +} + +// PortMapReport covers UPnP IGD, NAT-PMP and PCP. +type PortMapReport struct { + Gateway netip.Addr + UPnP ServiceProbe + NATPMP ServiceProbe + PCP ServiceProbe + Status Status + Summary string +} + +// --------------------------------------------------------------------------- +// Reachability +// --------------------------------------------------------------------------- + +// ReachProbe is one HTTP/TCP reachability datapoint. +type ReachProbe struct { + Name string + URL string + Region Region + OK bool + StatusCode int + RTT time.Duration + // ViaProxy reports whether the request honoured the environment's proxy + // settings. Running the same target both ways reveals proxy interference. + ViaProxy bool + Network string // "tcp4", "tcp6" or "" for unforced + Err string +} + +// OverseasReport captures whether traffic can leave for the wider internet, +// primarily via cp.cloudflare.com. +type OverseasReport struct { + Probes []ReachProbe + Status Status + Summary string +} + +// --------------------------------------------------------------------------- +// Egress IP + geolocation +// --------------------------------------------------------------------------- + +// EgressMethod is how a public address was observed. Different methods take +// different paths out of the machine, so they legitimately disagree when a +// proxy or split tunnel is active. +type EgressMethod string + +const ( + MethodSTUN EgressMethod = "stun" // raw UDP, bypasses HTTP proxies + MethodHTTPv4 EgressMethod = "http4" // forced IPv4, proxy bypassed + MethodHTTPv6 EgressMethod = "http6" // forced IPv6, proxy bypassed + MethodHTTPProxy EgressMethod = "http-proxy" // honours HTTP(S)_PROXY + MethodTailscale EgressMethod = "tailscale" // as seen by the tailnet +) + +// EgressObservation is one "what is my public IP" answer. +type EgressObservation struct { + Method EgressMethod + Source string // server or URL that answered + Region Region + IP netip.Addr + RTT time.Duration + Err string +} + +// GeoInfo is the geolocation of one public IP. +type GeoInfo struct { + IP netip.Addr + Country string // ISO code + CountryName string + Region string + City string + Org string + ASN string + Loc string + Timezone string + Provider string // which API answered + Err string +} + +// EgressReport lists every public address the machine appears to use. +type EgressReport struct { + Observations []EgressObservation + Geo []GeoInfo + // UniqueIPs is the deduplicated set across all methods. + UniqueIPs []netip.Addr + // Divergent is true when the probes disagreed about our public address + // within one address family, which usually means a proxy or VPN is + // intercepting part of the traffic. Having both an IPv4 and an IPv6 egress + // is ordinary dual stack and does not set this. + Divergent bool + // Countries is the set of distinct countries seen, sorted. + Countries []string + Status Status + Summary string +} + +// --------------------------------------------------------------------------- +// Tailscale's own view +// --------------------------------------------------------------------------- + +// DERPLatency is the round-trip time to one DERP region. +type DERPLatency struct { + RegionID int + RegionCode string + Name string + Latency time.Duration + Preferred bool +} + +// TailscaleReport mirrors the parts of tailscale's netcheck report that are +// useful here. Tri-state fields are nil when tailscale could not determine +// them. +type TailscaleReport struct { + Available bool + UDP bool + IPv4 bool + IPv6 bool + ICMPv4 bool + OSHasIPv6 bool + MappingVariesByDestIP *bool + UPnP *bool + PMP *bool + PCP *bool + CaptivePortal *bool + GlobalV4 string + GlobalV6 string + PreferredDERP string + DERP []DERPLatency + Status Status + Summary string + Err string +} + +// TailscaleSource supplies tailscale's internal network view. The GUI wires +// this to a live tsnet server; it is nil when tailscale is not running yet. +type TailscaleSource interface { + Netcheck(ctx context.Context) (*TailscaleReport, error) +} + +// --------------------------------------------------------------------------- +// Report + runner +// --------------------------------------------------------------------------- + +// Report is the complete diagnostic result. +type Report struct { + StartedAt time.Time + FinishedAt time.Time + Duration time.Duration + + Interfaces InterfaceReport + UDP UDPReport + NAT NATReport + PortMap PortMapReport + Overseas OverseasReport + Egress EgressReport + Tailscale TailscaleReport + + // Headline is the single most important sentence about this report. + Headline string + // Status is the worst status across all sections. + Status Status +} + +// Step identifies one unit of diagnostic work. The GUI renders these as a +// checklist while the run is in flight. +type Step struct { + Key string + Title string +} + +// Steps lists every phase in execution order. +var Steps = []Step{ + {Key: "iface", Title: "本机网络接口"}, + {Key: "udp", Title: "UDP 连通性"}, + {Key: "nat", Title: "NAT 类型"}, + {Key: "portmap", Title: "UPnP / NAT-PMP / PCP"}, + {Key: "overseas", Title: "境外连通性"}, + {Key: "egress", Title: "出口 IP"}, + {Key: "geo", Title: "IP 归属地"}, + {Key: "tailscale", Title: "Tailscale 内部状态"}, +} + +// Progress is emitted as each step starts and finishes. +type Progress struct { + Key string + Title string + Index int + Total int + Done bool + Err string + Elapsed time.Duration +} + +// Options configures a diagnostic run. +type Options struct { + Logger *slog.Logger + // OnProgress is called from the runner's goroutines; implementations must + // be safe for concurrent use. + OnProgress func(Progress) + // Tailscale is optional; when nil the tailscale section is skipped. + Tailscale TailscaleSource + // STUNServers overrides the default CN + international server list. + STUNServers []STUNServer + // IPInfoToken is an optional ipinfo.io token, raising the rate limit. + IPInfoToken string + // Timeout bounds the whole run. Zero means DefaultTimeout. + Timeout time.Duration + // SkipGeo disables outbound geolocation lookups (they leak the user's IP + // to a third party). + SkipGeo bool +} + +// DefaultTimeout bounds a full diagnostic run. +const DefaultTimeout = 45 * time.Second + +func (o *Options) logger() *slog.Logger { + if o.Logger != nil { + return o.Logger + } + return slog.Default() +} + +func (o *Options) progress(p Progress) { + if o.OnProgress != nil { + o.OnProgress(p) + } +} + +// worstStatus returns the most severe status in ss, treating StatusSkipped and +// StatusUnknown as less severe than StatusWarn. +func worstStatus(ss ...Status) Status { + rank := map[Status]int{ + StatusOK: 0, + StatusSkipped: 1, + StatusUnknown: 2, + StatusWarn: 3, + StatusFail: 4, + } + worst := StatusOK + for _, s := range ss { + if rank[s] > rank[worst] { + worst = s + } + } + return worst +} + +func boolPtr(b bool) *bool { return &b }