Merge remote-tracking branch 'homelab/sfcraft' into sfcraft
This commit is contained in:
+105
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -159,3 +159,84 @@ docker run -d \
|
||||
ghcr.io/saltedfishclub/tslink:latest \
|
||||
-c /etc/tslink/config.toml
|
||||
```
|
||||
|
||||
## 图形界面 `tslink-gui`
|
||||
|
||||
`tslink-gui` 是可选的桌面前端,使用 [Gio](https://gioui.org) 直接绘制界面——不含 WebView、不打包浏览器,Linux / macOS / Windows 各是一个原生可执行文件(约 30 MB)。
|
||||
|
||||
它在自身进程内运行与无界面版**完全相同**的转发服务,因此配置文件、规则语义和行为都一致:
|
||||
|
||||
```bash
|
||||
tslink-gui -c config.toml
|
||||
```
|
||||
|
||||
### 命令行参数
|
||||
|
||||
除下列参数外,`-c`、`-config-url`、`-level`、`-json-format`、`-diagnose` 与无界面版含义相同。
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `-light` | `false` | 以浅色主题启动(默认深色) |
|
||||
| `-ipinfo-token` | `$IPINFO_TOKEN` | ipinfo.io 的 API Token,可选,用于提高归属地查询的速率限制 |
|
||||
| `-pprof` | 空 | 在指定地址暴露 `net/http/pprof`,如 `127.0.0.1:6060`。仅允许回环地址 |
|
||||
|
||||
无论 `-level` 设为什么,界面内的日志缓冲区**始终按 debug 级别**记录最近 20000 条,所以出问题后不必重启加 `-level debug` 再复现一次。
|
||||
|
||||
### 界面说明
|
||||
|
||||
| 页面 | 内容 |
|
||||
|------|------|
|
||||
| **概览** | 在线节点数、局域网服务器数、规则数、运行时长,以及最近一次诊断的结论 |
|
||||
| **节点** | 每个 Tailscale 节点的在线状态、链路类型、实时延迟、抖动与丢包;顶部为多节点**延迟图谱**(最近 20 分钟,鼠标悬停可查看某一时刻的取值,点击图例可隐藏某个节点) |
|
||||
| **局域网** | 监听 `224.0.2.60:4445` / `[ff75:230::60]:4445` 的 Minecraft LAN 广播。由 tslink 自己广播的条目会标记为「本机广播」——**配置了规则却听不到自己的广播,说明隧道或组播链路有问题** |
|
||||
| **网络诊断** | 见下 |
|
||||
| **日志** | 按级别、来源、关键字检索,复制 / 保存 / 上传 |
|
||||
| **设置** | 主题、语言、版本与配置来源 |
|
||||
|
||||
启动过程中,界面显示分步进度的加载动画;实时日志以**半透明浮层**固定在底部,因此启动卡住时直接截图就包含了排查所需的信息。服务就绪后,浮层可通过标题栏按钮随时唤出。
|
||||
|
||||
### 网络诊断
|
||||
|
||||
点击「开始诊断」后并行执行以下检查,整体不超过 45 秒:
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| **NAT 类型** | 依 RFC 5780 做映射行为与过滤行为探测,并映射到常见的完全锥形 / 地址限制 / 端口限制 / 对称型命名。对称型 NAT 会导致打洞失败、连接回退到 DERP 中继 |
|
||||
| **UDP 连通性** | 对国内与境外 STUN 服务器分别探测 IPv4/IPv6,并识别疑似被封锁的目标端口 |
|
||||
| **本机出口地址** | 列出所有接口上的 IPv4 与 IPv6 地址,标注默认出口以及 CGNAT / Tailscale / 私有 / 公网等类型 |
|
||||
| **端口映射** | 自行实现的 UPnP IGD(SSDP + SOAP)、NAT-PMP(RFC 6886)与 PCP(RFC 6887)探测,能拿到路由器型号与外部地址 |
|
||||
| **境外连通性** | 以 `cp.cloudflare.com/generate_204` 为主,辅以 gstatic / Google,并用国内基准(小米 / 百度)区分「完全没网」与「只是出不了境」 |
|
||||
| **出口 IP 与归属地** | 通过 STUN(裸 UDP,绕过 HTTP 代理)、强制 IPv4、强制 IPv6、以及走系统代理四种方式分别探测,再用 ipinfo.io(失败时回退 ip-api.com / ip.sb)查询归属地 |
|
||||
| **Tailscale 内部状态** | 直接调用 tailscale 自己的 netcheck,取得 DERP 各区域延迟、首选中继、门户劫持判定,以及它自己看到的 UPnP/PMP/PCP 结果 |
|
||||
|
||||
STUN 服务器**同时包含国内与境外**两组(小米、B 站、腾讯、芒果 TV、Cloudflare 任播 / Google、Cloudflare、Nextcloud、BlackBerry、SipNet、StunProtocol)。这不只是为了容错:当本机启用了代理或分流工具时,不同探测路径会得到**不同的公网 IP**,诊断页会把这种分歧单独标出来——这通常正是「为什么对端连不上我」的答案。
|
||||
|
||||
> 归属地查询会把你的公网 IP 发送给第三方服务。不希望如此时,勾选「不查询归属地」即可跳过。
|
||||
>
|
||||
> 出口 IP 的「不一致」判定按 IPv4 / IPv6 分别计算,双栈主机同时拥有一个 v4 和一个 v6 出口属于正常情况,不会被误报。
|
||||
|
||||
### 导出与分享日志
|
||||
|
||||
日志页提供三种导出方式,都会附带一段环境信息头(版本、系统、配置来源、运行阶段、节点数),以及最近一次的完整诊断报告:
|
||||
|
||||
- **复制到剪贴板**
|
||||
- **保存到文件**:写入用户主目录,文件名形如 `tslink-log-20260726-084500.txt`
|
||||
- **上传并分享**:依次尝试 0x0.st、paste.rs、dpaste.org、termbin.com,成功后返回链接并自动复制
|
||||
|
||||
导出默认开启「隐去密钥」,会移除 `auth_key` 等凭据以及形如 `tskey-...` 的字符串。**上传是公开的**——任何拿到链接的人都能看到内容,其中包含你的公网 IP 与内网地址,请自行判断。
|
||||
|
||||
### 从源码构建
|
||||
|
||||
Windows 无需额外依赖。macOS 需要 Xcode Command Line Tools。Linux 需要 X11 / Wayland / EGL 的开发头文件:
|
||||
|
||||
```bash
|
||||
sudo apt install -y pkg-config libwayland-dev libx11-dev libx11-xcb-dev \
|
||||
libxkbcommon-dev libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev \
|
||||
libffi-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxxf86vm-dev
|
||||
|
||||
go build -o tslink-gui ./cmd/tslink-gui
|
||||
```
|
||||
|
||||
界面语言默认跟随中文字体的可用性:找不到任何中文字体时自动切换为英文,以免显示成方块(也可在设置里手动切换)。
|
||||
|
||||
由于 Gio 依赖 CGO,GUI 无法像无界面版那样交叉编译,需要在目标平台上分别构建。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// These tests exist to give `go test -race` something to chew on. The GUI
|
||||
// reads every one of these structures from its frame loop while background
|
||||
// goroutines write to them, which is exactly the shape of bug that never
|
||||
// shows up in a single-threaded test.
|
||||
|
||||
func TestLogBufferConcurrentAccess(t *testing.T) {
|
||||
buf := NewLogBuffer(128) // small, so eviction runs constantly
|
||||
logger := slog.New(buf.Handler(nil))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Writers.
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
l := logger.With("from", "writer", "id", id)
|
||||
for ctx.Err() == nil {
|
||||
l.Info("message", "n", id, "auth_key", "tskey-auth-SECRETVALUE123")
|
||||
l.Debug("detail", slog.Group("g", slog.String("k", "v")))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Readers, mimicking the GUI's frame loop.
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
_ = buf.Tail(50)
|
||||
_ = buf.Filter(LogQuery{MinLevel: slog.LevelInfo, Text: "message", Limit: 20})
|
||||
_ = buf.Sources()
|
||||
_ = buf.Counts()
|
||||
_ = buf.Len()
|
||||
_ = buf.Dropped()
|
||||
_ = buf.LastSeq()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Subscribers churning in and out.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
ch, cancelSub := buf.Subscribe()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
cancelSub()
|
||||
}
|
||||
}()
|
||||
|
||||
// Exporter, which walks the whole ring and redacts.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
out := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug, Limit: 100}})
|
||||
if len(out) > 0 && containsSecret(out) {
|
||||
t.Error("export leaked an auth key")
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if buf.Len() > 128 {
|
||||
t.Fatalf("ring exceeded its capacity: %d", buf.Len())
|
||||
}
|
||||
if buf.Dropped() == 0 {
|
||||
t.Fatal("expected eviction to have occurred")
|
||||
}
|
||||
}
|
||||
|
||||
func containsSecret(s string) bool {
|
||||
return len(s) > 0 && (indexOf(s, "SECRETVALUE123") >= 0)
|
||||
}
|
||||
|
||||
func indexOf(hay, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if hay[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestLogBufferTailOrderAndBounds(t *testing.T) {
|
||||
buf := NewLogBuffer(4)
|
||||
logger := slog.New(buf.Handler(nil))
|
||||
for i := 0; i < 10; i++ {
|
||||
logger.Info("m", "i", i)
|
||||
}
|
||||
|
||||
got := buf.Tail(3)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("Tail(3) returned %d entries", len(got))
|
||||
}
|
||||
// Oldest first, and the newest must be last.
|
||||
for i := 1; i < len(got); i++ {
|
||||
if got[i].Seq <= got[i-1].Seq {
|
||||
t.Fatalf("Tail is not in chronological order: %v", got)
|
||||
}
|
||||
}
|
||||
if got[len(got)-1].Seq != buf.LastSeq() {
|
||||
t.Fatalf("Tail did not end at the newest record")
|
||||
}
|
||||
if n := len(buf.Tail(100)); n != 4 {
|
||||
t.Fatalf("Tail beyond capacity returned %d, want 4", n)
|
||||
}
|
||||
if n := len(buf.Tail(0)); n != 0 {
|
||||
t.Fatalf("Tail(0) returned %d entries", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogBufferRedactsOnExport(t *testing.T) {
|
||||
buf := NewLogBuffer(16)
|
||||
logger := slog.New(buf.Handler(nil))
|
||||
logger.Info("joining", "auth_key", "tskey-auth-kSomeRealLookingKey123")
|
||||
logger.Info("inline", "url", "https://x/?k=tskey-client-abcdefghijkl")
|
||||
|
||||
out := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug}})
|
||||
if indexOf(out, "kSomeRealLookingKey123") >= 0 {
|
||||
t.Error("attribute-named secret survived redaction")
|
||||
}
|
||||
if indexOf(out, "abcdefghijkl") >= 0 {
|
||||
t.Error("inline tskey survived redaction")
|
||||
}
|
||||
|
||||
raw := buf.ExportText(ExportOptions{Query: LogQuery{MinLevel: slog.LevelDebug}, NoRedact: true})
|
||||
if indexOf(raw, "kSomeRealLookingKey123") < 0 {
|
||||
t.Error("NoRedact should preserve the original text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanScannerConcurrentAccess(t *testing.T) {
|
||||
s := NewLanScanner(slog.New(slog.DiscardHandler))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
s.Start(ctx)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Feed announcements the way the read loops do.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; ctx.Err() == nil; i++ {
|
||||
src := netip.AddrPortFrom(netip.MustParseAddr("192.168.1.50"), uint16(40000+i%3))
|
||||
s.handle(src, "[MOTD]§aTest §bServer[/MOTD][AD]25565[/AD]")
|
||||
s.handle(src, "malformed packet")
|
||||
}
|
||||
}()
|
||||
|
||||
// Read like the GUI does.
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
for _, srv := range s.Servers() {
|
||||
_ = srv.Motd
|
||||
_ = srv.Addr
|
||||
}
|
||||
_ = s.Err()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Reconfigure while it runs.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
s.SetSelfEntries([]LanEntry{{Motd: "Test Server", Port: 25565}})
|
||||
time.Sleep(time.Millisecond)
|
||||
s.SetSelfEntries(nil)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
servers := s.Servers()
|
||||
if len(servers) == 0 {
|
||||
t.Fatal("expected the synthetic announcements to be recorded")
|
||||
}
|
||||
for _, srv := range servers {
|
||||
if srv.Port != 25565 {
|
||||
t.Errorf("unexpected port %d", srv.Port)
|
||||
}
|
||||
// Colour codes must be stripped.
|
||||
if indexOf(srv.Motd, "§") >= 0 {
|
||||
t.Errorf("colour codes survived: %q", srv.Motd)
|
||||
}
|
||||
if srv.Motd != "Test Server" {
|
||||
t.Errorf("motd = %q, want %q", srv.Motd, "Test Server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerMonitorSnapshotIsIsolated(t *testing.T) {
|
||||
m := NewPeerMonitor(nil, nil, slog.New(slog.DiscardHandler), PeerMonitorOptions{})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
// A nil server must not panic; the monitor should degrade to an invalid
|
||||
// snapshot with an error rather than taking the GUI down.
|
||||
m.Start(ctx)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
snap := m.Snapshot()
|
||||
for _, p := range snap.Peers {
|
||||
_ = p.DisplayName
|
||||
_ = len(p.Samples)
|
||||
}
|
||||
_ = m.History("nonexistent")
|
||||
m.RefreshNow()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
snap := m.Snapshot()
|
||||
if snap.Valid {
|
||||
t.Error("snapshot from a nil tsnet server should not be valid")
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -75,7 +75,9 @@ func LanDiscoverService(ctx context.Context, entryList []LanEntry, logger *slog.
|
||||
}
|
||||
}
|
||||
|
||||
func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) {
|
||||
// LanEntriesFromRules collects the advertisements implied by the connect
|
||||
// rules. The GUI uses it to tell our own broadcasts apart from other servers'.
|
||||
func LanEntriesFromRules(rules map[string][]ConnectRule) []LanEntry {
|
||||
var lanEntries []LanEntry
|
||||
for tag, rs := range rules {
|
||||
for _, rule := range rs {
|
||||
@@ -89,6 +91,9 @@ func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule,
|
||||
})
|
||||
}
|
||||
}
|
||||
return lanEntries
|
||||
}
|
||||
|
||||
go LanDiscoverService(ctx, lanEntries, logger)
|
||||
func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) {
|
||||
go LanDiscoverService(ctx, LanEntriesFromRules(rules), logger)
|
||||
}
|
||||
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Minecraft's LAN discovery protocol: servers multicast the ASCII payload
|
||||
// "[MOTD]<motd>[/MOTD][AD]<port>[/AD]" to these groups roughly every 1.5s.
|
||||
// core/lan.go sends them; this file listens for them.
|
||||
const (
|
||||
lanScanGroupV4 = "224.0.2.60:4445"
|
||||
lanScanGroupV6 = "[ff75:230::60]:4445"
|
||||
)
|
||||
|
||||
const (
|
||||
// lanScanExpiry drops a server that stopped broadcasting.
|
||||
lanScanExpiry = 30 * time.Second
|
||||
// lanScanStale is how long a server may go unheard before the next packet
|
||||
// from it is treated as a real change worth waking the UI for. Without it
|
||||
// the GUI would redraw on every duplicate broadcast.
|
||||
lanScanStale = 10 * time.Second
|
||||
// lanScanSweep is the expiry tick interval.
|
||||
lanScanSweep = 5 * time.Second
|
||||
// lanScanBuf is the per-read buffer size; LAN announcements are tiny.
|
||||
lanScanBuf = 2048
|
||||
// lanScanMotdRunes caps a stored MOTD so a hostile peer cannot bloat the UI.
|
||||
lanScanMotdRunes = 120
|
||||
)
|
||||
|
||||
// LanServer is one Minecraft server seen broadcasting on the local network.
|
||||
type LanServer struct {
|
||||
Motd string // MOTD with Minecraft section-sign colour codes stripped
|
||||
RawMotd string // as received
|
||||
Port int //
|
||||
Source netip.AddrPort // who sent the packet
|
||||
Addr netip.Addr // Source.Addr(), the address to actually connect to
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
Count int // packets seen
|
||||
IsSelf bool // matches one of the entries tslink is advertising
|
||||
}
|
||||
|
||||
// lanScanKey deduplicates by sender address and advertised port. The sender's
|
||||
// ephemeral source port is deliberately excluded: it changes per socket.
|
||||
type lanScanKey struct {
|
||||
addr netip.Addr
|
||||
port int
|
||||
}
|
||||
|
||||
// LanScanner watches for Minecraft LAN broadcasts on every multicast-capable
|
||||
// interface and keeps a deduplicated, self-expiring view of what it heard.
|
||||
//
|
||||
// All methods are safe for concurrent use; the GUI calls [LanScanner.Servers]
|
||||
// from its frame loop while the read goroutines are writing.
|
||||
type LanScanner struct {
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
servers map[lanScanKey]*LanServer
|
||||
self []LanEntry
|
||||
lastErr string
|
||||
subs map[int]chan struct{}
|
||||
nextSub int
|
||||
|
||||
started bool
|
||||
// live counts read loops still running. A VPN or virtual adapter going
|
||||
// down kills its socket's loop; when the last one dies the scanner is
|
||||
// deaf, and Err() has to say so instead of continuing to report health.
|
||||
live int
|
||||
}
|
||||
|
||||
// NewLanScanner returns a scanner that has not started listening yet. A nil
|
||||
// logger falls back to slog.Default.
|
||||
func NewLanScanner(logger *slog.Logger) *LanScanner {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &LanScanner{
|
||||
logger: logger.With(slog.String("from", "lanscan")),
|
||||
servers: make(map[lanScanKey]*LanServer),
|
||||
subs: make(map[int]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSelfEntries tells the scanner which advertisements are our own, so the UI
|
||||
// can distinguish "the tunnel is working" from "someone else is hosting". It
|
||||
// may be called after Start and re-evaluates already-known servers.
|
||||
func (s *LanScanner) SetSelfEntries(entries []LanEntry) {
|
||||
cp := make([]LanEntry, len(entries))
|
||||
copy(cp, entries)
|
||||
|
||||
s.mu.Lock()
|
||||
s.self = cp
|
||||
changed := false
|
||||
for _, srv := range s.servers {
|
||||
self := matchesSelf(cp, srv.RawMotd, srv.Port)
|
||||
if self != srv.IsSelf {
|
||||
srv.IsSelf = self
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.notifyLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start begins listening; it returns immediately and stops when ctx is done.
|
||||
// Calling it twice is a no-op.
|
||||
func (s *LanScanner) Start(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.started = true
|
||||
s.mu.Unlock()
|
||||
|
||||
conns := s.listen()
|
||||
if len(conns) == 0 {
|
||||
s.mu.Lock()
|
||||
s.lastErr = "no multicast listener could be created"
|
||||
// Clear the guard so a caller that notices Err() can retry once the
|
||||
// network stack is up. Binding can fail simply because Start ran
|
||||
// before the interfaces existed, and a permanently dead scanner is a
|
||||
// worse outcome than a redundant retry.
|
||||
s.started = false
|
||||
s.mu.Unlock()
|
||||
s.logger.Warn("lan scan disabled, all multicast binds failed")
|
||||
return
|
||||
}
|
||||
s.logger.With(slog.Int("sockets", len(conns))).Debug("lan scan listening")
|
||||
|
||||
// One closer goroutine unblocks every read at once on cancellation.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
s.mu.Lock()
|
||||
s.live = len(conns)
|
||||
s.mu.Unlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, c := range conns {
|
||||
wg.Add(1)
|
||||
go func(c *net.UDPConn) {
|
||||
defer wg.Done()
|
||||
defer s.readerExited(ctx)
|
||||
s.readLoop(ctx, c)
|
||||
}(c)
|
||||
}
|
||||
go s.sweepLoop(ctx)
|
||||
go func() {
|
||||
wg.Wait()
|
||||
s.logger.Debug("lan scan stopped")
|
||||
}()
|
||||
}
|
||||
|
||||
// listen joins the IPv4 group on every up, multicast-capable interface plus a
|
||||
// nil-interface fallback, then does the same for IPv6. Per-interface failures
|
||||
// are expected (containers, down VPN adapters) and only logged at debug level.
|
||||
func (s *LanScanner) listen() []*net.UDPConn {
|
||||
var conns []*net.UDPConn
|
||||
|
||||
v4, err := net.ResolveUDPAddr("udp4", lanScanGroupV4)
|
||||
if err != nil {
|
||||
s.logger.With(slog.String("error", err.Error())).Error("failed to resolve ipv4 multicast group")
|
||||
}
|
||||
v6, err := net.ResolveUDPAddr("udp6", lanScanGroupV6)
|
||||
if err != nil {
|
||||
s.logger.With(slog.String("error", err.Error())).Debug("failed to resolve ipv6 multicast group")
|
||||
}
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
s.logger.With(slog.String("error", err.Error())).Warn("failed to enumerate interfaces, falling back to default")
|
||||
ifaces = nil
|
||||
}
|
||||
|
||||
for i := range ifaces {
|
||||
ifi := ifaces[i]
|
||||
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 {
|
||||
continue
|
||||
}
|
||||
if v4 != nil {
|
||||
if c, err := net.ListenMulticastUDP("udp4", &ifi, v4); err == nil {
|
||||
conns = append(conns, c)
|
||||
} else {
|
||||
s.logger.With(
|
||||
slog.String("iface", ifi.Name),
|
||||
slog.String("error", err.Error()),
|
||||
).Debug("ipv4 multicast join failed")
|
||||
}
|
||||
}
|
||||
if v6 != nil {
|
||||
if c, err := net.ListenMulticastUDP("udp6", &ifi, v6); err == nil {
|
||||
conns = append(conns, c)
|
||||
} else {
|
||||
s.logger.With(
|
||||
slog.String("iface", ifi.Name),
|
||||
slog.String("error", err.Error()),
|
||||
).Debug("ipv6 multicast join failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: let the OS pick the interface. On some hosts this is the only
|
||||
// socket that ever receives anything.
|
||||
if v4 != nil {
|
||||
if c, err := net.ListenMulticastUDP("udp4", nil, v4); err == nil {
|
||||
conns = append(conns, c)
|
||||
} else {
|
||||
s.logger.With(slog.String("error", err.Error())).Debug("default ipv4 multicast join failed")
|
||||
}
|
||||
}
|
||||
if v6 != nil {
|
||||
if c, err := net.ListenMulticastUDP("udp6", nil, v6); err == nil {
|
||||
conns = append(conns, c)
|
||||
} else {
|
||||
s.logger.With(slog.String("error", err.Error())).Debug("default ipv6 multicast join failed")
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range conns {
|
||||
_ = c.SetReadBuffer(64 * 1024)
|
||||
}
|
||||
return conns
|
||||
}
|
||||
|
||||
// readLoop drains one socket until ctx is done or the socket is closed. A
|
||||
// malformed packet is logged at debug level and never terminates the loop.
|
||||
func (s *LanScanner) readLoop(ctx context.Context, c *net.UDPConn) {
|
||||
buf := make([]byte, lanScanBuf)
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// A deadline guarantees the loop notices cancellation even if the
|
||||
// closer goroutine has not run yet.
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, src, err := c.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
var nerr net.Error
|
||||
if errors.As(err, &nerr) && nerr.Timeout() {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
// Anything else (ENETDOWN from an adapter disappearing, for
|
||||
// instance) means this socket is finished. Release it here rather
|
||||
// than leaving the fd until the process exits; the ctx closer
|
||||
// goroutine would otherwise be the only thing that ever closes it.
|
||||
s.logger.With(slog.String("error", err.Error())).Debug("lan scan read failed")
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
if n <= 0 || src == nil {
|
||||
continue
|
||||
}
|
||||
ap, ok := netip.AddrFromSlice(src.IP)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s.handle(netip.AddrPortFrom(ap.Unmap(), uint16(src.Port)), string(buf[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
// readerExited records that one read loop finished. Once every socket is gone
|
||||
// while the scanner is still meant to be running, Err() must report it — the
|
||||
// UI otherwise shows a healthy "listening" chip over a scanner that will never
|
||||
// hear another packet.
|
||||
func (s *LanScanner) readerExited(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
if s.live > 0 {
|
||||
s.live--
|
||||
}
|
||||
dead := s.live == 0 && ctx.Err() == nil
|
||||
if dead {
|
||||
s.lastErr = "all multicast listeners stopped, restart to rescan"
|
||||
s.started = false
|
||||
s.notifyLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if dead {
|
||||
s.logger.Warn("lan scan has no live listeners left")
|
||||
}
|
||||
}
|
||||
|
||||
// sweepLoop expires servers that stopped broadcasting.
|
||||
func (s *LanScanner) sweepLoop(ctx context.Context) {
|
||||
t := time.NewTicker(lanScanSweep)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.expire(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LanScanner) expire(now time.Time) {
|
||||
s.mu.Lock()
|
||||
changed := false
|
||||
for k, srv := range s.servers {
|
||||
if now.Sub(srv.LastSeen) > lanScanExpiry {
|
||||
delete(s.servers, k)
|
||||
changed = true
|
||||
s.logger.With(
|
||||
slog.String("addr", srv.Addr.String()),
|
||||
slog.Int("port", srv.Port),
|
||||
).Debug("lan server expired")
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.notifyLocked()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// handle records one parsed announcement.
|
||||
func (s *LanScanner) handle(src netip.AddrPort, payload string) {
|
||||
rawMotd, port, ok := parseLanAnnouncement(payload)
|
||||
if !ok {
|
||||
s.logger.With(
|
||||
slog.String("src", src.String()),
|
||||
slog.Int("len", len(payload)),
|
||||
).Debug("ignoring malformed lan announcement")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
key := lanScanKey{addr: src.Addr(), port: port}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
self := matchesSelf(s.self, rawMotd, port)
|
||||
if srv, ok := s.servers[key]; ok {
|
||||
// A repeat. Only wake the UI when something it renders actually moved.
|
||||
changed := srv.IsSelf != self || srv.RawMotd != rawMotd ||
|
||||
now.Sub(srv.LastSeen) > lanScanStale
|
||||
srv.LastSeen = now
|
||||
srv.Count++
|
||||
srv.RawMotd = rawMotd
|
||||
srv.Motd = cleanLanMotd(rawMotd)
|
||||
srv.IsSelf = self
|
||||
srv.Source = src
|
||||
if changed {
|
||||
s.notifyLocked()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
s.servers[key] = &LanServer{
|
||||
Motd: cleanLanMotd(rawMotd),
|
||||
RawMotd: rawMotd,
|
||||
Port: port,
|
||||
Source: src,
|
||||
Addr: src.Addr(),
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
Count: 1,
|
||||
IsSelf: self,
|
||||
}
|
||||
s.logger.With(
|
||||
slog.String("addr", src.Addr().String()),
|
||||
slog.Int("port", port),
|
||||
slog.Bool("self", self),
|
||||
).Debug("new lan server")
|
||||
s.notifyLocked()
|
||||
}
|
||||
|
||||
// Servers returns the currently-known servers, freshest first, safe to call
|
||||
// from the UI. The result is a copy: LanServer holds no reference types, so
|
||||
// the caller may read it without holding any lock.
|
||||
func (s *LanScanner) Servers() []LanServer {
|
||||
s.mu.RLock()
|
||||
out := make([]LanServer, 0, len(s.servers))
|
||||
for _, srv := range s.servers {
|
||||
out = append(out, *srv)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Deterministic ordering keeps the GUI from jittering between refreshes:
|
||||
// our own advertisements sink to the bottom, then freshest first.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
if a.IsSelf != b.IsSelf {
|
||||
return !a.IsSelf
|
||||
}
|
||||
if !a.LastSeen.Equal(b.LastSeen) {
|
||||
return a.LastSeen.After(b.LastSeen)
|
||||
}
|
||||
if a.Port != b.Port {
|
||||
return a.Port < b.Port
|
||||
}
|
||||
return a.Source.String() < b.Source.String()
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Err returns the last listener error, if the scanner could not bind at all.
|
||||
// It is empty while the scanner is healthy.
|
||||
func (s *LanScanner) Err() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.lastErr
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives a value whenever the server set
|
||||
// meaningfully changes, plus a function that cancels the subscription. The
|
||||
// channel is buffered and coalescing: a slow reader sees one wakeup, not a
|
||||
// backlog of duplicate broadcasts.
|
||||
func (s *LanScanner) Subscribe() (<-chan struct{}, func()) {
|
||||
ch := make(chan struct{}, 1)
|
||||
s.mu.Lock()
|
||||
id := s.nextSub
|
||||
s.nextSub++
|
||||
s.subs[id] = ch
|
||||
s.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
cancel := func() {
|
||||
once.Do(func() {
|
||||
s.mu.Lock()
|
||||
delete(s.subs, id)
|
||||
s.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ch, cancel
|
||||
}
|
||||
|
||||
// notifyLocked wakes every subscriber. The caller must hold s.mu.
|
||||
func (s *LanScanner) notifyLocked() {
|
||||
for _, ch := range s.subs {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default: // subscriber has a pending wakeup already
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseLanAnnouncement extracts the MOTD and port from a Minecraft LAN
|
||||
// broadcast. It is strict: anything not shaped exactly like
|
||||
// "[MOTD]…[/MOTD][AD]<1..65535>[/AD]" is rejected.
|
||||
func parseLanAnnouncement(payload string) (motd string, port int, ok bool) {
|
||||
motd, ok = between(payload, "[MOTD]", "[/MOTD]")
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
ad, ok := between(payload, "[AD]", "[/AD]")
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(ad))
|
||||
if err != nil || !validPort(port) {
|
||||
return "", 0, false
|
||||
}
|
||||
return motd, port, true
|
||||
}
|
||||
|
||||
// between returns the text enclosed by the first open tag and the first close
|
||||
// tag that follows it.
|
||||
func between(s, openTag, closeTag string) (string, bool) {
|
||||
i := strings.Index(s, openTag)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := s[i+len(openTag):]
|
||||
j := strings.Index(rest, closeTag)
|
||||
if j < 0 {
|
||||
return "", false
|
||||
}
|
||||
return rest[:j], true
|
||||
}
|
||||
|
||||
// cleanLanMotd strips Minecraft section-sign colour codes, trims whitespace and
|
||||
// caps the result so an oversized announcement cannot distort the UI.
|
||||
func cleanLanMotd(raw string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(raw))
|
||||
skip := false
|
||||
for _, r := range raw {
|
||||
if skip {
|
||||
// Drop the single formatting character following the section sign.
|
||||
skip = false
|
||||
continue
|
||||
}
|
||||
if r == '§' {
|
||||
skip = true
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
|
||||
n := 0
|
||||
for i := range out {
|
||||
n++
|
||||
if n > lanScanMotdRunes {
|
||||
return out[:i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchesSelf reports whether an announcement corresponds to one of our own
|
||||
// advertised entries. Comparison uses the raw MOTD, which is exactly what
|
||||
// core/lan.go puts on the wire.
|
||||
func matchesSelf(self []LanEntry, rawMotd string, port int) bool {
|
||||
for _, e := range self {
|
||||
if e.Port == port && e.Motd == rawMotd {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogAttr is one flattened structured field. Groups are folded into the key
|
||||
// with dots so the GUI can render a single flat line per entry.
|
||||
type LogAttr struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// LogEntry is a single captured log record.
|
||||
type LogEntry struct {
|
||||
Seq uint64
|
||||
Time time.Time
|
||||
Level slog.Level
|
||||
Msg string
|
||||
Attrs []LogAttr
|
||||
// Source is the value of the conventional "from" attribute, used by the
|
||||
// GUI to group logs by subsystem.
|
||||
Source string
|
||||
}
|
||||
|
||||
// Text renders the entry the way the console handler would, minus colour.
|
||||
func (e LogEntry) Text() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(e.Time.Format("2006-01-02 15:04:05.000"))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(levelLabel(e.Level))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(e.Msg)
|
||||
for _, a := range e.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(a.Key)
|
||||
b.WriteByte('=')
|
||||
if strings.ContainsAny(a.Value, " \t\"") {
|
||||
fmt.Fprintf(&b, "%q", a.Value)
|
||||
} else {
|
||||
b.WriteString(a.Value)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func levelLabel(l slog.Level) string {
|
||||
switch {
|
||||
case l < slog.LevelInfo:
|
||||
return "DBG"
|
||||
case l < slog.LevelWarn:
|
||||
return "INF"
|
||||
case l < slog.LevelError:
|
||||
return "WRN"
|
||||
default:
|
||||
return "ERR"
|
||||
}
|
||||
}
|
||||
|
||||
// LevelLabel exposes the three-letter level name used in exports and the GUI.
|
||||
func LevelLabel(l slog.Level) string { return levelLabel(l) }
|
||||
|
||||
// LogQuery filters a buffer snapshot.
|
||||
type LogQuery struct {
|
||||
// MinLevel drops anything below it.
|
||||
MinLevel slog.Level
|
||||
// Text is a case-insensitive substring matched against the message, the
|
||||
// attribute values and the source.
|
||||
Text string
|
||||
// Source, when set, keeps only entries from that subsystem.
|
||||
Source string
|
||||
// Limit keeps only the newest N matches. Zero means unlimited.
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (q LogQuery) match(e LogEntry) bool {
|
||||
if e.Level < q.MinLevel {
|
||||
return false
|
||||
}
|
||||
if q.Source != "" && e.Source != q.Source {
|
||||
return false
|
||||
}
|
||||
if q.Text == "" {
|
||||
return true
|
||||
}
|
||||
needle := strings.ToLower(q.Text)
|
||||
if strings.Contains(strings.ToLower(e.Msg), needle) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(e.Source), needle) {
|
||||
return true
|
||||
}
|
||||
for _, a := range e.Attrs {
|
||||
if strings.Contains(strings.ToLower(a.Key), needle) ||
|
||||
strings.Contains(strings.ToLower(a.Value), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LogBuffer is a fixed-capacity ring of the most recent log records. It is the
|
||||
// single source of truth for the GUI's log view and for diagnostic exports.
|
||||
//
|
||||
// All methods are safe for concurrent use.
|
||||
type LogBuffer struct {
|
||||
mu sync.RWMutex
|
||||
entries []LogEntry // ring storage, len == cap once full
|
||||
start int // index of the oldest entry
|
||||
count int
|
||||
nextSeq uint64
|
||||
dropped uint64
|
||||
subs map[int]chan struct{}
|
||||
nextSub int
|
||||
sources map[string]int
|
||||
levelCnt map[slog.Level]int
|
||||
}
|
||||
|
||||
// DefaultLogCapacity is how many records the GUI keeps in memory. At roughly
|
||||
// 200 bytes per record this is a few megabytes at most.
|
||||
const DefaultLogCapacity = 20000
|
||||
|
||||
// NewLogBuffer returns a buffer holding at most capacity records.
|
||||
func NewLogBuffer(capacity int) *LogBuffer {
|
||||
if capacity <= 0 {
|
||||
capacity = DefaultLogCapacity
|
||||
}
|
||||
return &LogBuffer{
|
||||
entries: make([]LogEntry, capacity),
|
||||
subs: make(map[int]chan struct{}),
|
||||
sources: make(map[string]int),
|
||||
levelCnt: make(map[slog.Level]int),
|
||||
}
|
||||
}
|
||||
|
||||
// Add appends an entry, evicting the oldest record when full.
|
||||
func (b *LogBuffer) Add(e LogEntry) {
|
||||
b.mu.Lock()
|
||||
b.nextSeq++
|
||||
e.Seq = b.nextSeq
|
||||
|
||||
capacity := len(b.entries)
|
||||
if b.count == capacity {
|
||||
evicted := b.entries[b.start]
|
||||
b.decStatsLocked(evicted)
|
||||
b.entries[b.start] = e
|
||||
b.start = (b.start + 1) % capacity
|
||||
b.dropped++
|
||||
} else {
|
||||
b.entries[(b.start+b.count)%capacity] = e
|
||||
b.count++
|
||||
}
|
||||
b.incStatsLocked(e)
|
||||
|
||||
for _, ch := range b.subs {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default: // subscriber has a pending wakeup already
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *LogBuffer) incStatsLocked(e LogEntry) {
|
||||
b.levelCnt[e.Level]++
|
||||
if e.Source != "" {
|
||||
b.sources[e.Source]++
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LogBuffer) decStatsLocked(e LogEntry) {
|
||||
b.levelCnt[e.Level]--
|
||||
if b.levelCnt[e.Level] <= 0 {
|
||||
delete(b.levelCnt, e.Level)
|
||||
}
|
||||
if e.Source != "" {
|
||||
b.sources[e.Source]--
|
||||
if b.sources[e.Source] <= 0 {
|
||||
delete(b.sources, e.Source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of buffered records.
|
||||
func (b *LogBuffer) Len() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.count
|
||||
}
|
||||
|
||||
// Dropped returns how many records were evicted because the ring was full.
|
||||
func (b *LogBuffer) Dropped() uint64 {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.dropped
|
||||
}
|
||||
|
||||
// LastSeq returns the sequence number of the most recent record.
|
||||
func (b *LogBuffer) LastSeq() uint64 {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.nextSeq
|
||||
}
|
||||
|
||||
// Counts returns how many buffered records exist per level.
|
||||
func (b *LogBuffer) Counts() map[slog.Level]int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make(map[slog.Level]int, len(b.levelCnt))
|
||||
for k, v := range b.levelCnt {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Sources returns the distinct subsystem names currently buffered, sorted.
|
||||
func (b *LogBuffer) Sources() []string {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make([]string, 0, len(b.sources))
|
||||
for k := range b.sources {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Snapshot returns every buffered record, oldest first.
|
||||
func (b *LogBuffer) Snapshot() []LogEntry {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.collectLocked(func(LogEntry) bool { return true }, 0)
|
||||
}
|
||||
|
||||
// Tail returns the newest n records, oldest first.
|
||||
//
|
||||
// It walks backwards from the newest record so the cost is O(n), not O(ring).
|
||||
// The GUI's log overlay calls this on every frame; scanning a full 20k-entry
|
||||
// ring each time was enough on its own to keep a core busy.
|
||||
func (b *LogBuffer) Tail(n int) []LogEntry {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.newestLocked(func(LogEntry) bool { return true }, n)
|
||||
}
|
||||
|
||||
// Filter returns the records matching q, oldest first.
|
||||
func (b *LogBuffer) Filter(q LogQuery) []LogEntry {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
if q.Limit > 0 {
|
||||
return b.newestLocked(q.match, q.Limit)
|
||||
}
|
||||
return b.collectLocked(q.match, 0)
|
||||
}
|
||||
|
||||
// newestLocked walks the ring newest-first, keeping at most limit matches, and
|
||||
// returns them oldest-first.
|
||||
func (b *LogBuffer) newestLocked(keep func(LogEntry) bool, limit int) []LogEntry {
|
||||
capacity := len(b.entries)
|
||||
out := make([]LogEntry, 0, min(limit, b.count))
|
||||
for i := b.count - 1; i >= 0 && len(out) < limit; i-- {
|
||||
e := b.entries[(b.start+i)%capacity]
|
||||
if keep(e) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
// Reverse in place to restore chronological order.
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectLocked walks the ring oldest-first. When limit > 0 only the newest
|
||||
// limit matches are kept.
|
||||
func (b *LogBuffer) collectLocked(keep func(LogEntry) bool, limit int) []LogEntry {
|
||||
capacity := len(b.entries)
|
||||
out := make([]LogEntry, 0, min(b.count, 512))
|
||||
for i := 0; i < b.count; i++ {
|
||||
e := b.entries[(b.start+i)%capacity]
|
||||
if keep(e) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[len(out)-limit:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives a value whenever a record is
|
||||
// added, plus a function that cancels the subscription. The channel is
|
||||
// buffered and coalescing: a slow reader sees one wakeup, not a backlog.
|
||||
func (b *LogBuffer) Subscribe() (<-chan struct{}, func()) {
|
||||
ch := make(chan struct{}, 1)
|
||||
b.mu.Lock()
|
||||
id := b.nextSub
|
||||
b.nextSub++
|
||||
b.subs[id] = ch
|
||||
b.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
cancel := func() {
|
||||
once.Do(func() {
|
||||
b.mu.Lock()
|
||||
delete(b.subs, id)
|
||||
b.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ch, cancel
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// slog handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// bufHandler tees records into a LogBuffer and on to a wrapped handler.
|
||||
type bufHandler struct {
|
||||
buf *LogBuffer
|
||||
next slog.Handler
|
||||
attrs []LogAttr
|
||||
groups []string
|
||||
}
|
||||
|
||||
// Handler returns a slog.Handler that records everything into b and forwards
|
||||
// to next. next may be nil, in which case records are only buffered.
|
||||
//
|
||||
// The buffer always captures at debug level regardless of what next filters,
|
||||
// so the GUI can show detail the console suppressed.
|
||||
func (b *LogBuffer) Handler(next slog.Handler) slog.Handler {
|
||||
return &bufHandler{buf: b, next: next}
|
||||
}
|
||||
|
||||
func (h *bufHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
// Always capture: the buffer is the diagnostic record of last resort.
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *bufHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
attrs := make([]LogAttr, 0, len(h.attrs)+r.NumAttrs())
|
||||
attrs = append(attrs, h.attrs...)
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
attrs = appendAttr(attrs, h.groups, a)
|
||||
return true
|
||||
})
|
||||
|
||||
source := ""
|
||||
for _, a := range attrs {
|
||||
if a.Key == "from" {
|
||||
source = a.Value
|
||||
}
|
||||
}
|
||||
|
||||
t := r.Time
|
||||
if t.IsZero() {
|
||||
t = time.Now()
|
||||
}
|
||||
h.buf.Add(LogEntry{
|
||||
Time: t,
|
||||
Level: r.Level,
|
||||
Msg: r.Message,
|
||||
Attrs: attrs,
|
||||
Source: source,
|
||||
})
|
||||
|
||||
if h.next != nil && h.next.Enabled(ctx, r.Level) {
|
||||
return h.next.Handle(ctx, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *bufHandler) WithAttrs(as []slog.Attr) slog.Handler {
|
||||
if len(as) == 0 {
|
||||
return h
|
||||
}
|
||||
clone := *h
|
||||
clone.attrs = make([]LogAttr, len(h.attrs), len(h.attrs)+len(as))
|
||||
copy(clone.attrs, h.attrs)
|
||||
for _, a := range as {
|
||||
clone.attrs = appendAttr(clone.attrs, h.groups, a)
|
||||
}
|
||||
if h.next != nil {
|
||||
clone.next = h.next.WithAttrs(as)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (h *bufHandler) WithGroup(name string) slog.Handler {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
clone := *h
|
||||
clone.groups = append(append([]string(nil), h.groups...), name)
|
||||
if h.next != nil {
|
||||
clone.next = h.next.WithGroup(name)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// appendAttr flattens a slog.Attr, expanding groups into dotted keys.
|
||||
func appendAttr(dst []LogAttr, groups []string, a slog.Attr) []LogAttr {
|
||||
a.Value = a.Value.Resolve()
|
||||
if a.Equal(slog.Attr{}) {
|
||||
return dst
|
||||
}
|
||||
if a.Value.Kind() == slog.KindGroup {
|
||||
sub := a.Value.Group()
|
||||
if len(sub) == 0 {
|
||||
return dst
|
||||
}
|
||||
nested := groups
|
||||
if a.Key != "" {
|
||||
nested = append(append([]string(nil), groups...), a.Key)
|
||||
}
|
||||
for _, s := range sub {
|
||||
dst = appendAttr(dst, nested, s)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
key := a.Key
|
||||
if len(groups) > 0 {
|
||||
key = strings.Join(groups, ".") + "." + key
|
||||
}
|
||||
return append(dst, LogAttr{Key: key, Value: a.Value.String()})
|
||||
}
|
||||
|
||||
// NewLoggerWithBuffer builds the console logger exactly as [NewLogger] does
|
||||
// and tees every record into buf.
|
||||
func NewLoggerWithBuffer(level string, useJsonFormat bool, buf *LogBuffer) *slog.Logger {
|
||||
base := NewLogger(level, useJsonFormat)
|
||||
logger := slog.New(buf.Handler(base.Handler()))
|
||||
slog.SetDefault(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// secretPattern matches Tailscale auth keys and OAuth client secrets, which
|
||||
// are the one thing in these logs that must never reach a paste service.
|
||||
var secretPattern = regexp.MustCompile(`\b(tskey-[a-zA-Z]+-)[A-Za-z0-9\-_]{6,}`)
|
||||
|
||||
// secretKeys are attribute names whose values are replaced wholesale.
|
||||
var secretKeys = map[string]bool{
|
||||
"auth_key": true,
|
||||
"authkey": true,
|
||||
"auth-key": true,
|
||||
"token": true,
|
||||
"secret": true,
|
||||
"password": true,
|
||||
"client_secret": true,
|
||||
}
|
||||
|
||||
// Redact removes credentials from a single string.
|
||||
func Redact(s string) string {
|
||||
return secretPattern.ReplaceAllString(s, "${1}REDACTED")
|
||||
}
|
||||
|
||||
func redactAttr(a LogAttr) LogAttr {
|
||||
if secretKeys[strings.ToLower(a.Key)] {
|
||||
if a.Value == "" {
|
||||
return a
|
||||
}
|
||||
return LogAttr{Key: a.Key, Value: "[REDACTED]"}
|
||||
}
|
||||
a.Value = Redact(a.Value)
|
||||
return a
|
||||
}
|
||||
|
||||
// ExportOptions controls how a log dump is rendered.
|
||||
type ExportOptions struct {
|
||||
Query LogQuery
|
||||
// Redact strips credentials. Callers sharing logs publicly must leave this
|
||||
// on; it defaults to on because [ExportText] is built for sharing.
|
||||
NoRedact bool
|
||||
// Header is prepended verbatim, used for environment metadata.
|
||||
Header string
|
||||
}
|
||||
|
||||
// ExportText renders matching entries as a plain-text report suitable for
|
||||
// pasting into an issue tracker or a paste service.
|
||||
func (b *LogBuffer) ExportText(opt ExportOptions) string {
|
||||
entries := b.Filter(opt.Query)
|
||||
|
||||
var sb strings.Builder
|
||||
if opt.Header != "" {
|
||||
sb.WriteString(opt.Header)
|
||||
if !strings.HasSuffix(opt.Header, "\n") {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if dropped := b.Dropped(); dropped > 0 {
|
||||
fmt.Fprintf(&sb, "# %d earlier record(s) were dropped from the ring buffer\n\n", dropped)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !opt.NoRedact {
|
||||
e.Msg = Redact(e.Msg)
|
||||
redacted := make([]LogAttr, len(e.Attrs))
|
||||
for i, a := range e.Attrs {
|
||||
redacted[i] = redactAttr(a)
|
||||
}
|
||||
e.Attrs = redacted
|
||||
}
|
||||
sb.WriteString(e.Text())
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
sb.WriteString("(no matching log entries)\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
+858
@@ -0,0 +1,858 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
// PeerRoute is how traffic currently reaches a peer.
|
||||
type PeerRoute string
|
||||
|
||||
const (
|
||||
RouteDirect PeerRoute = "direct"
|
||||
RouteDERP PeerRoute = "derp"
|
||||
RoutePeerRelay PeerRoute = "peer-relay"
|
||||
RouteOffline PeerRoute = "offline"
|
||||
RouteUnknown PeerRoute = "unknown"
|
||||
)
|
||||
|
||||
// PeerSample is one latency measurement.
|
||||
type PeerSample struct {
|
||||
At time.Time
|
||||
Latency time.Duration
|
||||
OK bool
|
||||
Route PeerRoute
|
||||
}
|
||||
|
||||
// PeerInfo is everything the GUI shows about one node.
|
||||
type PeerInfo struct {
|
||||
ID, HostName, DNSName, DisplayName, OS string
|
||||
TailscaleIPs []netip.Addr
|
||||
Online, Active, ExitNode bool
|
||||
CurAddr, Relay string
|
||||
Route PeerRoute
|
||||
RxBytes, TxBytes int64
|
||||
Created, LastSeen, LastWrite, LastHandshake time.Time
|
||||
// Linked reports that a config rule points at this peer; those are the nodes
|
||||
// the user actually cares about and the GUI lists them first.
|
||||
Linked bool
|
||||
LinkTags []string
|
||||
LastLatency time.Duration
|
||||
LatencyOK bool
|
||||
Samples []PeerSample // chronological, oldest first
|
||||
AvgLatency time.Duration
|
||||
MinLatency time.Duration
|
||||
MaxLatency time.Duration
|
||||
JitterMs float64 // mean absolute successive difference
|
||||
LossPct float64
|
||||
}
|
||||
|
||||
// clone returns a deep copy of p so callers cannot reach into monitor state.
|
||||
func (p PeerInfo) clone() PeerInfo {
|
||||
out := p
|
||||
out.TailscaleIPs = append([]netip.Addr(nil), p.TailscaleIPs...)
|
||||
out.LinkTags = append([]string(nil), p.LinkTags...)
|
||||
out.Samples = append([]PeerSample(nil), p.Samples...)
|
||||
return out
|
||||
}
|
||||
|
||||
// PeerSnapshot is a consistent view of the tailnet at one instant.
|
||||
type PeerSnapshot struct {
|
||||
At time.Time
|
||||
Valid bool
|
||||
Self PeerInfo
|
||||
Peers []PeerInfo
|
||||
TailnetName string
|
||||
BackendState string
|
||||
MagicDNSSuffix string
|
||||
Err string
|
||||
}
|
||||
|
||||
// clone returns a deep copy of s, including every peer's slices.
|
||||
func (s PeerSnapshot) clone() PeerSnapshot {
|
||||
out := s
|
||||
out.Self = s.Self.clone()
|
||||
out.Peers = make([]PeerInfo, len(s.Peers))
|
||||
for i, p := range s.Peers {
|
||||
out.Peers[i] = p.clone()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PeerMonitorOptions tunes the two polling loops and the history depth.
|
||||
type PeerMonitorOptions struct {
|
||||
// StatusInterval defaults to 3s, PingInterval to 10s, HistorySize to 120 samples.
|
||||
StatusInterval, PingInterval time.Duration
|
||||
HistorySize int
|
||||
}
|
||||
|
||||
const (
|
||||
defaultStatusInterval = 3 * time.Second
|
||||
defaultPingInterval = 10 * time.Second
|
||||
defaultHistorySize = 120
|
||||
|
||||
// pingTimeout bounds a single peer ping. A hung probe must never stall the
|
||||
// sweep, and the sweep must never outlive its own interval by much.
|
||||
pingTimeout = 5 * time.Second
|
||||
// pingConcurrency bounds in-flight pings so a large tailnet cannot spawn
|
||||
// hundreds of goroutines at once.
|
||||
pingConcurrency = 4
|
||||
// linkResolveInterval re-resolves config rules, because MagicDNS answers
|
||||
// change when a peer's address is reassigned.
|
||||
linkResolveInterval = 5 * time.Minute
|
||||
// linkResolveTimeout bounds resolution of a single rule destination.
|
||||
linkResolveTimeout = 10 * time.Second
|
||||
// statusTimeout bounds one lc.Status call.
|
||||
statusTimeout = 10 * time.Second
|
||||
// maxStatusBackoff caps the retry delay after repeated status failures.
|
||||
maxStatusBackoff = 30 * time.Second
|
||||
)
|
||||
|
||||
func (o PeerMonitorOptions) withDefaults() PeerMonitorOptions {
|
||||
if o.StatusInterval <= 0 {
|
||||
o.StatusInterval = defaultStatusInterval
|
||||
}
|
||||
if o.PingInterval <= 0 {
|
||||
o.PingInterval = defaultPingInterval
|
||||
}
|
||||
if o.HistorySize <= 0 {
|
||||
o.HistorySize = defaultHistorySize
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// pingOutcome is the most recent ping result for one peer, used to refine the
|
||||
// route derivation that the status fields alone can only guess at.
|
||||
type pingOutcome struct {
|
||||
ok bool
|
||||
latency time.Duration
|
||||
derpRegion string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// PeerMonitor keeps a live view of the tailnet for the GUI: a cheap status
|
||||
// poll, an independent ping sweep, and a capped latency history per peer.
|
||||
//
|
||||
// All methods are safe for concurrent use.
|
||||
type PeerMonitor struct {
|
||||
srv *tsnet.Server
|
||||
rules map[string][]ConnectRule
|
||||
log *slog.Logger
|
||||
opt PeerMonitorOptions
|
||||
|
||||
refreshStatus chan struct{}
|
||||
refreshPing chan struct{}
|
||||
|
||||
mu sync.RWMutex
|
||||
raw *ipnstate.Status // last good status, nil until the first poll lands
|
||||
rawErr string
|
||||
built PeerSnapshot // rebuilt after every poll and sweep
|
||||
hist map[string][]PeerSample
|
||||
last map[string]pingOutcome
|
||||
links map[netip.Addr][]string
|
||||
subs map[int]chan struct{}
|
||||
nextSub int
|
||||
}
|
||||
|
||||
// NewPeerMonitor returns a monitor for srv. rules are the configured connect
|
||||
// rules, used to mark which peers the user actually links to; it may be nil.
|
||||
// logger may be nil.
|
||||
func NewPeerMonitor(srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger, opt PeerMonitorOptions) *PeerMonitor {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &PeerMonitor{
|
||||
srv: srv,
|
||||
rules: rules,
|
||||
log: logger.With("from", "peermon"),
|
||||
opt: opt.withDefaults(),
|
||||
refreshStatus: make(chan struct{}, 1),
|
||||
refreshPing: make(chan struct{}, 1),
|
||||
hist: make(map[string][]PeerSample),
|
||||
last: make(map[string]pingOutcome),
|
||||
links: make(map[netip.Addr][]string),
|
||||
subs: make(map[int]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the status loop, the ping loop and the link resolver. All of
|
||||
// them stop when ctx is cancelled. Start does not block.
|
||||
func (m *PeerMonitor) Start(ctx context.Context) {
|
||||
go m.statusLoop(ctx)
|
||||
go m.pingLoop(ctx)
|
||||
go m.linkLoop(ctx)
|
||||
}
|
||||
|
||||
// RefreshNow triggers an immediate status+ping cycle without blocking the caller.
|
||||
func (m *PeerMonitor) RefreshNow() {
|
||||
kick(m.refreshStatus)
|
||||
kick(m.refreshPing)
|
||||
}
|
||||
|
||||
// kick delivers a coalescing wakeup: a pending signal is enough.
|
||||
func kick(ch chan struct{}) {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns a consistent, fully copied view of the tailnet. It performs
|
||||
// no I/O and is safe to call from the render path.
|
||||
func (m *PeerMonitor) Snapshot() PeerSnapshot {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.built.clone()
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives a value after every status poll and
|
||||
// every completed ping sweep, plus a function that cancels the subscription.
|
||||
// The channel is buffered and coalescing: a slow reader sees one wakeup, not a
|
||||
// backlog.
|
||||
func (m *PeerMonitor) Subscribe() (<-chan struct{}, func()) {
|
||||
ch := make(chan struct{}, 1)
|
||||
m.mu.Lock()
|
||||
id := m.nextSub
|
||||
m.nextSub++
|
||||
m.subs[id] = ch
|
||||
m.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
cancel := func() {
|
||||
once.Do(func() {
|
||||
m.mu.Lock()
|
||||
delete(m.subs, id)
|
||||
m.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ch, cancel
|
||||
}
|
||||
|
||||
// History returns the samples for one peer keyed by stable node ID, oldest
|
||||
// first. The returned slice is a copy.
|
||||
func (m *PeerMonitor) History(id string) []PeerSample {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return append([]PeerSample(nil), m.hist[id]...)
|
||||
}
|
||||
|
||||
func (m *PeerMonitor) notify() {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, ch := range m.subs {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default: // subscriber has a pending wakeup already
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// status loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// statusLoop polls lc.Status on StatusInterval. It never waits on the ping
|
||||
// sweep, so a slow tailnet cannot freeze the peer list in the GUI.
|
||||
func (m *PeerMonitor) statusLoop(ctx context.Context) {
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
|
||||
var fails int
|
||||
first := true
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
case <-m.refreshStatus:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := m.pollStatus(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
delay := m.opt.StatusInterval
|
||||
if err != nil {
|
||||
fails++
|
||||
delay = backoffDelay(m.opt.StatusInterval, fails)
|
||||
m.log.Debug("status poll failed", "err", err, "retry_in", delay)
|
||||
} else {
|
||||
fails = 0
|
||||
if first {
|
||||
first = false
|
||||
kick(m.refreshPing) // ping as soon as we know who is out there
|
||||
}
|
||||
}
|
||||
timer.Reset(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// backoffDelay grows the retry delay exponentially, capped at maxStatusBackoff.
|
||||
func backoffDelay(base time.Duration, fails int) time.Duration {
|
||||
d := base
|
||||
for i := 1; i < fails && d < maxStatusBackoff; i++ {
|
||||
d *= 2
|
||||
}
|
||||
if d > maxStatusBackoff {
|
||||
d = maxStatusBackoff
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// pollStatus refreshes the cached status. On failure the previous status is
|
||||
// kept so the GUI degrades to stale data instead of going blank.
|
||||
func (m *PeerMonitor) pollStatus(ctx context.Context) error {
|
||||
lc, err := m.localClient()
|
||||
if err == nil {
|
||||
var st *ipnstate.Status
|
||||
st, err = func() (*ipnstate.Status, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||
defer cancel()
|
||||
return lc.Status(cctx)
|
||||
}()
|
||||
if err == nil {
|
||||
m.mu.Lock()
|
||||
m.raw = st
|
||||
m.rawErr = ""
|
||||
m.rebuildLocked()
|
||||
m.mu.Unlock()
|
||||
m.notify()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.rawErr = err.Error()
|
||||
m.rebuildLocked()
|
||||
m.mu.Unlock()
|
||||
m.notify()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *PeerMonitor) localClient() (*local.Client, error) {
|
||||
if m.srv == nil {
|
||||
return nil, errors.New("tsnet server not started")
|
||||
}
|
||||
return m.srv.LocalClient()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ping loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// pingLoop sweeps every online peer on PingInterval. A sweep that overruns its
|
||||
// interval simply delays the next sweep; the status loop is unaffected.
|
||||
func (m *PeerMonitor) pingLoop(ctx context.Context) {
|
||||
timer := time.NewTimer(m.opt.PingInterval)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
case <-m.refreshPing:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.pingSweep(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
timer.Reset(m.opt.PingInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// pingTarget is one node to probe in a sweep.
|
||||
type pingTarget struct {
|
||||
id string
|
||||
name string
|
||||
addr netip.Addr
|
||||
}
|
||||
|
||||
// pingTargets lists the online peers worth probing, taken from the last good
|
||||
// status. Self is skipped: pinging your own address is not a network test.
|
||||
func (m *PeerMonitor) pingTargets() []pingTarget {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.raw == nil {
|
||||
return nil
|
||||
}
|
||||
var out []pingTarget
|
||||
for _, ps := range m.raw.Peer {
|
||||
if ps == nil || !ps.Online {
|
||||
continue
|
||||
}
|
||||
addr := pingAddr(ps.TailscaleIPs)
|
||||
if !addr.IsValid() {
|
||||
continue
|
||||
}
|
||||
out = append(out, pingTarget{id: peerKey(ps), name: displayName(ps), addr: addr})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
|
||||
return out
|
||||
}
|
||||
|
||||
// pingSweep probes every online peer, bounded to pingConcurrency in flight.
|
||||
func (m *PeerMonitor) pingSweep(ctx context.Context) {
|
||||
targets := m.pingTargets()
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
lc, err := m.localClient()
|
||||
if err != nil {
|
||||
m.log.Debug("ping sweep skipped", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, pingConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, t := range targets {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
wg.Wait()
|
||||
return
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(t pingTarget) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
m.pingOne(ctx, lc, t)
|
||||
}(t)
|
||||
}
|
||||
wg.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.rebuildLocked()
|
||||
m.mu.Unlock()
|
||||
m.notify()
|
||||
}
|
||||
|
||||
// pingOne probes a single peer and records the outcome. A failure is recorded
|
||||
// as a sample with OK=false: loss is data.
|
||||
func (m *PeerMonitor) pingOne(ctx context.Context, lc *local.Client, t pingTarget) {
|
||||
cctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := lc.Ping(cctx, t.addr, tailcfg.PingDisco)
|
||||
now := time.Now()
|
||||
|
||||
out := pingOutcome{at: now}
|
||||
switch {
|
||||
case err != nil:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
m.log.Debug("peer ping failed", "peer", t.name, "addr", t.addr, "err", err)
|
||||
}
|
||||
case res == nil:
|
||||
m.log.Debug("peer ping returned nothing", "peer", t.name, "addr", t.addr)
|
||||
case res.Err != "":
|
||||
m.log.Debug("peer ping error", "peer", t.name, "addr", t.addr, "err", res.Err)
|
||||
default:
|
||||
out.ok = true
|
||||
out.latency = time.Duration(res.LatencySeconds * float64(time.Second))
|
||||
out.derpRegion = res.DERPRegionCode
|
||||
}
|
||||
|
||||
sample := PeerSample{At: now, Latency: out.latency, OK: out.ok}
|
||||
if out.ok {
|
||||
if out.derpRegion == "" {
|
||||
sample.Route = RouteDirect
|
||||
} else {
|
||||
sample.Route = RouteDERP
|
||||
}
|
||||
} else {
|
||||
sample.Route = RouteUnknown
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.last[t.id] = out
|
||||
m.hist[t.id] = appendSample(m.hist[t.id], sample, m.opt.HistorySize)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// appendSample pushes s onto a capped ring, dropping the oldest entry when
|
||||
// full. Chronological order is preserved.
|
||||
func appendSample(ring []PeerSample, s PeerSample, size int) []PeerSample {
|
||||
if size <= 0 {
|
||||
size = defaultHistorySize
|
||||
}
|
||||
if len(ring) < size {
|
||||
return append(ring, s)
|
||||
}
|
||||
// Shift left by the overflow so a shrunken HistorySize also converges.
|
||||
drop := len(ring) - size + 1
|
||||
copy(ring, ring[drop:])
|
||||
ring = ring[:size-1]
|
||||
return append(ring, s)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// link resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// linkLoop resolves every connect rule's destination to a tailnet address once
|
||||
// at start and again every linkResolveInterval. Resolution touches the network,
|
||||
// so it never happens on the render path.
|
||||
func (m *PeerMonitor) linkLoop(ctx context.Context) {
|
||||
if len(m.rules) == 0 || m.srv == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(linkResolveInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
m.resolveLinks(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.resolveLinks(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLinks maps every rule destination to a peer address, remembering which
|
||||
// config tags referenced it.
|
||||
func (m *PeerMonitor) resolveLinks(ctx context.Context) {
|
||||
found := make(map[netip.Addr]map[string]struct{})
|
||||
|
||||
for tag, rules := range m.rules {
|
||||
for _, rule := range rules {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
host, _, err := net.SplitHostPort(rule.DstAddr)
|
||||
if err != nil {
|
||||
m.log.Debug("link: bad dst_addr", "tag", tag, "dst", rule.DstAddr, "err", err)
|
||||
continue
|
||||
}
|
||||
addr, err := func() (*netip.Addr, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, linkResolveTimeout)
|
||||
defer cancel()
|
||||
return resolveAddr(cctx, m.srv, host)
|
||||
}()
|
||||
if err != nil || addr == nil {
|
||||
m.log.Debug("link: failed to resolve dst_addr", "tag", tag, "dst", rule.DstAddr, "err", err)
|
||||
continue
|
||||
}
|
||||
if found[*addr] == nil {
|
||||
found[*addr] = make(map[string]struct{})
|
||||
}
|
||||
found[*addr][tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
links := make(map[netip.Addr][]string, len(found))
|
||||
for addr, tags := range found {
|
||||
list := make([]string, 0, len(tags))
|
||||
for tag := range tags {
|
||||
list = append(list, tag)
|
||||
}
|
||||
sort.Strings(list)
|
||||
links[addr] = list
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.links = links
|
||||
m.rebuildLocked()
|
||||
m.mu.Unlock()
|
||||
m.log.Debug("link targets resolved", "count", len(links))
|
||||
m.notify()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// snapshot assembly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// rebuildLocked recomputes the cached snapshot from the last good status, the
|
||||
// latency history and the resolved links. m.mu must be held for writing.
|
||||
func (m *PeerMonitor) rebuildLocked() {
|
||||
snap := PeerSnapshot{At: time.Now(), Err: m.rawErr}
|
||||
st := m.raw
|
||||
if st == nil {
|
||||
snap.Valid = false
|
||||
m.built = snap
|
||||
return
|
||||
}
|
||||
|
||||
// Stale data is still useful data: Valid stays true once a status landed,
|
||||
// and Err tells the GUI the view may be out of date.
|
||||
snap.Valid = true
|
||||
snap.BackendState = st.BackendState
|
||||
snap.MagicDNSSuffix = st.MagicDNSSuffix
|
||||
if st.CurrentTailnet != nil {
|
||||
snap.TailnetName = st.CurrentTailnet.Name
|
||||
if st.CurrentTailnet.MagicDNSSuffix != "" {
|
||||
snap.MagicDNSSuffix = st.CurrentTailnet.MagicDNSSuffix
|
||||
}
|
||||
}
|
||||
|
||||
live := make(map[string]struct{}, len(st.Peer)+1)
|
||||
if st.Self != nil {
|
||||
snap.Self = m.peerInfoLocked(st.Self)
|
||||
live[snap.Self.ID] = struct{}{}
|
||||
}
|
||||
snap.Peers = make([]PeerInfo, 0, len(st.Peer))
|
||||
for _, ps := range st.Peer {
|
||||
if ps == nil {
|
||||
continue
|
||||
}
|
||||
info := m.peerInfoLocked(ps)
|
||||
live[info.ID] = struct{}{}
|
||||
snap.Peers = append(snap.Peers, info)
|
||||
}
|
||||
sortPeers(snap.Peers)
|
||||
|
||||
// Forget history for nodes that left the netmap, so a long-running GUI
|
||||
// session does not grow without bound.
|
||||
for id := range m.hist {
|
||||
if _, ok := live[id]; !ok {
|
||||
delete(m.hist, id)
|
||||
delete(m.last, id)
|
||||
}
|
||||
}
|
||||
|
||||
m.built = snap
|
||||
}
|
||||
|
||||
// peerInfoLocked converts one PeerStatus into the GUI's view of it. m.mu must
|
||||
// be held.
|
||||
func (m *PeerMonitor) peerInfoLocked(ps *ipnstate.PeerStatus) PeerInfo {
|
||||
id := peerKey(ps)
|
||||
info := PeerInfo{
|
||||
ID: id,
|
||||
HostName: ps.HostName,
|
||||
DNSName: strings.TrimSuffix(ps.DNSName, "."),
|
||||
DisplayName: displayName(ps),
|
||||
OS: ps.OS,
|
||||
TailscaleIPs: append([]netip.Addr(nil), ps.TailscaleIPs...),
|
||||
Online: ps.Online,
|
||||
Active: ps.Active,
|
||||
ExitNode: ps.ExitNode,
|
||||
CurAddr: ps.CurAddr,
|
||||
Relay: ps.Relay,
|
||||
RxBytes: ps.RxBytes,
|
||||
TxBytes: ps.TxBytes,
|
||||
Created: ps.Created,
|
||||
LastSeen: ps.LastSeen,
|
||||
LastWrite: ps.LastWrite,
|
||||
LastHandshake: ps.LastHandshake,
|
||||
}
|
||||
|
||||
for _, ip := range ps.TailscaleIPs {
|
||||
tags, ok := m.links[ip]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
info.Linked = true
|
||||
info.LinkTags = mergeTags(info.LinkTags, tags)
|
||||
}
|
||||
|
||||
last, hasPing := m.last[id]
|
||||
info.Route = deriveRoute(ps, last, hasPing)
|
||||
if hasPing {
|
||||
info.LatencyOK = last.ok
|
||||
if last.ok {
|
||||
info.LastLatency = last.latency
|
||||
}
|
||||
}
|
||||
|
||||
samples := m.hist[id]
|
||||
info.Samples = append([]PeerSample(nil), samples...)
|
||||
summariseSamples(&info)
|
||||
return info
|
||||
}
|
||||
|
||||
// deriveRoute decides how traffic reaches the peer. Status fields give the
|
||||
// baseline; a successful ping is authoritative because it reports the path the
|
||||
// packet actually took.
|
||||
func deriveRoute(ps *ipnstate.PeerStatus, last pingOutcome, hasPing bool) PeerRoute {
|
||||
if hasPing && last.ok {
|
||||
if last.derpRegion != "" {
|
||||
return RouteDERP
|
||||
}
|
||||
if ps.PeerRelay != "" {
|
||||
return RoutePeerRelay
|
||||
}
|
||||
return RouteDirect
|
||||
}
|
||||
switch {
|
||||
case ps.PeerRelay != "":
|
||||
return RoutePeerRelay
|
||||
case ps.CurAddr != "":
|
||||
return RouteDirect
|
||||
case ps.Relay != "":
|
||||
return RouteDERP
|
||||
case !ps.Online:
|
||||
return RouteOffline
|
||||
default:
|
||||
return RouteUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// summariseSamples fills the aggregate latency fields. Averages, minimum,
|
||||
// maximum and jitter consider successful samples only; loss covers the whole
|
||||
// window.
|
||||
func summariseSamples(info *PeerInfo) {
|
||||
if len(info.Samples) == 0 {
|
||||
return
|
||||
}
|
||||
var (
|
||||
sum time.Duration
|
||||
ok int
|
||||
fails int
|
||||
lo, hi time.Duration
|
||||
prev time.Duration
|
||||
havePrev bool
|
||||
diffSum float64
|
||||
diffs int
|
||||
)
|
||||
for _, s := range info.Samples {
|
||||
if !s.OK {
|
||||
fails++
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
sum += s.Latency
|
||||
if ok == 1 || s.Latency < lo {
|
||||
lo = s.Latency
|
||||
}
|
||||
if ok == 1 || s.Latency > hi {
|
||||
hi = s.Latency
|
||||
}
|
||||
if havePrev {
|
||||
d := float64(s.Latency-prev) / float64(time.Millisecond)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
diffSum += d
|
||||
diffs++
|
||||
}
|
||||
prev = s.Latency
|
||||
havePrev = true
|
||||
}
|
||||
|
||||
info.LossPct = float64(fails) / float64(len(info.Samples)) * 100
|
||||
if ok == 0 {
|
||||
return
|
||||
}
|
||||
info.AvgLatency = sum / time.Duration(ok)
|
||||
info.MinLatency = lo
|
||||
info.MaxLatency = hi
|
||||
if diffs > 0 {
|
||||
info.JitterMs = diffSum / float64(diffs)
|
||||
}
|
||||
}
|
||||
|
||||
// sortPeers orders the list the way the GUI renders it: linked nodes first,
|
||||
// then online before offline, then by display name. The final tiebreak on ID
|
||||
// keeps the order stable across refreshes.
|
||||
func sortPeers(peers []PeerInfo) {
|
||||
sort.Slice(peers, func(i, j int) bool {
|
||||
a, b := peers[i], peers[j]
|
||||
if a.Linked != b.Linked {
|
||||
return a.Linked
|
||||
}
|
||||
if a.Online != b.Online {
|
||||
return a.Online
|
||||
}
|
||||
if an, bn := strings.ToLower(a.DisplayName), strings.ToLower(b.DisplayName); an != bn {
|
||||
return an < bn
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
|
||||
// peerKey is the stable identity used to key history. It falls back to the DNS
|
||||
// name and then the first address for nodes without a stable ID.
|
||||
func peerKey(ps *ipnstate.PeerStatus) string {
|
||||
if id := string(ps.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
if dns := strings.TrimSuffix(ps.DNSName, "."); dns != "" {
|
||||
return dns
|
||||
}
|
||||
if len(ps.TailscaleIPs) > 0 {
|
||||
return ps.TailscaleIPs[0].String()
|
||||
}
|
||||
return ps.HostName
|
||||
}
|
||||
|
||||
// displayName prefers the first label of the MagicDNS name, which is what the
|
||||
// user typed in the config, then the reported hostname, then an address.
|
||||
func displayName(ps *ipnstate.PeerStatus) string {
|
||||
if dns := strings.TrimSuffix(ps.DNSName, "."); dns != "" {
|
||||
if label, _, ok := strings.Cut(dns, "."); ok && label != "" {
|
||||
return label
|
||||
}
|
||||
return dns
|
||||
}
|
||||
if ps.HostName != "" {
|
||||
return ps.HostName
|
||||
}
|
||||
if len(ps.TailscaleIPs) > 0 {
|
||||
return ps.TailscaleIPs[0].String()
|
||||
}
|
||||
return string(ps.ID)
|
||||
}
|
||||
|
||||
// pingAddr picks the address to probe, preferring IPv4 because that is what
|
||||
// MagicDNS hands out for tailnet peers.
|
||||
func pingAddr(ips []netip.Addr) netip.Addr {
|
||||
var v6 netip.Addr
|
||||
for _, ip := range ips {
|
||||
if ip.Is4() {
|
||||
return ip
|
||||
}
|
||||
if !v6.IsValid() {
|
||||
v6 = ip
|
||||
}
|
||||
}
|
||||
return v6
|
||||
}
|
||||
|
||||
// mergeTags appends the tags missing from dst, keeping the result sorted and
|
||||
// free of duplicates.
|
||||
func mergeTags(dst, extra []string) []string {
|
||||
for _, t := range extra {
|
||||
i := sort.SearchStrings(dst, t)
|
||||
if i < len(dst) && dst[i] == t {
|
||||
continue
|
||||
}
|
||||
dst = append(dst, "")
|
||||
copy(dst[i+1:], dst[i:])
|
||||
dst[i] = t
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
// Phase is the coarse lifecycle state of the service, shown as the header
|
||||
// status pill in the GUI.
|
||||
type Phase int
|
||||
|
||||
const (
|
||||
PhaseIdle Phase = iota
|
||||
PhaseStarting
|
||||
PhaseReady
|
||||
PhaseRetrying
|
||||
PhaseError
|
||||
PhaseStopped
|
||||
)
|
||||
|
||||
func (p Phase) String() string {
|
||||
switch p {
|
||||
case PhaseStarting:
|
||||
return "starting"
|
||||
case PhaseReady:
|
||||
return "ready"
|
||||
case PhaseRetrying:
|
||||
return "retrying"
|
||||
case PhaseError:
|
||||
return "error"
|
||||
case PhaseStopped:
|
||||
return "stopped"
|
||||
default:
|
||||
return "idle"
|
||||
}
|
||||
}
|
||||
|
||||
// StepState is the state of one boot step.
|
||||
type StepState int
|
||||
|
||||
const (
|
||||
StepPending StepState = iota
|
||||
StepRunning
|
||||
StepDone
|
||||
StepFailed
|
||||
StepSkipped
|
||||
)
|
||||
|
||||
// Boot step keys. The GUI maps these onto localised titles.
|
||||
const (
|
||||
StepKeyConfig = "config"
|
||||
StepKeyTsnet = "tsnet"
|
||||
StepKeyRules = "rules"
|
||||
StepKeyServices = "services"
|
||||
StepKeyMonitors = "monitors"
|
||||
StepKeyReady = "ready"
|
||||
)
|
||||
|
||||
// BootStep is one entry in the startup checklist.
|
||||
type BootStep struct {
|
||||
Key string
|
||||
State StepState
|
||||
Err string
|
||||
Started time.Time
|
||||
Finished time.Time
|
||||
}
|
||||
|
||||
// Elapsed is how long the step took, or how long it has been running.
|
||||
func (s BootStep) Elapsed() time.Duration {
|
||||
if s.Started.IsZero() {
|
||||
return 0
|
||||
}
|
||||
if s.Finished.IsZero() {
|
||||
return time.Since(s.Started)
|
||||
}
|
||||
return s.Finished.Sub(s.Started)
|
||||
}
|
||||
|
||||
// State is an immutable snapshot of the supervisor, safe to read from the UI
|
||||
// goroutine.
|
||||
type State struct {
|
||||
Phase Phase
|
||||
Steps []BootStep
|
||||
Err string
|
||||
StartedAt time.Time
|
||||
ReadyAt time.Time
|
||||
Restarts int
|
||||
// NextRetryAt is set while Phase is PhaseRetrying.
|
||||
NextRetryAt time.Time
|
||||
|
||||
Config *Config
|
||||
Server *tsnet.Server
|
||||
Peers *PeerMonitor
|
||||
Lan *LanScanner
|
||||
}
|
||||
|
||||
// Ready reports whether the service finished booting.
|
||||
func (s State) Ready() bool { return s.Phase == PhaseReady }
|
||||
|
||||
// Progress is the fraction of boot steps completed, for the splash bar.
|
||||
func (s State) Progress() float32 {
|
||||
if len(s.Steps) == 0 {
|
||||
return 0
|
||||
}
|
||||
done := 0
|
||||
for _, st := range s.Steps {
|
||||
if st.State == StepDone || st.State == StepSkipped {
|
||||
done++
|
||||
}
|
||||
}
|
||||
return float32(done) / float32(len(s.Steps))
|
||||
}
|
||||
|
||||
// SupervisorOptions configures a Supervisor.
|
||||
type SupervisorOptions struct {
|
||||
ConfigPath string
|
||||
ConfigURL string
|
||||
TsnetDebug bool
|
||||
Logger *slog.Logger
|
||||
// MaxBackoff caps the retry delay. Zero means 30s.
|
||||
MaxBackoff time.Duration
|
||||
}
|
||||
|
||||
// Supervisor owns the service lifecycle for the GUI. It is the same startup
|
||||
// sequence the headless binary runs in serviceLogic, split into observable
|
||||
// steps and wrapped in a restart loop that keeps the window alive when
|
||||
// tailscale is unreachable — a CLI can exit on failure, a GUI must explain
|
||||
// itself instead.
|
||||
type Supervisor struct {
|
||||
opt SupervisorOptions
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
state State
|
||||
|
||||
subsMu sync.Mutex
|
||||
subs map[int]chan struct{}
|
||||
nextSub int
|
||||
|
||||
restartCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewSupervisor creates an unstarted supervisor.
|
||||
func NewSupervisor(opt SupervisorOptions) *Supervisor {
|
||||
logger := opt.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if opt.MaxBackoff <= 0 {
|
||||
opt.MaxBackoff = 30 * time.Second
|
||||
}
|
||||
return &Supervisor{
|
||||
opt: opt,
|
||||
logger: logger.With("from", "supervisor"),
|
||||
subs: make(map[int]chan struct{}),
|
||||
restartCh: make(chan struct{}, 1),
|
||||
state: State{
|
||||
Phase: PhaseIdle,
|
||||
Steps: freshSteps(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func freshSteps() []BootStep {
|
||||
keys := []string{
|
||||
StepKeyConfig, StepKeyTsnet, StepKeyRules,
|
||||
StepKeyServices, StepKeyMonitors, StepKeyReady,
|
||||
}
|
||||
steps := make([]BootStep, len(keys))
|
||||
for i, k := range keys {
|
||||
steps[i] = BootStep{Key: k}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// Snapshot returns the current state.
|
||||
func (s *Supervisor) Snapshot() State {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
st := s.state
|
||||
st.Steps = append([]BootStep(nil), s.state.Steps...)
|
||||
return st
|
||||
}
|
||||
|
||||
// Subscribe returns a coalescing wakeup channel and a cancel func.
|
||||
func (s *Supervisor) Subscribe() (<-chan struct{}, func()) {
|
||||
ch := make(chan struct{}, 1)
|
||||
s.subsMu.Lock()
|
||||
id := s.nextSub
|
||||
s.nextSub++
|
||||
s.subs[id] = ch
|
||||
s.subsMu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return ch, func() {
|
||||
once.Do(func() {
|
||||
s.subsMu.Lock()
|
||||
delete(s.subs, id)
|
||||
s.subsMu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) notify() {
|
||||
s.subsMu.Lock()
|
||||
for _, ch := range s.subs {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
s.subsMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Supervisor) update(f func(*State)) {
|
||||
s.mu.Lock()
|
||||
f(&s.state)
|
||||
s.mu.Unlock()
|
||||
s.notify()
|
||||
}
|
||||
|
||||
func (s *Supervisor) stepStart(key string) {
|
||||
s.update(func(st *State) {
|
||||
for i := range st.Steps {
|
||||
if st.Steps[i].Key == key {
|
||||
st.Steps[i].State = StepRunning
|
||||
st.Steps[i].Started = time.Now()
|
||||
st.Steps[i].Err = ""
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Supervisor) stepDone(key string, err error) {
|
||||
s.update(func(st *State) {
|
||||
for i := range st.Steps {
|
||||
if st.Steps[i].Key != key {
|
||||
continue
|
||||
}
|
||||
st.Steps[i].Finished = time.Now()
|
||||
if err != nil {
|
||||
st.Steps[i].State = StepFailed
|
||||
st.Steps[i].Err = err.Error()
|
||||
} else {
|
||||
st.Steps[i].State = StepDone
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Restart asks the supervisor to tear down and boot again. It never blocks.
|
||||
func (s *Supervisor) Restart() {
|
||||
select {
|
||||
case s.restartCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run drives the boot-and-supervise loop until ctx is cancelled. It blocks, so
|
||||
// callers run it on their own goroutine.
|
||||
func (s *Supervisor) Run(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
s.update(func(st *State) { st.Phase = PhaseStopped })
|
||||
return
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
err := s.boot(runCtx)
|
||||
if err == nil {
|
||||
backoff = time.Second
|
||||
// Supervise until something asks us to restart.
|
||||
reason := s.supervise(runCtx)
|
||||
cancel()
|
||||
s.teardown()
|
||||
if ctx.Err() != nil {
|
||||
s.update(func(st *State) { st.Phase = PhaseStopped })
|
||||
return
|
||||
}
|
||||
s.logger.Warn("restarting service", "reason", reason)
|
||||
s.update(func(st *State) {
|
||||
st.Phase = PhaseRetrying
|
||||
st.Restarts++
|
||||
st.Steps = freshSteps()
|
||||
st.NextRetryAt = time.Now().Add(time.Second)
|
||||
})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
cancel()
|
||||
s.teardown()
|
||||
if ctx.Err() != nil {
|
||||
s.update(func(st *State) { st.Phase = PhaseStopped })
|
||||
return
|
||||
}
|
||||
|
||||
// Configuration errors will not fix themselves; surface them and wait
|
||||
// for an explicit Restart rather than looping on a broken file.
|
||||
if errors.Is(err, errFatalConfig) {
|
||||
// Log it as well as showing it: the on-screen log sheet is the
|
||||
// thing users screenshot, and a bare error panel with an empty log
|
||||
// tells whoever is helping them nothing.
|
||||
s.logger.Error("configuration error, waiting for retry", "err", err)
|
||||
s.update(func(st *State) {
|
||||
st.Phase = PhaseError
|
||||
st.Err = err.Error()
|
||||
})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.update(func(st *State) { st.Phase = PhaseStopped })
|
||||
return
|
||||
case <-s.restartCh:
|
||||
s.update(func(st *State) {
|
||||
st.Phase = PhaseStarting
|
||||
st.Err = ""
|
||||
st.Steps = freshSteps()
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Warn("startup failed, retrying", "err", err, "backoff", backoff)
|
||||
s.update(func(st *State) {
|
||||
st.Phase = PhaseRetrying
|
||||
st.Err = err.Error()
|
||||
st.Restarts++
|
||||
st.NextRetryAt = time.Now().Add(backoff)
|
||||
})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.update(func(st *State) { st.Phase = PhaseStopped })
|
||||
return
|
||||
case <-s.restartCh:
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > s.opt.MaxBackoff {
|
||||
backoff = s.opt.MaxBackoff
|
||||
}
|
||||
s.update(func(st *State) { st.Steps = freshSteps() })
|
||||
}
|
||||
}
|
||||
|
||||
// errFatalConfig marks an error that retrying cannot fix.
|
||||
var errFatalConfig = errors.New("configuration error")
|
||||
|
||||
// boot runs the startup sequence, reporting each step.
|
||||
func (s *Supervisor) boot(ctx context.Context) error {
|
||||
s.update(func(st *State) {
|
||||
st.Phase = PhaseStarting
|
||||
st.Err = ""
|
||||
st.StartedAt = time.Now()
|
||||
st.ReadyAt = time.Time{}
|
||||
st.NextRetryAt = time.Time{}
|
||||
})
|
||||
|
||||
// --- config -----------------------------------------------------------
|
||||
s.stepStart(StepKeyConfig)
|
||||
source := s.opt.ConfigPath
|
||||
if s.opt.ConfigURL != "" {
|
||||
source = s.opt.ConfigURL
|
||||
s.logger.Info("using config url", "url", s.opt.ConfigURL)
|
||||
}
|
||||
cfg, err := LoadConfig(source)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load config", "source", source, "err", err)
|
||||
s.stepDone(StepKeyConfig, err)
|
||||
return errors.Join(errFatalConfig, err)
|
||||
}
|
||||
SetDoHServers(cfg.DNS.DoHServers)
|
||||
if len(cfg.DNS.DoHServers) > 0 {
|
||||
s.logger.Info("dns-over-https fallback enabled", "servers", cfg.DNS.DoHServers)
|
||||
}
|
||||
s.update(func(st *State) { st.Config = cfg })
|
||||
s.stepDone(StepKeyConfig, nil)
|
||||
|
||||
// --- tsnet ------------------------------------------------------------
|
||||
s.stepStart(StepKeyTsnet)
|
||||
srv, err := InitTsNet(ctx, &cfg.Core, s.logger, s.opt.TsnetDebug)
|
||||
if err != nil {
|
||||
s.stepDone(StepKeyTsnet, err)
|
||||
return err
|
||||
}
|
||||
s.update(func(st *State) { st.Server = srv })
|
||||
s.stepDone(StepKeyTsnet, nil)
|
||||
|
||||
// --- rules ------------------------------------------------------------
|
||||
s.stepStart(StepKeyRules)
|
||||
NormalizeConnectRulesDstAddr(ctx, srv, cfg.Connect, s.logger)
|
||||
s.stepDone(StepKeyRules, nil)
|
||||
|
||||
// --- services ---------------------------------------------------------
|
||||
s.stepStart(StepKeyServices)
|
||||
StartForwarders(ctx, srv, cfg.Forward)
|
||||
StartConnectors(ctx, srv, cfg.Connect)
|
||||
RunLanDiscoverService(ctx, cfg.Connect, s.logger.With("from", "lan_service"))
|
||||
s.stepDone(StepKeyServices, nil)
|
||||
|
||||
// --- monitors ---------------------------------------------------------
|
||||
s.stepStart(StepKeyMonitors)
|
||||
peers := NewPeerMonitor(srv, cfg.Connect, s.logger, PeerMonitorOptions{})
|
||||
peers.Start(ctx)
|
||||
|
||||
lan := NewLanScanner(s.logger.With("from", "lan_scan"))
|
||||
lan.SetSelfEntries(LanEntriesFromRules(cfg.Connect))
|
||||
lan.Start(ctx)
|
||||
|
||||
s.update(func(st *State) {
|
||||
st.Peers = peers
|
||||
st.Lan = lan
|
||||
})
|
||||
s.stepDone(StepKeyMonitors, nil)
|
||||
|
||||
// --- ready ------------------------------------------------------------
|
||||
s.stepStart(StepKeyReady)
|
||||
s.stepDone(StepKeyReady, nil)
|
||||
s.update(func(st *State) {
|
||||
st.Phase = PhaseReady
|
||||
st.ReadyAt = time.Now()
|
||||
st.Err = ""
|
||||
})
|
||||
s.logger.Info("service ready", "took", time.Since(s.Snapshot().StartedAt).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
// supervise blocks until the service should be restarted, returning why.
|
||||
func (s *Supervisor) supervise(ctx context.Context) string {
|
||||
watchdog := StartTimeWatchDog(ctx, s.logger.With("from", "watchdog"))
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "context cancelled"
|
||||
case <-watchdog:
|
||||
return "system time jump"
|
||||
case <-s.restartCh:
|
||||
return "requested by user"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// teardown closes the tsnet server and clears the per-run state.
|
||||
func (s *Supervisor) teardown() {
|
||||
s.mu.Lock()
|
||||
srv := s.state.Server
|
||||
s.state.Server = nil
|
||||
s.state.Peers = nil
|
||||
s.state.Lan = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if srv != nil {
|
||||
if err := srv.Close(); err != nil {
|
||||
s.logger.Debug("closing tsnet server", "err", err)
|
||||
}
|
||||
}
|
||||
s.notify()
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/netcheck"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
|
||||
"tslink/netdiag"
|
||||
)
|
||||
|
||||
// TsDiagSource adapts a running tsnet server to [netdiag.TailscaleSource].
|
||||
//
|
||||
// The diagnostics package probes the network from scratch; this asks tailscale
|
||||
// what it already believes. The two disagreeing is itself informative — for
|
||||
// DERP latency and tailscale's own UPnP/PMP/PCP probe, tailscale's answer is
|
||||
// the one that governs how the tunnel will actually behave.
|
||||
type TsDiagSource struct {
|
||||
srv *tsnet.Server
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewTailscaleSource wraps srv. A nil logger falls back to slog.Default.
|
||||
func NewTailscaleSource(srv *tsnet.Server, logger *slog.Logger) *TsDiagSource {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &TsDiagSource{srv: srv, logger: logger}
|
||||
}
|
||||
|
||||
// DefaultTailscaleSource returns a source for srv, or a nil interface when srv
|
||||
// is nil, so callers can pass the result straight into netdiag.Options without
|
||||
// tripping over a typed-nil interface.
|
||||
func DefaultTailscaleSource(srv *tsnet.Server, logger *slog.Logger) netdiag.TailscaleSource {
|
||||
if srv == nil {
|
||||
return nil
|
||||
}
|
||||
return NewTailscaleSource(srv, logger)
|
||||
}
|
||||
|
||||
// netcheckTimeout bounds one report. netcheck's own full run probes every DERP
|
||||
// region, which takes a while on a slow link.
|
||||
const netcheckTimeout = 15 * time.Second
|
||||
|
||||
// Netcheck runs tailscale's own network check and translates the result.
|
||||
func (s *TsDiagSource) Netcheck(ctx context.Context) (rep *netdiag.TailscaleReport, err error) {
|
||||
if s == nil || s.srv == nil {
|
||||
return &netdiag.TailscaleReport{
|
||||
Status: netdiag.StatusSkipped,
|
||||
Summary: "Tailscale 未运行",
|
||||
}, errors.New("tsnet server is nil")
|
||||
}
|
||||
|
||||
lc, err := s.srv.LocalClient()
|
||||
if err != nil {
|
||||
return &netdiag.TailscaleReport{
|
||||
Status: netdiag.StatusSkipped,
|
||||
Err: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
dm, err := lc.CurrentDERPMap(ctx)
|
||||
if err != nil || dm == nil {
|
||||
if err == nil {
|
||||
err = errors.New("no DERP map available")
|
||||
}
|
||||
return &netdiag.TailscaleReport{
|
||||
Status: netdiag.StatusSkipped,
|
||||
Err: err.Error(),
|
||||
Summary: "无法获取 DERP 列表,跳过 Tailscale 内部检查",
|
||||
}, err
|
||||
}
|
||||
|
||||
// A static monitor takes a one-shot snapshot of the interfaces without
|
||||
// spawning the change-watching goroutines a long-lived Monitor would. That
|
||||
// is what we want for a single report, and Close on a static monitor is a
|
||||
// no-op.
|
||||
mon := netmon.NewStatic()
|
||||
|
||||
client := &netcheck.Client{
|
||||
NetMon: mon,
|
||||
Logf: func(format string, args ...any) {
|
||||
s.logger.With(slog.String("from", "netcheck")).
|
||||
Debug(fmt.Sprintf(format, args...))
|
||||
},
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, netcheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
// GetReport reaches into internal magicsock machinery; a panic there must
|
||||
// degrade this one panel, not take the window down.
|
||||
var raw *netcheck.Report
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("netcheck panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
raw, err = client.GetReport(runCtx, dm, &netcheck.GetReportOpts{})
|
||||
}()
|
||||
if err != nil || raw == nil {
|
||||
if err == nil {
|
||||
err = errors.New("netcheck returned no report")
|
||||
}
|
||||
return &netdiag.TailscaleReport{
|
||||
Status: netdiag.StatusSkipped,
|
||||
Err: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return convertNetcheck(raw, dm), nil
|
||||
}
|
||||
|
||||
// convertNetcheck maps tailscale's report onto the diagnostics contract.
|
||||
func convertNetcheck(raw *netcheck.Report, dm *tailcfg.DERPMap) *netdiag.TailscaleReport {
|
||||
out := &netdiag.TailscaleReport{
|
||||
Available: true,
|
||||
UDP: raw.UDP,
|
||||
IPv4: raw.IPv4,
|
||||
IPv6: raw.IPv6,
|
||||
ICMPv4: raw.ICMPv4,
|
||||
OSHasIPv6: raw.OSHasIPv6,
|
||||
|
||||
MappingVariesByDestIP: optBool(raw.MappingVariesByDestIP.Get()),
|
||||
UPnP: optBool(raw.UPnP.Get()),
|
||||
PMP: optBool(raw.PMP.Get()),
|
||||
PCP: optBool(raw.PCP.Get()),
|
||||
CaptivePortal: optBool(raw.CaptivePortal.Get()),
|
||||
}
|
||||
if raw.GlobalV4.IsValid() {
|
||||
out.GlobalV4 = raw.GlobalV4.String()
|
||||
}
|
||||
if raw.GlobalV6.IsValid() {
|
||||
out.GlobalV6 = raw.GlobalV6.String()
|
||||
}
|
||||
|
||||
for id, latency := range raw.RegionLatency {
|
||||
entry := netdiag.DERPLatency{
|
||||
RegionID: id,
|
||||
Latency: latency,
|
||||
Preferred: id == raw.PreferredDERP,
|
||||
}
|
||||
if dm != nil {
|
||||
if region, ok := dm.Regions[id]; ok && region != nil {
|
||||
entry.RegionCode = region.RegionCode
|
||||
entry.Name = region.RegionName
|
||||
}
|
||||
}
|
||||
if entry.RegionCode == "" {
|
||||
entry.RegionCode = strconv.Itoa(id)
|
||||
}
|
||||
if entry.Name == "" {
|
||||
entry.Name = entry.RegionCode
|
||||
}
|
||||
if entry.Preferred {
|
||||
out.PreferredDERP = entry.RegionCode
|
||||
}
|
||||
out.DERP = append(out.DERP, entry)
|
||||
}
|
||||
sort.Slice(out.DERP, func(i, j int) bool {
|
||||
if out.DERP[i].Latency != out.DERP[j].Latency {
|
||||
return out.DERP[i].Latency < out.DERP[j].Latency
|
||||
}
|
||||
return out.DERP[i].RegionID < out.DERP[j].RegionID
|
||||
})
|
||||
if out.PreferredDERP == "" && raw.PreferredDERP != 0 {
|
||||
out.PreferredDERP = strconv.Itoa(raw.PreferredDERP)
|
||||
}
|
||||
|
||||
out.Status, out.Summary = netcheckVerdict(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// netcheckVerdict grades the report from the perspective of whether tailscale
|
||||
// can carry traffic well, not whether every box is ticked.
|
||||
func netcheckVerdict(r *netdiag.TailscaleReport) (netdiag.Status, string) {
|
||||
switch {
|
||||
case !r.UDP:
|
||||
return netdiag.StatusFail,
|
||||
"Tailscale 无法通过 UDP 与 DERP 通信,连接将非常不稳定"
|
||||
case r.CaptivePortal != nil && *r.CaptivePortal:
|
||||
return netdiag.StatusWarn,
|
||||
"检测到门户劫持(Captive Portal),需要先在浏览器完成网络认证"
|
||||
case len(r.DERP) == 0:
|
||||
return netdiag.StatusWarn,
|
||||
"没有任何 DERP 节点响应,中继回退可能不可用"
|
||||
}
|
||||
|
||||
best := r.DERP[0]
|
||||
summary := fmt.Sprintf("首选 DERP %s,延迟 %dms",
|
||||
nonEmpty(r.PreferredDERP, best.RegionCode),
|
||||
best.Latency.Milliseconds())
|
||||
if r.MappingVariesByDestIP != nil && *r.MappingVariesByDestIP {
|
||||
return netdiag.StatusWarn,
|
||||
summary + ";NAT 映射随目标变化(对称型),直连打洞成功率低"
|
||||
}
|
||||
return netdiag.StatusOK, summary
|
||||
}
|
||||
|
||||
func nonEmpty(v, fallback string) string {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// optBool converts tailscale's opt.Bool (value, ok) pair into a tri-state
|
||||
// pointer: nil means tailscale could not determine the answer, which is
|
||||
// different from determining "no".
|
||||
func optBool(v, ok bool) *bool {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := v
|
||||
return &out
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 "否"
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user