add: gui and tsdiag

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