Compare commits

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

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

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

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

  Also fixed:

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

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

    * peer.AllowedIPs is a nillable pointer, dereferenced unguarded.
2026-07-26 14:52:14 +08:00
iceBear67 5dc1759d80 add doh support 2026-07-26 14:14:03 +08:00
iceBear67 20097a02a1 fix: domain names aren't resolved by tailscale's dns resolver. 2026-07-21 01:18:01 +08:00
nc e1cfd61d46 add USAGE.md 2026-06-10 15:38:43 +08:00
nc bfd3bcdff5 chore(readme): quick start 2026-06-10 15:32:12 +08:00
nc 91722048cf fix(ci): release artifact & filename 2026-06-10 05:04:10 +08:00
nc 3c8d685385 fix(ci): release artifact & filename 2026-06-10 04:55:21 +08:00
nc 6b06cb03f8 feat(ci): auto upload to ghcr and release artifact 2026-06-10 04:43:13 +08:00
nc c725d40d80 fix(tsnet): revert unexpected changes 2026-06-10 02:10:03 +08:00
nc 30330a1525 Merge branch 'fix-multi-layer-magicdns-resolve'
# Conflicts:
#	core/utils.go
#	main.go
2026-06-10 01:32:26 +08:00
nc aaf56fef69 Merge remote-tracking branch 'origin/fix-multi-layer-magicdns-resolve' into fix-multi-layer-magicdns-resolve 2026-06-10 01:30:48 +08:00
nc 74618fff4c chore(forwarder): adjust log level 2026-06-10 01:30:04 +08:00
nc cb6ebb03e6 chore(forwarder): adjust log level 2026-06-10 01:25:59 +08:00
nc 35f92fa781 feat(forwarder): split forwarder to different file 2026-06-10 01:23:38 +08:00
nc 7c5904cc47 feat(config): add config validator to avoid wrong settings 2026-06-10 01:22:18 +08:00
nc 125bc4bf3d feat(utils): resolveAddr can resolve subnet ip at domain properly 2026-06-05 03:56:52 +08:00
nc 390946e0a2 fix(utils): normalizer cannot handle domain with dot
such as cm.any->cm.any.ts.net
2026-06-05 02:35:09 +08:00
65 changed files with 19286 additions and 748 deletions
+197 -4
View File
@@ -5,8 +5,38 @@ on:
branches:
- master
pull_request:
release:
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:
@@ -24,6 +54,14 @@ jobs:
go-version: '1.26.3'
cache: true
- name: Cache Go build cache
uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-go-build-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-go-build-${{ matrix.goos }}-${{ matrix.goarch }}-
- name: Install dependencies
run: go mod download
@@ -46,12 +84,167 @@ jobs:
release/tslink*
args: "-9"
- name: Get current date
id: date
run: echo "date=$(date +'%y%m%d')" >> $GITHUB_OUTPUT
- name: Determine version
id: version
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: Upload Artifact
uses: actions/upload-artifact@v5
with:
name: tslink-dev${{ steps.date.outputs.date }}-${{ matrix.goos }}_${{ matrix.goarch }}
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
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26.3'
cache: true
- name: Cache Go build cache
uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-go-build-container-${{ hashFiles('go.sum') }}
restore-keys: |
${{ runner.os }}-go-build-container-
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install ko
run: go install github.com/google/ko@latest
- name: Build and push container image
env:
KO_DOCKER_REPO: ghcr.io/${{ github.repository_owner }}/tslink
run: |
ko build --bare ./ --platform=all \
-t latest \
-t ${{ github.ref_name }}
release:
if: github.event_name == 'release'
needs: [build, build-gui]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v5
- name: Rename binaries
run: |
for dir in */; do
dir=${dir%/}
plat=$(echo "$dir" | grep -oE '(linux|windows|darwin)_(amd64|arm64)$')
[ -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/$name" "$dir/$name.exe"; do
if [ -f "$bin" ]; then
case "$bin" in
*.exe) mv "$bin" "$dir/${name}-${version}-${platform}.exe" ;;
*) mv "$bin" "$dir/${name}-${version}-${platform}" ;;
esac
fi
done
done
- name: Generate checksums
run: |
find . -type f -name 'tslink-*' -exec sha256sum {} \; > tslink-checksums.txt
- name: Upload to release
uses: softprops/action-gh-release@v2
with:
files: |
*/tslink-*
tslink-checksums.txt
+2
View File
@@ -3,3 +3,5 @@
.idea/**
.gocache/**
tslink
config.toml
tslink-gui
+11
View File
@@ -0,0 +1,11 @@
builds:
- id: tslink
main: .
env:
- CGO_ENABLED=0
ldflags:
- -s
- -w
platforms:
- linux/amd64
- linux/arm64
+39 -4
View File
@@ -10,19 +10,54 @@
- **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
## 环境要求
- Go 1.26+
- Tailscale / Headscale 授权密钥
## 快速开始
```powershell
go build -o tslink.exe .
.\tslink.exe -c config.toml
1. 前往 [Releases](https://github.com/saltedfishclub/tslink/releases) 下载对应平台的二进制文件,赋予执行权限后放入 PATH
2. 准备配置文件:
```bash
cp config.example.toml config.toml
vim config.toml # 填入 auth_key 并配置转发规则
```
3. 启动:
```bash
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
cp config.example.toml config.toml
# 编辑 config.toml,然后对照修改 docker-compose.yml 中的端口映射
docker compose up -d
```
详细配置与使用说明见 [USAGE.md](USAGE.md)。
+242
View File
@@ -0,0 +1,242 @@
# USAGE
## 配置文件
配置使用 [TOML](https://toml.io) 格式,支持本地文件或远程 URL。
### `[core]` — 核心配置
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `auth_key` | string | **是** | — | Tailscale / Headscale 授权密钥(可在控制面生成) |
| `control_url` | string | 否 | `https://controlplane.tailscale.com` | 控制面地址,使用 Headscale 时改为自建实例地址 |
| `hostname` | string | 否 | 本机主机名 | 在 Tailnet 中的节点名称 |
| `ephemeral` | bool | 否 | `true` | 节点是否临时节点,离开 Tailnet 后自动删除 |
| `accept_routes` | bool | 否 | `true` | 是否接受其他节点发布的子网路由 |
### `[dns]` — DNS 解析配置
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `doh_servers` | []string | 否 | `[]` | DNS-over-HTTPSRFC 8484)回落解析器地址列表,须为 `http(s)://` URL |
`dst_addr` 中的域名默认走 Tailnet 自身的 DNS 解析器(支持 MagicDNS 与 split-DNS)。当该解析器无法解析目标(例如宿主机本身没有可用的系统 DNS,或目标不在 Tailnet 的 split-DNS 路由内)时,会**依次**尝试 `doh_servers` 中配置的 DoH 端点解析公网域名。留空则关闭此回落。仅对原始域名发起 DoH 查询(Tailnet 内部 MagicDNS 名称无法通过 DoH 解析)。
### `[[forward.<name>]]` — 转发规则(Tailscale → 本地)
将 Tailscale 上的流量转发到本地服务。`<name>` 为自定义标签名。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `protocol` | string | **是** | `tcp``udp` |
| `tailscale_port` | int | **是** | 在 Tailscale IP 上监听的端口,1-65535 |
| `local_addr` | string | **是** | 本地转发目标地址,`host:port` 格式 |
**注意事项:** 容器环境下 `local_addr` 中的 `127.0.0.1` 指向容器自身;若要转发到宿主机服务,需使用桥接网络的 `host.docker.internal` 或 host 模式下的真实 IP。
### `[[connect.<name>]]` — 连接规则(本地 → Tailscale
在本机监听端口,将接入的流量转发到 Tailnet 中的目标。`<name>` 为自定义标签名。
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `protocol` | string | **是** | — | `tcp``udp``minecraft` |
| `local_port` | int | **是** | — | 本地监听端口,1-65535 |
| `dst_addr` | string | **是** | — | Tailscale 目标地址,`host:port` 格式,支持 MagicDNS 主机名(如 `my-server.ts.net:8080` |
| `local_addr` | string | 否 | `127.0.0.1` | 监听 IP 地址,设为 `0.0.0.0` 可暴露到局域网 |
| `lan_enable` | bool | 否 | `minecraft` 时为 `true`,其余为 `false` | 启用 LAN 多播发现(Minecraft 专用) |
| `lan_motd` | string | 否 | `Minecraft via Tailscale` | LAN 广播的 MOTD 文本 |
**Minecraft 模式说明:** `protocol = "minecraft"` 实质为 TCP 转发,额外在多播地址 `224.0.2.60:4445`IPv4)和 `ff75:230::60:4445`(IPv6)上发送 LAN 广播,使局域网内的 Minecraft 客户端可直接发现服务器。
## 命令行参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `-c` | `config.toml` | 配置文件路径或 HTTP(S) URL |
| `-config-url` | 构建时注入 | 远程配置 URL(`-c` 指定 URL 时优先使用 `-c` |
| `-level` | `info` | 日志级别:`debug``info``warn``error` |
| `-json-format` | `false` | 使用 JSON 格式输出日志 |
| `-diagnose` | `false` | 输出 tsnet 内部调试信息(需 `-level debug` |
## 配置来源
### 本地文件
```bash
tslink -c /path/to/config.toml
```
如果指定路径的文件不存在,tslink 会自动生成一份默认配置模板。
### 远程 URL
配置可通过 HTTP(S) 远程加载,启动后不会将密钥写入磁盘:
```bash
tslink -c https://example.com/tslink.toml
```
### 构建时注入
可在编译时注入默认配置 URL,适用于分发场景:
```bash
go build -ldflags "-X tslink/core.DefaultConfigURL=https://example.com/tslink.toml"
```
当未指定 `-c``-config-url` 时,自动使用该 URL。
## 完整示例
```toml
[core]
auth_key = "tskey-auth-..." # 必填,Tailscale 授权密钥
control_url = "https://controlplane.tailscale.com" # 可选,Headscale 用户改为自建实例
hostname = "" # 可选,留空使用本机主机名
ephemeral = true # 可选,临时节点
accept_routes = true # 可选,接受子网路由
[dns]
# 可选,Tailnet DNS 无法解析时回落到 DoH 解析公网域名;留空关闭
doh_servers = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"]
# 示例1: 将 Tailnet 上 8080 端口的请求转发到本地 9090
[[forward.web]]
protocol = "tcp"
tailscale_port = 8080
local_addr = "127.0.0.1:9090"
# 示例2: 将 Tailnet 上的 UDP 流量转发到本地
[[forward.dns_udp]]
protocol = "udp"
tailscale_port = 5353
local_addr = "127.0.0.1:53"
# 示例3: 本机监听 9000,转发到 Tailnet 中某主机的 8080
[[connect.web]]
protocol = "tcp"
local_port = 9000
local_addr = "127.0.0.1" # 可选,仅本机可访问
dst_addr = "other-host.ts.net:8080"
# 示例4: 本机监听 0.0.0.0:25565,转发到 Tailnet 中的 Minecraft 服务器
[[connect.minecraft]]
protocol = "minecraft"
local_port = 25565
dst_addr = "mc-server.ts.net:25566"
lan_enable = true # 可选,启用 LAN 多播发现
lan_motd = "Minecraft via Tailscale" # 可选,自定义 MOTD
# 示例5: UDP 转发
[[connect.udp_example]]
protocol = "udp"
local_port = 24454
dst_addr = "tailnet-game.ts.net:24454"
```
## 容器部署
项目根目录提供了 `docker-compose.yml`,使用前需:
1. 准备好 `config.toml` 并放在项目根目录
2. 根据配置中的 `connect` 规则,修改 `docker-compose.yml` 中的 `ports:` 映射
3. 启动:
```bash
docker compose up -d
```
也可以直接拉取镜像部署:
```bash
docker pull ghcr.io/saltedfishclub/tslink:latest
docker run -d \
--name tslink \
-v $(pwd)/config.toml:/etc/tslink/config.toml:ro \
-p 9000:9000 \
-p 25565:25565 \
ghcr.io/saltedfishclub/tslink:latest \
-c /etc/tslink/config.toml
```
## 图形界面 `tslink-gui`
`tslink-gui` 是可选的桌面前端,使用 [Gio](https://gioui.org) 直接绘制界面——不含 WebView、不打包浏览器,Linux / macOS / Windows 各是一个原生可执行文件(约 30 MB)。
它在自身进程内运行与无界面版**完全相同**的转发服务,因此配置文件、规则语义和行为都一致:
```bash
tslink-gui -c config.toml
```
### 命令行参数
除下列参数外,`-c``-config-url``-level``-json-format``-diagnose` 与无界面版含义相同。
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `-light` | `false` | 以浅色主题启动(默认深色) |
| `-ipinfo-token` | `$IPINFO_TOKEN` | ipinfo.io 的 API Token,可选,用于提高归属地查询的速率限制 |
| `-pprof` | 空 | 在指定地址暴露 `net/http/pprof`,如 `127.0.0.1:6060`。仅允许回环地址 |
无论 `-level` 设为什么,界面内的日志缓冲区**始终按 debug 级别**记录最近 20000 条,所以出问题后不必重启加 `-level debug` 再复现一次。
### 界面说明
| 页面 | 内容 |
|------|------|
| **概览** | 在线节点数、局域网服务器数、规则数、运行时长,以及最近一次诊断的结论 |
| **节点** | 每个 Tailscale 节点的在线状态、链路类型、实时延迟、抖动与丢包;顶部为多节点**延迟图谱**(最近 20 分钟,鼠标悬停可查看某一时刻的取值,点击图例可隐藏某个节点) |
| **局域网** | 监听 `224.0.2.60:4445` / `[ff75:230::60]:4445` 的 Minecraft LAN 广播。由 tslink 自己广播的条目会标记为「本机广播」——**配置了规则却听不到自己的广播,说明隧道或组播链路有问题** |
| **网络诊断** | 见下 |
| **日志** | 按级别、来源、关键字检索,复制 / 保存 / 上传 |
| **设置** | 主题、语言、版本与配置来源 |
启动过程中,界面显示分步进度的加载动画;实时日志以**半透明浮层**固定在底部,因此启动卡住时直接截图就包含了排查所需的信息。服务就绪后,浮层可通过标题栏按钮随时唤出。
### 网络诊断
点击「开始诊断」后并行执行以下检查,整体不超过 45 秒:
| 检查项 | 说明 |
|--------|------|
| **NAT 类型** | 依 RFC 5780 做映射行为与过滤行为探测,并映射到常见的完全锥形 / 地址限制 / 端口限制 / 对称型命名。对称型 NAT 会导致打洞失败、连接回退到 DERP 中继 |
| **UDP 连通性** | 对国内与境外 STUN 服务器分别探测 IPv4/IPv6,并识别疑似被封锁的目标端口 |
| **本机出口地址** | 列出所有接口上的 IPv4 与 IPv6 地址,标注默认出口以及 CGNAT / Tailscale / 私有 / 公网等类型 |
| **端口映射** | 自行实现的 UPnP IGDSSDP + SOAP)、NAT-PMPRFC 6886)与 PCPRFC 6887)探测,能拿到路由器型号与外部地址 |
| **境外连通性** | 以 `cp.cloudflare.com/generate_204` 为主,辅以 gstatic / Google,并用国内基准(小米 / 百度)区分「完全没网」与「只是出不了境」 |
| **出口 IP 与归属地** | 通过 STUN(裸 UDP,绕过 HTTP 代理)、强制 IPv4、强制 IPv6、以及走系统代理四种方式分别探测,再用 ipinfo.io(失败时回退 ip-api.com / ip.sb)查询归属地 |
| **Tailscale 内部状态** | 直接调用 tailscale 自己的 netcheck,取得 DERP 各区域延迟、首选中继、门户劫持判定,以及它自己看到的 UPnP/PMP/PCP 结果 |
STUN 服务器**同时包含国内与境外**两组(小米、B 站、腾讯、芒果 TV、Cloudflare 任播 / Google、Cloudflare、Nextcloud、BlackBerry、SipNet、StunProtocol)。这不只是为了容错:当本机启用了代理或分流工具时,不同探测路径会得到**不同的公网 IP**,诊断页会把这种分歧单独标出来——这通常正是「为什么对端连不上我」的答案。
> 归属地查询会把你的公网 IP 发送给第三方服务。不希望如此时,勾选「不查询归属地」即可跳过。
>
> 出口 IP 的「不一致」判定按 IPv4 / IPv6 分别计算,双栈主机同时拥有一个 v4 和一个 v6 出口属于正常情况,不会被误报。
### 导出与分享日志
日志页提供三种导出方式,都会附带一段环境信息头(版本、系统、配置来源、运行阶段、节点数),以及最近一次的完整诊断报告:
- **复制到剪贴板**
- **保存到文件**:写入用户主目录,文件名形如 `tslink-log-20260726-084500.txt`
- **上传并分享**:依次尝试 0x0.st、paste.rs、dpaste.org、termbin.com,成功后返回链接并自动复制
导出默认开启「隐去密钥」,会移除 `auth_key` 等凭据以及形如 `tskey-...` 的字符串。**上传是公开的**——任何拿到链接的人都能看到内容,其中包含你的公网 IP 与内网地址,请自行判断。
### 从源码构建
Windows 无需额外依赖。macOS 需要 Xcode Command Line Tools。Linux 需要 X11 / Wayland / EGL 的开发头文件:
```bash
sudo apt install -y pkg-config libwayland-dev libx11-dev libx11-xcb-dev \
libxkbcommon-dev libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev \
libffi-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxxf86vm-dev
go build -o tslink-gui ./cmd/tslink-gui
```
界面语言默认跟随中文字体的可用性:找不到任何中文字体时自动切换为英文,以免显示成方块(也可在设置里手动切换)。
由于 Gio 依赖 CGO,GUI 无法像无界面版那样交叉编译,需要在目标平台上分别构建。
+147
View File
@@ -0,0 +1,147 @@
// Command tslink-gui is the desktop front-end for tslink.
//
// It runs the same service the headless binary does — see the root main.go —
// but supervises it in-process so the window can show tailnet peer health,
// latency history, the services tslink is forwarding, and a full network
// diagnostic run, plus a searchable log view that can be shared to a paste
// service for support.
//
// 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()
}
+8
View File
@@ -5,6 +5,13 @@ hostname = "" # leave blank to use machine name
ephemeral = true
accept_routes = true
[dns]
# Fallback DNS-over-HTTPS resolvers (RFC 8484), tried only when the tailnet
# resolver can't resolve a destination (e.g. the host has no working system DNS,
# or the name is outside the tailnet's split-DNS routes). Leave empty to disable.
doh_servers = []
# doh_servers = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"]
[[forward.web]] # you -> others
protocol = "tcp"
tailscale_port = 8080
@@ -13,6 +20,7 @@ local_addr = "127.0.0.1:9090"
[[connect.web]] # others -> you
protocol = "tcp"
local_port = 9000
local_addr = "127.0.0.1" # default; set to 0.0.0.0 to expose on LAN
dst_addr = "any-client-in.ts.net:8080"
[[connect.minecraft]]
+185
View File
@@ -0,0 +1,185 @@
package core
import (
"context"
"log/slog"
"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 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")
}
}
+162 -12
View File
@@ -2,9 +2,12 @@ package core
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -39,6 +42,13 @@ type Core struct {
AcceptRoutes bool `toml:"accept_routes"`
}
// DNS holds resolver options. DoHServers are DNS-over-HTTPS endpoints (RFC 8484)
// queried as a fallback when the tailnet resolver cannot resolve a dial
// destination. An empty list disables the fallback.
type DNS struct {
DoHServers []string `toml:"doh_servers"`
}
func (r ConnectRule) LANEnabled() bool {
if r.LanEnable != nil {
return *r.LanEnable
@@ -53,12 +63,158 @@ func (r ConnectRule) LANMotdOr(def string) string {
return def
}
func (r ConnectRule) BindIP() string {
if r.LANEnabled() {
return "0.0.0.0"
}
if r.LocalAddr != "" {
return r.LocalAddr
}
return "127.0.0.1"
}
type Config struct {
Core Core `toml:"core"`
DNS DNS `toml:"dns"`
Forward map[string][]ForwardRule `toml:"forward"`
Connect map[string][]ConnectRule `toml:"connect"`
}
func (cfg *Config) ApplyDefaults() {
if cfg.Forward == nil {
cfg.Forward = make(map[string][]ForwardRule)
}
if cfg.Connect == nil {
cfg.Connect = make(map[string][]ConnectRule)
}
if cfg.Core.Hostname == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
cfg.Core.Hostname = hostname
}
}
func (cfg *Config) Validate() error {
var errs []error
if strings.TrimSpace(cfg.Core.AuthKey) == "" {
errs = append(errs, errors.New("core.auth_key is required"))
}
for i, server := range cfg.DNS.DoHServers {
u, err := url.Parse(strings.TrimSpace(server))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
errs = append(errs, fmt.Errorf("dns.doh_servers[%d] must be a valid http(s) URL", i))
}
}
usedForwardListeners := make(map[string]string)
usedConnectListeners := make(map[string]string)
for tag, rules := range cfg.Forward {
for i, rule := range rules {
path := fmt.Sprintf("forward.%s[%d]", tag, i)
if rule.Protocol != "tcp" && rule.Protocol != "udp" {
errs = append(errs, fmt.Errorf("%s.protocol must be tcp or udp", path))
}
if !validPort(rule.TailscalePort) {
errs = append(errs, fmt.Errorf("%s.tailscale_port must be between 1 and 65535", path))
} else {
key := fmt.Sprintf("%s:%d", rule.Protocol, rule.TailscalePort)
if prev, ok := usedForwardListeners[key]; ok {
errs = append(errs, fmt.Errorf("%s.tailscale_port duplicates %s", path, prev))
} else {
usedForwardListeners[key] = path
}
}
if strings.TrimSpace(rule.LocalAddr) == "" {
errs = append(errs, fmt.Errorf("%s.local_addr is required", path))
} else if err := validateHostPort(rule.LocalAddr); err != nil {
errs = append(errs, fmt.Errorf("%s.local_addr invalid: %w", path, err))
}
}
}
for tag, rules := range cfg.Connect {
for i, rule := range rules {
path := fmt.Sprintf("connect.%s[%d]", tag, i)
if rule.Protocol != "tcp" && rule.Protocol != "udp" && rule.Protocol != "minecraft" {
errs = append(errs, fmt.Errorf("%s.protocol must be tcp, udp, or minecraft", path))
}
if !validPort(rule.LocalPort) {
errs = append(errs, fmt.Errorf("%s.local_port must be between 1 and 65535", path))
}
if strings.TrimSpace(rule.DstAddr) == "" {
errs = append(errs, fmt.Errorf("%s.dst_addr is required", path))
} else if err := validateHostPort(rule.DstAddr); err != nil {
errs = append(errs, fmt.Errorf("%s.dst_addr invalid: %w", path, err))
}
if rule.LocalAddr != "" && net.ParseIP(rule.LocalAddr) == nil {
errs = append(errs, fmt.Errorf("%s.local_addr must be an IP address", path))
}
if validPort(rule.LocalPort) && (rule.Protocol == "tcp" || rule.Protocol == "udp" || rule.Protocol == "minecraft") {
network := rule.Protocol
if network == "minecraft" {
network = "tcp"
}
if prev, ok := conflictingListener(usedConnectListeners, network, rule.BindIP(), rule.LocalPort); ok {
errs = append(errs, fmt.Errorf("%s local listener duplicates %s", path, prev))
}
usedConnectListeners[listenerKey(network, rule.BindIP(), rule.LocalPort)] = path
}
}
}
return errors.Join(errs...)
}
func validPort(port int) bool {
return port > 0 && port <= 65535
}
func validateHostPort(addr string) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}
if strings.TrimSpace(host) == "" {
return errors.New("host is required")
}
if strings.TrimSpace(port) == "" {
return errors.New("port is required")
}
return nil
}
func listenerKey(network, ip string, port int) string {
return fmt.Sprintf("%s/%s", network, net.JoinHostPort(ip, fmt.Sprintf("%d", port)))
}
func conflictingListener(used map[string]string, network, ip string, port int) (string, bool) {
candidates := []string{
listenerKey(network, ip, port),
}
if ip == "0.0.0.0" {
for key, path := range used {
prefix := network + "/"
_, usedPort, err := net.SplitHostPort(strings.TrimPrefix(key, prefix))
if strings.HasPrefix(key, prefix) && err == nil && usedPort == fmt.Sprintf("%d", port) {
return path, true
}
}
} else {
candidates = append(candidates, listenerKey(network, "0.0.0.0", port))
}
for _, key := range candidates {
if prev, ok := used[key]; ok {
return prev, true
}
}
return "", false
}
// LoadConfig loads configuration from a file path or URL.
// If path starts with "http://" or "https://", it fetches the config from the URL.
// Otherwise, it reads from the local file system.
@@ -92,12 +248,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
if cfg.Core.Hostname == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
cfg.Core.Hostname = hostname
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
@@ -139,12 +292,9 @@ func loadConfigFromURL(url string) (*Config, error) {
return nil, fmt.Errorf("failed to decode TOML config from %s: %w", url, err)
}
if cfg.Core.Hostname == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
cfg.Core.Hostname = hostname
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
+135
View File
@@ -0,0 +1,135 @@
package core
import (
"strings"
"testing"
)
func TestConnectRuleBindIP(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rule ConnectRule
want string
}{
{
name: "default local only",
rule: ConnectRule{Protocol: "tcp"},
want: "127.0.0.1",
},
{
name: "explicit local addr",
rule: ConnectRule{Protocol: "udp", LocalAddr: "192.168.1.10"},
want: "192.168.1.10",
},
{
name: "minecraft exposes LAN by default",
rule: ConnectRule{Protocol: "minecraft"},
want: "0.0.0.0",
},
{
name: "lan enable exposes LAN",
rule: ConnectRule{Protocol: "tcp", LanEnable: boolPtr(true)},
want: "0.0.0.0",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.rule.BindIP(); got != tt.want {
t.Fatalf("BindIP() = %q, want %q", got, tt.want)
}
})
}
}
func TestConfigValidateAcceptsValidConfig(t *testing.T) {
t.Parallel()
cfg := Config{
Core: Core{AuthKey: "tskey-auth-example"},
DNS: DNS{DoHServers: []string{"https://cloudflare-dns.com/dns-query"}},
Forward: map[string][]ForwardRule{
"web": {
{Protocol: "tcp", TailscalePort: 8080, LocalAddr: "127.0.0.1:9090"},
{Protocol: "udp", TailscalePort: 8080, LocalAddr: "127.0.0.1:9090"},
},
},
Connect: map[string][]ConnectRule{
"api": {
{Protocol: "tcp", LocalPort: 9000, DstAddr: "host.ts.net:8080"},
{Protocol: "udp", LocalPort: 9000, DstAddr: "host.ts.net:8080"},
},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error: %v", err)
}
}
func TestConfigValidateRejectsInvalidConfig(t *testing.T) {
t.Parallel()
cfg := Config{
Core: Core{},
Forward: map[string][]ForwardRule{
"bad": {
{Protocol: "icmp", TailscalePort: 70000, LocalAddr: "127.0.0.1"},
},
},
Connect: map[string][]ConnectRule{
"bad": {
{Protocol: "tcp", LocalPort: 9000, DstAddr: "host.ts.net:8080"},
{Protocol: "minecraft", LocalPort: 9000, DstAddr: "host.ts.net:25565"},
},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil, want error")
}
for _, want := range []string{
"core.auth_key is required",
"forward.bad[0].protocol must be tcp or udp",
"forward.bad[0].tailscale_port must be between 1 and 65535",
"forward.bad[0].local_addr invalid",
"connect.bad[1] local listener duplicates connect.bad[0]",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("Validate() error %q does not contain %q", err.Error(), want)
}
}
}
func TestConfigValidateRejectsInvalidDoHServer(t *testing.T) {
t.Parallel()
cfg := Config{
Core: Core{AuthKey: "tskey-auth-example"},
DNS: DNS{DoHServers: []string{"https://ok.example/dns-query", "not a url", "ftp://wrong.example"}},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() returned nil, want error")
}
for _, want := range []string{
"dns.doh_servers[1] must be a valid http(s) URL",
"dns.doh_servers[2] must be a valid http(s) URL",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("Validate() error %q does not contain %q", err.Error(), want)
}
}
}
func boolPtr(v bool) *bool {
return &v
}
+39
View File
@@ -0,0 +1,39 @@
package core
import (
"context"
"log/slog"
"tailscale.com/tsnet"
)
func StartConnectors(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule) {
for tag, rrs := range rules {
for _, rule := range rrs {
args := []any{
slog.String("tag", tag),
slog.String("protocol", rule.Protocol),
slog.Int("local_port", rule.LocalPort),
slog.String("dst_addr", rule.DstAddr),
}
if rule.LocalAddr != "" {
args = append(args, slog.String("local_addr", rule.LocalAddr))
}
slog.Info("starting connector", args...)
go runConnector(ctx, srv, rule, tag)
}
}
}
func runConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, tag string) {
logger := RuleLogger(rule, tag)
switch rule.Protocol {
case "tcp", "minecraft":
runTCPConnector(ctx, srv, rule, logger)
case "udp":
runUDPConnector(ctx, srv, rule, logger)
default:
logger.Error("unsupported protocol, expected tcp or udp")
}
}
+27
View File
@@ -0,0 +1,27 @@
package core
import (
"context"
"net"
"time"
"tailscale.com/tsnet"
)
const dialTimeout = 10 * time.Second
func dialTCP(ctx context.Context, addr string) (net.Conn, error) {
dialer := net.Dialer{Timeout: dialTimeout}
return dialer.DialContext(ctx, "tcp", addr)
}
func dialUDP(ctx context.Context, addr string) (net.Conn, error) {
dialer := net.Dialer{Timeout: dialTimeout}
return dialer.DialContext(ctx, "udp", addr)
}
func dialTsnet(ctx context.Context, srv *tsnet.Server, network, addr string) (net.Conn, error) {
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
defer cancel()
return srv.Dial(dialCtx, network, addr)
}
+295
View File
@@ -0,0 +1,295 @@
package core
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"strings"
"sync"
"time"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/dns"
"tailscale.com/tsnet"
)
// magicdns suffix cache
var (
magicDNSSuffixMu sync.RWMutex
magicDNSSuffix string
)
func SetMagicDNSSuffix(raw string) {
magicDNSSuffixMu.Lock()
defer magicDNSSuffixMu.Unlock()
magicDNSSuffix = strings.Trim(raw, ".")
}
func GetMagicDNSSuffix() (string, bool) {
magicDNSSuffixMu.RLock()
defer magicDNSSuffixMu.RUnlock()
if magicDNSSuffix == "" {
return "", false
}
return magicDNSSuffix, true
}
func GetMagicDNSSuffixFromStatus(st *ipnstate.Status) (string, error) {
suffix := st.CurrentTailnet.MagicDNSSuffix
suffix = strings.Trim(suffix, ".")
if suffix == "" {
return "", errors.New("magic dns suffix not found in status")
}
return suffix, nil
}
// errNotTailnetPeer reports that a destination resolved successfully but the
// resulting IP is not carried by any tailnet peer — an ordinary public address.
// It is distinct from a resolution failure: retrying will not change the answer.
var errNotTailnetPeer = errors.New("address is not reachable through a tailnet peer")
// resolveAddr maps a destination host (an IP literal or a domain) to the
// tailnet address of the peer that carries it, so the peer can be pinged for
// connectivity diagnostics. Names are resolved through the same tailnet-aware
// path the dial code uses (see resolveHostToIP), so MagicDNS and split-DNS
// destinations behave identically in both.
func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Addr, error) {
ip, err := netip.ParseAddr(addr)
if err != nil {
ip, err = resolveHostToIP(ctx, srv, addr)
if err != nil {
return nil, err
}
}
stat, err := getCachedStatus(ctx, srv)
if err != nil {
return nil, err
}
peer, ok := peerCarryingIP(stat, ip)
if !ok {
return nil, fmt.Errorf("%w: %s (%s)", errNotTailnetPeer, addr, ip)
}
return &peer, nil
}
// peerCarryingIP returns the tailnet address of the peer that ip belongs to,
// either because it is the peer's own address or because the peer advertises a
// route covering it.
func peerCarryingIP(stat *ipnstate.Status, ip netip.Addr) (netip.Addr, bool) {
for _, peer := range stat.Peer {
for _, peerIP := range peer.TailscaleIPs {
if peerIP == ip {
return peer.TailscaleIPs[0], true
}
}
}
// Otherwise the subnet router advertising the most specific route wins.
// Default routes are skipped: an exit node advertises 0.0.0.0/0, which
// contains every address and would otherwise shadow the real owner at
// random, since Go's map iteration order is unspecified. Ties are broken by
// the lowest tailnet address so repeated calls agree with each other.
bestBits := -1
var best netip.Addr
for _, peer := range stat.Peer {
if peer.AllowedIPs == nil || peer.AllowedIPs.IsNil() || len(peer.TailscaleIPs) == 0 {
continue
}
for _, route := range peer.AllowedIPs.All() {
if route.Bits() == 0 || !route.Contains(ip) {
continue
}
candidate := peer.TailscaleIPs[0]
if route.Bits() > bestBits || (route.Bits() == bestBits && candidate.Compare(best) < 0) {
bestBits, best = route.Bits(), candidate
}
}
}
return best, bestBits >= 0
}
// resolveHostToIP resolves a bare hostname to an address using the tailnet's
// own resolver, falling back to DNS-over-HTTPS. Both the dial path and the
// connectivity diagnostics go through here so they share one view of DNS.
//
// A bare single-label name additionally gets the MagicDNS suffix appended so
// short tailnet hostnames still resolve; a name that already contains a dot (an
// FQDN, including split-DNS suffixes) is queried as-is.
func resolveHostToIP(ctx context.Context, srv *tsnet.Server, host string) (netip.Addr, error) {
candidates := []string{host}
if suffix, ok := GetMagicDNSSuffix(); ok && !strings.Contains(host, ".") {
candidates = append(candidates, host+"."+suffix)
}
var lastErr error
if dnsMgr, ok := srv.Sys().DNSManager.GetOK(); ok {
for _, name := range candidates {
ip, err := resolveHostViaResolver(ctx, dnsMgr, name)
if err != nil {
lastErr = err
continue
}
return ip, nil
}
} else {
lastErr = errors.New("DNS manager not available")
}
// Fallback: resolve public names via DNS-over-HTTPS when the tailnet
// resolver couldn't (no working system DNS on the host, or a name outside
// the tailnet's split-DNS routes). Only the original host is queried — DoH
// can't resolve tailnet-internal MagicDNS names.
if dohEnabled() {
if ip, derr := resolveHostViaDoH(ctx, host); derr == nil {
return ip, nil
} else {
lastErr = fmt.Errorf("tailnet dns: %v; doh: %w", lastErr, derr)
}
}
return netip.Addr{}, fmt.Errorf("resolve %q: %w", host, lastErr)
}
// resolveDialAddr resolves the host portion of a "host:port" destination to a
// concrete "ip:port" using the tailnet's own DNS resolver.
//
// tsnet's Server.Dial only resolves MagicDNS names that are baked into the
// network map; for everything else it falls back to the host OS resolver, which
// has no knowledge of the tailnet's split-DNS configuration (custom search
// domains such as *.homelab.ice whose queries are routed to a nameserver
// reachable over Tailscale). By resolving through srv.Sys().DNSManager here —
// which honors MagicDNS and split-DNS routes exactly like quad-100 would — and
// dialing the resulting IP, split-DNS destinations resolve correctly.
//
// Literal IP destinations are returned unchanged. When tailnet resolution
// fails, the original address is returned together with the error so the caller
// may still fall back to dialing the name directly (e.g. via the system
// resolver for ordinary public names).
func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr, err
}
if _, err := netip.ParseAddr(host); err == nil {
return addr, nil // already ip:port, nothing to resolve
}
ip, err := resolveHostToIP(ctx, srv, host)
if err != nil {
return addr, err
}
return net.JoinHostPort(ip.String(), port), nil
}
// dnsExchange sends a single DNS question and returns the first address answer,
// or a CNAME target if one is present instead. It abstracts the transport so the
// Tailscale resolver and the DNS-over-HTTPS fallback (see doh.go) can share the
// CNAME-chasing logic in resolveHostChase.
type dnsExchange func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error)
// resolveHostViaResolver resolves a hostname to a netip.Addr using the
// Tailscale DNS resolver. It queries A then AAAA records and follows CNAME
// chains (up to 8 levels deep).
func resolveHostViaResolver(ctx context.Context, resolver *dns.Manager, host string) (netip.Addr, error) {
return resolveHostChase(ctx, host, 0, tailnetExchange(resolver))
}
// tailnetExchange returns a dnsExchange backed by the Tailscale DNS resolver.
func tailnetExchange(r *dns.Manager) dnsExchange {
return func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) {
queryBytes, err := buildDNSQuery(name, qType)
if err != nil {
return netip.Addr{}, "", err
}
qctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
respBytes, err := r.Query(qctx, queryBytes, "udp", netip.AddrPort{})
if err != nil {
return netip.Addr{}, "", fmt.Errorf("DNS resolution failed for %s: %w", strings.TrimSuffix(name.String(), "."), err)
}
return parseDNSAnswer(respBytes)
}
}
// resolveHostChase resolves host to an address by issuing A then AAAA questions
// through exchange and following CNAME chains (up to maxCNAMEChase levels deep).
// A first (MagicDNS hands out an IPv4 for tailnet peers), then AAAA so IPv6-only
// split-DNS hosts still resolve; a CNAME seen in either answer is chased once no
// address record is found.
func resolveHostChase(ctx context.Context, host string, depth int, exchange dnsExchange) (netip.Addr, error) {
const maxCNAMEChase = 8
if depth > maxCNAMEChase {
return netip.Addr{}, fmt.Errorf("CNAME chain too deep for %s", host)
}
name, err := dnsmessage.NewName(host + ".")
if err != nil {
return netip.Addr{}, fmt.Errorf("invalid hostname %s: %w", host, err)
}
var cnameTarget string
for _, qType := range []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA} {
ip, cname, err := exchange(ctx, name, qType)
if err != nil {
return netip.Addr{}, err
}
if ip.IsValid() {
return ip, nil
}
if cname != "" {
cnameTarget = cname
}
}
if cnameTarget != "" {
return resolveHostChase(ctx, cnameTarget, depth+1, exchange)
}
return netip.Addr{}, fmt.Errorf("no A/AAAA record found for %s", host)
}
// buildDNSQuery packs a single-question DNS query message for name/qType.
func buildDNSQuery(name dnsmessage.Name, qType dnsmessage.Type) ([]byte, error) {
msg := dnsmessage.Message{
Header: dnsmessage.Header{RecursionDesired: true},
Questions: []dnsmessage.Question{
{Name: name, Type: qType, Class: dnsmessage.ClassINET},
},
}
queryBytes, err := msg.Pack()
if err != nil {
return nil, fmt.Errorf("failed to pack DNS query: %w", err)
}
return queryBytes, nil
}
// parseDNSAnswer unpacks a DNS response and returns the first A/AAAA address, or
// a CNAME target if one is present instead of an address record.
func parseDNSAnswer(respBytes []byte) (netip.Addr, string, error) {
var resp dnsmessage.Message
if err := resp.Unpack(respBytes); err != nil {
return netip.Addr{}, "", fmt.Errorf("failed to unpack DNS response: %w", err)
}
var cname string
for _, ans := range resp.Answers {
switch body := ans.Body.(type) {
case *dnsmessage.AResource:
if ip := netip.AddrFrom4(body.A); ip.IsValid() {
return ip, "", nil
}
case *dnsmessage.AAAAResource:
if ip := netip.AddrFrom16(body.AAAA); ip.IsValid() {
return ip, "", nil
}
case *dnsmessage.CNAMEResource:
cname = strings.TrimSuffix(body.CNAME.String(), ".")
}
}
return netip.Addr{}, cname, nil
}
+135
View File
@@ -0,0 +1,135 @@
package core
import (
"net/netip"
"testing"
"tailscale.com/ipn/ipnstate"
"tailscale.com/types/key"
"tailscale.com/types/views"
)
// peerStatus builds a PeerStatus with the given tailnet address and advertised
// routes. Passing no routes leaves AllowedIPs nil, as it is for peers that
// advertise nothing.
func peerStatus(tailIP string, routes ...string) *ipnstate.PeerStatus {
ps := &ipnstate.PeerStatus{
TailscaleIPs: []netip.Addr{netip.MustParseAddr(tailIP)},
}
if len(routes) > 0 {
prefixes := make([]netip.Prefix, 0, len(routes))
for _, r := range routes {
prefixes = append(prefixes, netip.MustParsePrefix(r))
}
s := views.SliceOf(prefixes)
ps.AllowedIPs = &s
}
return ps
}
func statusWithPeers(peers ...*ipnstate.PeerStatus) *ipnstate.Status {
st := &ipnstate.Status{Peer: make(map[key.NodePublic]*ipnstate.PeerStatus, len(peers))}
for _, p := range peers {
st.Peer[key.NewNode().Public()] = p
}
return st
}
func TestPeerCarryingIP(t *testing.T) {
t.Parallel()
tests := []struct {
name string
peers []*ipnstate.PeerStatus
ip string
want string // "" means no peer expected
}{
{
name: "peer's own address",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1", "100.64.0.1/32")},
ip: "100.64.0.1",
want: "100.64.0.1",
},
{
name: "subnet router carries a LAN address",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.2", "100.64.0.2/32", "10.0.0.0/24"),
peerStatus("100.64.0.3", "100.64.0.3/32"),
},
ip: "10.0.0.7",
want: "100.64.0.2",
},
{
// An exit node advertises 0.0.0.0/0, which Contains every address.
// Matching it would pick a peer at random out of map iteration order.
name: "exit node does not shadow the real subnet router",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.9", "0.0.0.0/0", "::/0"),
peerStatus("100.64.0.2", "10.0.0.0/24"),
},
ip: "10.0.0.7",
want: "100.64.0.2",
},
{
name: "most specific route wins",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.4", "10.0.0.0/8"),
peerStatus("100.64.0.5", "10.0.0.0/24"),
},
ip: "10.0.0.7",
want: "100.64.0.5",
},
{
name: "equal routes break the tie deterministically",
peers: []*ipnstate.PeerStatus{
peerStatus("100.64.0.8", "10.0.0.0/24"),
peerStatus("100.64.0.6", "10.0.0.0/24"),
},
ip: "10.0.0.7",
want: "100.64.0.6",
},
{
name: "public address belongs to no peer",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1", "10.0.0.0/24")},
ip: "1.1.1.1",
want: "",
},
{
name: "peer without AllowedIPs is skipped, not dereferenced",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.1")},
ip: "10.0.0.7",
want: "",
},
{
name: "exit node alone still does not match",
peers: []*ipnstate.PeerStatus{peerStatus("100.64.0.9", "0.0.0.0/0")},
ip: "1.1.1.1",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
st := statusWithPeers(tt.peers...)
// Run repeatedly: map iteration order is unspecified, so a result
// that depends on it shows up as a flake here.
for range 20 {
got, ok := peerCarryingIP(st, netip.MustParseAddr(tt.ip))
if tt.want == "" {
if ok {
t.Fatalf("peerCarryingIP() = %v, true; want no match", got)
}
continue
}
if !ok {
t.Fatalf("peerCarryingIP() = _, false; want %s", tt.want)
}
if got.String() != tt.want {
t.Fatalf("peerCarryingIP() = %s, want %s", got, tt.want)
}
}
})
}
}
+110
View File
@@ -0,0 +1,110 @@
package core
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"sync"
"time"
"golang.org/x/net/dns/dnsmessage"
)
// dohContentType is the RFC 8484 media type for binary DNS messages over HTTPS.
const dohContentType = "application/dns-message"
// maxDoHResponse bounds how much of a DoH response body we read, guarding against
// a hostile or misbehaving endpoint streaming an unbounded body.
const maxDoHResponse = 64 << 10 // 64 KiB
// Configured DNS-over-HTTPS endpoints, set once at startup from the config file
// (see SetDoHServers). Mirrors the magicDNSSuffix package-global pattern in
// dns.go so the resolver need not thread config through every call.
var (
dohMu sync.RWMutex
dohServers []string
dohClient = &http.Client{Timeout: 10 * time.Second}
)
// SetDoHServers records the DNS-over-HTTPS fallback endpoints.
func SetDoHServers(servers []string) {
dohMu.Lock()
defer dohMu.Unlock()
dohServers = append([]string(nil), servers...)
}
// dohEnabled reports whether any DoH fallback endpoint is configured.
func dohEnabled() bool {
dohMu.RLock()
defer dohMu.RUnlock()
return len(dohServers) > 0
}
// getDoHServers returns a copy of the configured DoH endpoints.
func getDoHServers() []string {
dohMu.RLock()
defer dohMu.RUnlock()
return append([]string(nil), dohServers...)
}
// resolveHostViaDoH resolves host through the configured DoH endpoints.
func resolveHostViaDoH(ctx context.Context, host string) (netip.Addr, error) {
return resolveViaDoHServers(ctx, getDoHServers(), host)
}
// resolveViaDoHServers tries each server in order, returning the first address
// that resolves. It takes the server list explicitly so it can be exercised in
// tests without touching package globals.
func resolveViaDoHServers(ctx context.Context, servers []string, host string) (netip.Addr, error) {
if len(servers) == 0 {
return netip.Addr{}, errors.New("no DoH servers configured")
}
var lastErr error
for _, server := range servers {
ip, err := resolveHostChase(ctx, host, 0, dohExchange(server))
if err != nil {
lastErr = fmt.Errorf("doh %s: %w", server, err)
continue
}
return ip, nil
}
return netip.Addr{}, lastErr
}
// dohExchange returns a dnsExchange that resolves a single question against a
// single DoH endpoint using the RFC 8484 binary wire format over HTTPS POST.
func dohExchange(server string) dnsExchange {
return func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) {
query, err := buildDNSQuery(name, qType)
if err != nil {
return netip.Addr{}, "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server, bytes.NewReader(query))
if err != nil {
return netip.Addr{}, "", fmt.Errorf("build DoH request: %w", err)
}
req.Header.Set("Content-Type", dohContentType)
req.Header.Set("Accept", dohContentType)
resp, err := dohClient.Do(req)
if err != nil {
return netip.Addr{}, "", fmt.Errorf("DoH request to %s failed: %w", server, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return netip.Addr{}, "", fmt.Errorf("DoH request to %s: unexpected status %d", server, resp.StatusCode)
}
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxDoHResponse))
if err != nil {
return netip.Addr{}, "", fmt.Errorf("read DoH response from %s: %w", server, err)
}
return parseDNSAnswer(respBytes)
}
}
+96
View File
@@ -0,0 +1,96 @@
package core
import (
"context"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"golang.org/x/net/dns/dnsmessage"
)
func TestResolveViaDoHServers(t *testing.T) {
t.Parallel()
want := netip.AddrFrom4([4]byte{93, 184, 216, 34})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != dohContentType {
t.Errorf("Content-Type = %q, want %q", ct, dohContentType)
}
w.Header().Set("Content-Type", dohContentType)
_, _ = w.Write(packAResponse(t, "example.com.", [4]byte{93, 184, 216, 34}))
}))
defer srv.Close()
got, err := resolveViaDoHServers(context.Background(), []string{srv.URL}, "example.com")
if err != nil {
t.Fatalf("resolveViaDoHServers() error: %v", err)
}
if got != want {
t.Fatalf("resolveViaDoHServers() = %v, want %v", got, want)
}
}
func TestResolveViaDoHServersFallsThrough(t *testing.T) {
t.Parallel()
// First endpoint errors; the second answers. Confirms the loop advances past
// a failing server instead of giving up.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer bad.Close()
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", dohContentType)
_, _ = w.Write(packAResponse(t, "example.com.", [4]byte{1, 2, 3, 4}))
}))
defer good.Close()
got, err := resolveViaDoHServers(context.Background(), []string{bad.URL, good.URL}, "example.com")
if err != nil {
t.Fatalf("resolveViaDoHServers() error: %v", err)
}
if want := netip.AddrFrom4([4]byte{1, 2, 3, 4}); got != want {
t.Fatalf("resolveViaDoHServers() = %v, want %v", got, want)
}
}
func TestResolveViaDoHServersNoServers(t *testing.T) {
t.Parallel()
if _, err := resolveViaDoHServers(context.Background(), nil, "example.com"); err == nil {
t.Fatal("resolveViaDoHServers() with no servers returned nil error, want error")
}
}
// packAResponse builds a minimal DNS response carrying a single A record.
func packAResponse(t *testing.T, name string, ip [4]byte) []byte {
t.Helper()
dnsName, err := dnsmessage.NewName(name)
if err != nil {
t.Fatalf("NewName: %v", err)
}
msg := dnsmessage.Message{
Header: dnsmessage.Header{Response: true},
Answers: []dnsmessage.Resource{
{
Header: dnsmessage.ResourceHeader{
Name: dnsName,
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
},
Body: &dnsmessage.AResource{A: ip},
},
},
}
b, err := msg.Pack()
if err != nil {
t.Fatalf("Pack: %v", err)
}
return b
}
-591
View File
@@ -2,21 +2,12 @@ package core
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/netip"
"sync"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tsnet"
)
const udpForwardIdleTimeout = 2 * time.Minute
func StartForwarders(ctx context.Context, srv *tsnet.Server, rules map[string][]ForwardRule) {
for tag, rrs := range rules {
for _, rule := range rrs {
@@ -31,24 +22,6 @@ func StartForwarders(ctx context.Context, srv *tsnet.Server, rules map[string][]
}
}
func StartConnectors(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule) {
for tag, rrs := range rules {
for _, rule := range rrs {
args := []any{
slog.String("tag", tag),
slog.String("protocol", rule.Protocol),
slog.Int("local_port", rule.LocalPort),
slog.String("dst_addr", rule.DstAddr),
}
if rule.LocalAddr != "" {
args = append(args, slog.String("local_addr", rule.LocalAddr))
}
slog.Info("starting connector", args...)
go runConnector(ctx, srv, rule, tag)
}
}
}
func RuleLogger(rule any, tag string) *slog.Logger {
var args []any
switch r := rule.(type) {
@@ -90,567 +63,3 @@ func runForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, tag
logger.Error("unsupported protocol, expected tcp or udp")
}
}
func runTCPForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, logger *slog.Logger) {
ip := getSelfTsnetAddr(srv)
ln, err := srv.Listen("tcp", fmt.Sprintf("%s:%d", ip.String(), rule.TailscalePort))
if err != nil {
logger.Error("failed to listen", "error", err)
return
}
logger.Debug("listening", slog.String("on", fmt.Sprintf("tailscale:%s:%d", ip.String(), rule.TailscalePort)))
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("accept error", "error", err)
continue
}
go handleTCPForward(ctx, srv, conn, rule, logger)
}
}
func handleTCPForward(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ForwardRule, logger *slog.Logger) {
remoteAddrStr := conn.RemoteAddr().String()
clog := logger.With(slog.String("remote", remoteAddrStr))
lc, err := srv.LocalClient()
if err == nil {
who, err := lc.WhoIs(ctx, remoteAddrStr)
if err == nil {
clog = clog.With(slog.String("user", who.UserProfile.LoginName))
}
}
connType := getConnType(ctx, srv, remoteAddrStr)
clog.Info("accepted connection",
slog.String("conn_type", connType),
slog.String("local_addr", rule.LocalAddr),
)
localConn, err := net.Dial("tcp", rule.LocalAddr)
if err != nil {
clog.Error("failed to dial local", "error", err)
conn.Close()
return
}
stop := context.AfterFunc(ctx, func() {
conn.Close()
localConn.Close()
})
defer stop()
toLocal, toTs := pipeConns(conn, localConn)
clog.Info("connection closed", slog.Int64("ts_rx_bytes", toLocal), slog.Int64("ts_tx_bytes", toTs))
}
var statusCache struct {
mu sync.Mutex
status *ipnstate.Status
expires time.Time
}
const statusCacheTTL = 5 * time.Second
func getCachedStatus(ctx context.Context, srv *tsnet.Server) (*ipnstate.Status, error) {
statusCache.mu.Lock()
if statusCache.status != nil && time.Now().Before(statusCache.expires) {
st := statusCache.status
statusCache.mu.Unlock()
return st, nil
}
statusCache.mu.Unlock()
lc, err := srv.LocalClient()
if err != nil {
return nil, err
}
st, err := lc.Status(ctx)
if err != nil {
return nil, err
}
statusCache.mu.Lock()
statusCache.status = st
statusCache.expires = time.Now().Add(statusCacheTTL)
statusCache.mu.Unlock()
return st, nil
}
func isTsnetTarget(host string) bool {
if ip, err := netip.ParseAddr(host); err == nil {
tsnetV4 := netip.MustParsePrefix("100.64.0.0/10")
tsnetV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
return tsnetV4.Contains(ip) || tsnetV6.Contains(ip)
}
return true
}
func getConnType(ctx context.Context, srv *tsnet.Server, remoteAddrStr string) string {
st, err := getCachedStatus(ctx, srv)
if err != nil {
return "unknown"
}
remoteHost, _, err := net.SplitHostPort(remoteAddrStr)
if err != nil {
return "unknown"
}
for _, peer := range st.Peer {
for _, addr := range peer.TailscaleIPs {
if addr.String() == remoteHost {
if peer.CurAddr != "" {
return "direct"
}
if peer.Relay != "" {
return fmt.Sprintf("derp(%s)", peer.Relay)
}
return "direct"
}
}
}
return "unknown"
}
func runUDPForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, logger *slog.Logger) {
ip := getSelfTsnetAddr(srv)
ln, err := srv.Listen("udp", fmt.Sprintf("%s:%d", ip.String(), rule.TailscalePort))
if err != nil {
logger.Error("failed to listen", "error", err)
return
}
logger.Debug("listening", slog.String("on", fmt.Sprintf("tailscale:%s:%d", ip.String(), rule.TailscalePort)))
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("accept error", "error", err)
continue
}
go handleUDPForward(ctx, srv, conn, rule, logger)
}
}
func handleUDPForward(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ForwardRule, logger *slog.Logger) {
remoteAddrStr := conn.RemoteAddr().String()
clog := logger.With(slog.String("remote", remoteAddrStr))
lc, err := srv.LocalClient()
if err == nil {
who, err := lc.WhoIs(ctx, remoteAddrStr)
if err == nil {
clog = clog.With(slog.String("user", who.UserProfile.LoginName))
}
}
connType := getConnType(ctx, srv, remoteAddrStr)
clog.Info("accepted connection",
slog.String("conn_type", connType),
slog.String("local_addr", rule.LocalAddr),
)
localConn, err := net.Dial("udp", rule.LocalAddr)
if err != nil {
clog.Error("failed to dial local", "error", err)
conn.Close()
return
}
stop := context.AfterFunc(ctx, func() {
conn.Close()
localConn.Close()
})
defer stop()
remoteIP, _, _ := net.SplitHostPort(remoteAddrStr)
var toTs, toLocal int64
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
buf := make([]byte, 65535)
for {
_ = conn.SetReadDeadline(time.Now().Add(udpForwardIdleTimeout))
n, err := conn.Read(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
clog.Debug("udp forward idle timeout on ts side")
} else if !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.EOF) {
clog.Debug("udp forward read from ts", "error", err)
}
localConn.Close()
return
}
_ = conn.SetReadDeadline(time.Time{})
toLocal += int64(n)
clog.Debug("inbound udp packet",
slog.String("from_ip", remoteIP),
slog.String("to_ip", rule.LocalAddr),
slog.Int("pkg_size", n),
)
if _, err := localConn.Write(buf[:n]); err != nil {
conn.Close()
return
}
}
}()
go func() {
defer wg.Done()
buf := make([]byte, 65535)
for {
_ = localConn.SetReadDeadline(time.Now().Add(udpForwardIdleTimeout))
n, err := localConn.Read(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
clog.Debug("udp forward idle timeout on local side")
} else if !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.EOF) {
clog.Debug("udp forward read from local", "error", err)
}
conn.Close()
return
}
_ = localConn.SetReadDeadline(time.Time{})
toTs += int64(n)
localIP, _, _ := net.SplitHostPort(localConn.RemoteAddr().String())
clog.Debug("outbound udp packet",
slog.String("from_ip", localIP),
slog.String("to_ip", remoteIP),
slog.Int("pkg_size", n),
)
if _, err := conn.Write(buf[:n]); err != nil {
localConn.Close()
return
}
}
}()
wg.Wait()
clog.Info("connection closed", slog.Int64("ts_rx_bytes", toLocal), slog.Int64("ts_tx_bytes", toTs))
}
const udpRelayMaxSessions = 1024
type udpSession struct {
conn net.Conn
remote net.Addr
lastUse time.Time
}
type udpRelay struct {
listenConn net.PacketConn
dialAddr string
logger *slog.Logger
direction string
srv *tsnet.Server
mu sync.Mutex
sessions map[string]*udpSession
}
func (r *udpRelay) run(ctx context.Context) {
go func() {
<-ctx.Done()
r.listenConn.Close()
}()
go func() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.cleanup()
}
}
}()
buf := make([]byte, 65535)
for {
select {
case <-ctx.Done():
return
default:
}
n, from, err := r.listenConn.ReadFrom(buf)
if err != nil {
if ctx.Err() != nil {
return
}
r.logger.Error("udp read error", "error", err)
return
}
key := from.String()
var toIP string
r.mu.Lock()
sess, exists := r.sessions[key]
if !exists {
if len(r.sessions) >= udpRelayMaxSessions {
r.mu.Unlock()
r.logger.Warn("udp relay session limit reached, dropping packet",
slog.Int("limit", udpRelayMaxSessions),
slog.String("remote", key),
)
continue
}
host, _, err := net.SplitHostPort(r.dialAddr)
if err != nil {
r.mu.Unlock()
r.logger.Error("failed to parse dial addr", "error", err)
continue
}
inTsnet := isTsnetTarget(host)
r.mu.Unlock()
var dialed net.Conn
if inTsnet {
dialed, err = r.srv.Dial(ctx, "udp", r.dialAddr)
} else {
dialed, err = net.Dial("udp", r.dialAddr)
}
if err != nil {
r.logger.Error("failed to dial", "error", err)
continue
}
sess = &udpSession{conn: dialed, remote: from, lastUse: time.Now()}
r.mu.Lock()
if existing, dup := r.sessions[key]; dup {
dialed.Close()
sess = existing
sess.lastUse = time.Now()
} else {
r.sessions[key] = sess
}
toIP = sess.conn.RemoteAddr().String()
r.mu.Unlock()
r.logger.Info("new udp session", slog.String("remote", key), slog.String("direction", r.direction))
go r.readSession(key, sess)
} else {
sess.lastUse = time.Now()
toIP = sess.conn.RemoteAddr().String()
r.mu.Unlock()
}
fromIP, _, _ := net.SplitHostPort(from.String())
toIPHost, _, _ := net.SplitHostPort(toIP)
r.logger.Debug("outbound udp packet",
slog.String("from_ip", fromIP),
slog.String("to_ip", toIPHost),
slog.Int("pkg_size", n),
)
if _, err := sess.conn.Write(buf[:n]); err != nil {
r.logger.Error("failed to write", "error", err)
r.removeSession(key)
}
}
}
func (r *udpRelay) readSession(key string, sess *udpSession) {
buf := make([]byte, 65535)
for {
n, err := sess.conn.Read(buf)
if err != nil {
r.removeSession(key)
return
}
fromIP, _, _ := net.SplitHostPort(sess.conn.RemoteAddr().String())
toIP, _, _ := net.SplitHostPort(sess.remote.String())
r.logger.Info("udp packet",
slog.String("from_ip", fromIP),
slog.String("to_ip", toIP),
slog.Int("pkg_size", n),
)
if _, err := r.listenConn.WriteTo(buf[:n], sess.remote); err != nil {
r.logger.Error("failed to write back", "error", err)
r.removeSession(key)
return
}
sess.lastUse = time.Now()
}
}
func (r *udpRelay) removeSession(key string) {
r.mu.Lock()
defer r.mu.Unlock()
if s, ok := r.sessions[key]; ok {
remote := s.remote.String()
s.conn.Close()
delete(r.sessions, key)
r.logger.Debug("udp session closed", slog.String("remote", remote))
}
}
func (r *udpRelay) cleanup() {
r.mu.Lock()
defer r.mu.Unlock()
threshold := time.Now().Add(-5 * time.Minute)
for key, s := range r.sessions {
if s.lastUse.Before(threshold) {
remote := s.remote.String()
s.conn.Close()
delete(r.sessions, key)
r.logger.Debug("udp session cleaned up", slog.String("remote", remote))
}
}
}
func runConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, tag string) {
logger := RuleLogger(rule, tag)
switch rule.Protocol {
case "tcp", "minecraft":
runTCPConnector(ctx, srv, rule, logger)
case "udp":
runUDPConnector(ctx, srv, rule, logger)
default:
logger.Error("unsupported protocol, expected tcp or udp")
}
}
func runTCPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, logger *slog.Logger) {
bindIP := rule.LocalAddr
if bindIP == "" {
bindIP = "0.0.0.0"
}
if rule.LANEnabled() && bindIP != "0.0.0.0" {
logger.Warn("lan_enable forces local_addr to 0.0.0.0, overriding")
bindIP = "0.0.0.0"
}
addr := fmt.Sprintf("%s:%d", bindIP, rule.LocalPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
logger.Error("failed to listen locally", "error", err)
return
}
logger.Info("listening", slog.String("on", addr))
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("accept error", "error", err)
continue
}
go handleTCPConnect(ctx, srv, conn, rule, logger)
}
}
func handleTCPConnect(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ConnectRule, logger *slog.Logger) {
clog := logger.With(slog.String("local_client", conn.RemoteAddr().String()))
tsConn, err := srv.Dial(ctx, "tcp", rule.DstAddr)
if err != nil {
clog.Error("failed to dial tailscale", "error", err)
conn.Close()
return
}
stop := context.AfterFunc(ctx, func() {
conn.Close()
tsConn.Close()
})
defer stop()
clog.Info("accepted connection", slog.String("dst_addr", rule.DstAddr))
toConn, toTs := pipeConns(conn, tsConn)
clog.Info("connection closed", slog.Int64("ts_rx_bytes", toTs), slog.Int64("ts_tx_bytes", toConn))
}
func runUDPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, logger *slog.Logger) {
bindIP := rule.LocalAddr
if bindIP == "" {
bindIP = "0.0.0.0"
}
addr := fmt.Sprintf("%s:%d", bindIP, rule.LocalPort)
addrUDP, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
logger.Error("failed to resolve local addr", "error", err)
return
}
pc, err := net.ListenUDP("udp", addrUDP)
if err != nil {
logger.Error("failed to listen locally", "error", err)
return
}
logger.Info("listening", slog.String("on", addr))
relay := &udpRelay{
listenConn: pc,
dialAddr: rule.DstAddr,
logger: logger,
direction: "tailscale",
srv: srv,
sessions: make(map[string]*udpSession),
}
relay.run(ctx)
}
func pipeConns(a, b net.Conn) (toA, toB int64) {
done := make(chan struct{}, 2)
var aToB, bToA int64
go func() {
defer func() { done <- struct{}{} }()
n, err := io.Copy(a, b)
aToB = n
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
slog.Debug("pipe copy error", "direction", "b->a", "error", err)
}
if tc, ok := a.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
a.Close()
}
}()
go func() {
defer func() { done <- struct{}{} }()
n, err := io.Copy(b, a)
bToA = n
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
slog.Debug("pipe copy error", "direction", "a->b", "error", err)
}
if tc, ok := b.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
b.Close()
}
}()
<-done
<-done
return aToB, bToA
}
+7 -2
View File
@@ -75,7 +75,9 @@ func LanDiscoverService(ctx context.Context, entryList []LanEntry, logger *slog.
}
}
func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) {
// LanEntriesFromRules collects the advertisements implied by the connect
// rules. The GUI uses it to tell our own broadcasts apart from other servers'.
func LanEntriesFromRules(rules map[string][]ConnectRule) []LanEntry {
var lanEntries []LanEntry
for tag, rs := range rules {
for _, rule := range rs {
@@ -89,6 +91,9 @@ func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule,
})
}
}
return lanEntries
}
go LanDiscoverService(ctx, lanEntries, logger)
func RunLanDiscoverService(ctx context.Context, rules map[string][]ConnectRule, logger *slog.Logger) {
go LanDiscoverService(ctx, LanEntriesFromRules(rules), logger)
}
+523
View File
@@ -0,0 +1,523 @@
package core
import (
"context"
"fmt"
"log/slog"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// LogAttr is one flattened structured field. Groups are folded into the key
// with dots so the GUI can render a single flat line per entry.
type LogAttr struct {
Key string
Value string
}
// LogEntry is a single captured log record.
type LogEntry struct {
Seq uint64
Time time.Time
Level slog.Level
Msg string
Attrs []LogAttr
// Source is the value of the conventional "from" attribute, used by the
// GUI to group logs by subsystem.
Source string
}
// Text renders the entry the way the console handler would, minus colour.
func (e LogEntry) Text() string {
var b strings.Builder
b.WriteString(e.Time.Format("2006-01-02 15:04:05.000"))
b.WriteByte(' ')
b.WriteString(levelLabel(e.Level))
b.WriteByte(' ')
b.WriteString(e.Msg)
for _, a := range e.Attrs {
b.WriteByte(' ')
b.WriteString(a.Key)
b.WriteByte('=')
if strings.ContainsAny(a.Value, " \t\"") {
fmt.Fprintf(&b, "%q", a.Value)
} else {
b.WriteString(a.Value)
}
}
return b.String()
}
func levelLabel(l slog.Level) string {
switch {
case l < slog.LevelInfo:
return "DBG"
case l < slog.LevelWarn:
return "INF"
case l < slog.LevelError:
return "WRN"
default:
return "ERR"
}
}
// LevelLabel exposes the three-letter level name used in exports and the GUI.
func LevelLabel(l slog.Level) string { return levelLabel(l) }
// LogQuery filters a buffer snapshot.
type LogQuery struct {
// MinLevel drops anything below it.
MinLevel slog.Level
// Text is a case-insensitive substring matched against the message, the
// attribute values and the source.
Text string
// Source, when set, keeps only entries from that subsystem.
Source string
// Limit keeps only the newest N matches. Zero means unlimited.
Limit int
}
func (q LogQuery) match(e LogEntry) bool {
if e.Level < q.MinLevel {
return false
}
if q.Source != "" && e.Source != q.Source {
return false
}
if q.Text == "" {
return true
}
needle := strings.ToLower(q.Text)
if strings.Contains(strings.ToLower(e.Msg), needle) {
return true
}
if strings.Contains(strings.ToLower(e.Source), needle) {
return true
}
for _, a := range e.Attrs {
if strings.Contains(strings.ToLower(a.Key), needle) ||
strings.Contains(strings.ToLower(a.Value), needle) {
return true
}
}
return false
}
// LogBuffer is a fixed-capacity ring of the most recent log records. It is the
// single source of truth for the GUI's log view and for diagnostic exports.
//
// All methods are safe for concurrent use.
type LogBuffer struct {
mu sync.RWMutex
entries []LogEntry // ring storage, len == cap once full
start int // index of the oldest entry
count int
nextSeq uint64
dropped uint64
subs map[int]chan struct{}
nextSub int
sources map[string]int
levelCnt map[slog.Level]int
}
// DefaultLogCapacity is how many records the GUI keeps in memory. At roughly
// 200 bytes per record this is a few megabytes at most.
const DefaultLogCapacity = 20000
// NewLogBuffer returns a buffer holding at most capacity records.
func NewLogBuffer(capacity int) *LogBuffer {
if capacity <= 0 {
capacity = DefaultLogCapacity
}
return &LogBuffer{
entries: make([]LogEntry, capacity),
subs: make(map[int]chan struct{}),
sources: make(map[string]int),
levelCnt: make(map[slog.Level]int),
}
}
// Add appends an entry, evicting the oldest record when full.
func (b *LogBuffer) Add(e LogEntry) {
b.mu.Lock()
b.nextSeq++
e.Seq = b.nextSeq
capacity := len(b.entries)
if b.count == capacity {
evicted := b.entries[b.start]
b.decStatsLocked(evicted)
b.entries[b.start] = e
b.start = (b.start + 1) % capacity
b.dropped++
} else {
b.entries[(b.start+b.count)%capacity] = e
b.count++
}
b.incStatsLocked(e)
for _, ch := range b.subs {
select {
case ch <- struct{}{}:
default: // subscriber has a pending wakeup already
}
}
b.mu.Unlock()
}
func (b *LogBuffer) incStatsLocked(e LogEntry) {
b.levelCnt[e.Level]++
if e.Source != "" {
b.sources[e.Source]++
}
}
func (b *LogBuffer) decStatsLocked(e LogEntry) {
b.levelCnt[e.Level]--
if b.levelCnt[e.Level] <= 0 {
delete(b.levelCnt, e.Level)
}
if e.Source != "" {
b.sources[e.Source]--
if b.sources[e.Source] <= 0 {
delete(b.sources, e.Source)
}
}
}
// Len returns the number of buffered records.
func (b *LogBuffer) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.count
}
// Dropped returns how many records were evicted because the ring was full.
func (b *LogBuffer) Dropped() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.dropped
}
// LastSeq returns the sequence number of the most recent record.
func (b *LogBuffer) LastSeq() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.nextSeq
}
// Counts returns how many buffered records exist per level.
func (b *LogBuffer) Counts() map[slog.Level]int {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[slog.Level]int, len(b.levelCnt))
for k, v := range b.levelCnt {
out[k] = v
}
return out
}
// Sources returns the distinct subsystem names currently buffered, sorted.
func (b *LogBuffer) Sources() []string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]string, 0, len(b.sources))
for k := range b.sources {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Snapshot returns every buffered record, oldest first.
func (b *LogBuffer) Snapshot() []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
return b.collectLocked(func(LogEntry) bool { return true }, 0)
}
// Tail returns the newest n records, oldest first.
//
// It walks backwards from the newest record so the cost is O(n), not O(ring).
// The GUI's log overlay calls this on every frame; scanning a full 20k-entry
// ring each time was enough on its own to keep a core busy.
func (b *LogBuffer) Tail(n int) []LogEntry {
if n <= 0 {
return nil
}
b.mu.RLock()
defer b.mu.RUnlock()
return b.newestLocked(func(LogEntry) bool { return true }, n)
}
// Filter returns the records matching q, oldest first.
func (b *LogBuffer) Filter(q LogQuery) []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
if q.Limit > 0 {
return b.newestLocked(q.match, q.Limit)
}
return b.collectLocked(q.match, 0)
}
// newestLocked walks the ring newest-first, keeping at most limit matches, and
// returns them oldest-first.
func (b *LogBuffer) newestLocked(keep func(LogEntry) bool, limit int) []LogEntry {
capacity := len(b.entries)
out := make([]LogEntry, 0, min(limit, b.count))
for i := b.count - 1; i >= 0 && len(out) < limit; i-- {
e := b.entries[(b.start+i)%capacity]
if keep(e) {
out = append(out, e)
}
}
// Reverse in place to restore chronological order.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// collectLocked walks the ring oldest-first. When limit > 0 only the newest
// limit matches are kept.
func (b *LogBuffer) collectLocked(keep func(LogEntry) bool, limit int) []LogEntry {
capacity := len(b.entries)
out := make([]LogEntry, 0, min(b.count, 512))
for i := 0; i < b.count; i++ {
e := b.entries[(b.start+i)%capacity]
if keep(e) {
out = append(out, e)
}
}
if limit > 0 && len(out) > limit {
out = out[len(out)-limit:]
}
return out
}
// Subscribe returns a channel that receives a value whenever a record is
// added, plus a function that cancels the subscription. The channel is
// buffered and coalescing: a slow reader sees one wakeup, not a backlog.
func (b *LogBuffer) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
b.mu.Lock()
id := b.nextSub
b.nextSub++
b.subs[id] = ch
b.mu.Unlock()
var once sync.Once
cancel := func() {
once.Do(func() {
b.mu.Lock()
delete(b.subs, id)
b.mu.Unlock()
})
}
return ch, cancel
}
// ---------------------------------------------------------------------------
// slog handler
// ---------------------------------------------------------------------------
// bufHandler tees records into a LogBuffer and on to a wrapped handler.
type bufHandler struct {
buf *LogBuffer
next slog.Handler
attrs []LogAttr
groups []string
}
// Handler returns a slog.Handler that records everything into b and forwards
// to next. next may be nil, in which case records are only buffered.
//
// The buffer always captures at debug level regardless of what next filters,
// so the GUI can show detail the console suppressed.
func (b *LogBuffer) Handler(next slog.Handler) slog.Handler {
return &bufHandler{buf: b, next: next}
}
func (h *bufHandler) Enabled(ctx context.Context, l slog.Level) bool {
// Always capture: the buffer is the diagnostic record of last resort.
return true
}
func (h *bufHandler) Handle(ctx context.Context, r slog.Record) error {
attrs := make([]LogAttr, 0, len(h.attrs)+r.NumAttrs())
attrs = append(attrs, h.attrs...)
r.Attrs(func(a slog.Attr) bool {
attrs = appendAttr(attrs, h.groups, a)
return true
})
source := ""
for _, a := range attrs {
if a.Key == "from" {
source = a.Value
}
}
t := r.Time
if t.IsZero() {
t = time.Now()
}
h.buf.Add(LogEntry{
Time: t,
Level: r.Level,
Msg: r.Message,
Attrs: attrs,
Source: source,
})
if h.next != nil && h.next.Enabled(ctx, r.Level) {
return h.next.Handle(ctx, r)
}
return nil
}
func (h *bufHandler) WithAttrs(as []slog.Attr) slog.Handler {
if len(as) == 0 {
return h
}
clone := *h
clone.attrs = make([]LogAttr, len(h.attrs), len(h.attrs)+len(as))
copy(clone.attrs, h.attrs)
for _, a := range as {
clone.attrs = appendAttr(clone.attrs, h.groups, a)
}
if h.next != nil {
clone.next = h.next.WithAttrs(as)
}
return &clone
}
func (h *bufHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
clone := *h
clone.groups = append(append([]string(nil), h.groups...), name)
if h.next != nil {
clone.next = h.next.WithGroup(name)
}
return &clone
}
// appendAttr flattens a slog.Attr, expanding groups into dotted keys.
func appendAttr(dst []LogAttr, groups []string, a slog.Attr) []LogAttr {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return dst
}
if a.Value.Kind() == slog.KindGroup {
sub := a.Value.Group()
if len(sub) == 0 {
return dst
}
nested := groups
if a.Key != "" {
nested = append(append([]string(nil), groups...), a.Key)
}
for _, s := range sub {
dst = appendAttr(dst, nested, s)
}
return dst
}
key := a.Key
if len(groups) > 0 {
key = strings.Join(groups, ".") + "." + key
}
return append(dst, LogAttr{Key: key, Value: a.Value.String()})
}
// NewLoggerWithBuffer builds the console logger exactly as [NewLogger] does
// and tees every record into buf.
func NewLoggerWithBuffer(level string, useJsonFormat bool, buf *LogBuffer) *slog.Logger {
base := NewLogger(level, useJsonFormat)
logger := slog.New(buf.Handler(base.Handler()))
slog.SetDefault(logger)
return logger
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
// secretPattern matches Tailscale auth keys and OAuth client secrets, which
// are the one thing in these logs that must never reach a paste service.
var secretPattern = regexp.MustCompile(`\b(tskey-[a-zA-Z]+-)[A-Za-z0-9\-_]{6,}`)
// secretKeys are attribute names whose values are replaced wholesale.
var secretKeys = map[string]bool{
"auth_key": true,
"authkey": true,
"auth-key": true,
"token": true,
"secret": true,
"password": true,
"client_secret": true,
}
// Redact removes credentials from a single string.
func Redact(s string) string {
return secretPattern.ReplaceAllString(s, "${1}REDACTED")
}
func redactAttr(a LogAttr) LogAttr {
if secretKeys[strings.ToLower(a.Key)] {
if a.Value == "" {
return a
}
return LogAttr{Key: a.Key, Value: "[REDACTED]"}
}
a.Value = Redact(a.Value)
return a
}
// ExportOptions controls how a log dump is rendered.
type ExportOptions struct {
Query LogQuery
// Redact strips credentials. Callers sharing logs publicly must leave this
// on; it defaults to on because [ExportText] is built for sharing.
NoRedact bool
// Header is prepended verbatim, used for environment metadata.
Header string
}
// ExportText renders matching entries as a plain-text report suitable for
// pasting into an issue tracker or a paste service.
func (b *LogBuffer) ExportText(opt ExportOptions) string {
entries := b.Filter(opt.Query)
var sb strings.Builder
if opt.Header != "" {
sb.WriteString(opt.Header)
if !strings.HasSuffix(opt.Header, "\n") {
sb.WriteByte('\n')
}
sb.WriteString("\n")
}
if dropped := b.Dropped(); dropped > 0 {
fmt.Fprintf(&sb, "# %d earlier record(s) were dropped from the ring buffer\n\n", dropped)
}
for _, e := range entries {
if !opt.NoRedact {
e.Msg = Redact(e.Msg)
redacted := make([]LogAttr, len(e.Attrs))
for i, a := range e.Attrs {
redacted[i] = redactAttr(a)
}
e.Attrs = redacted
}
sb.WriteString(e.Text())
sb.WriteByte('\n')
}
if len(entries) == 0 {
sb.WriteString("(no matching log entries)\n")
}
return sb.String()
}
+82
View File
@@ -0,0 +1,82 @@
package core
import (
"context"
"fmt"
"net"
"net/netip"
"sync"
"time"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tsnet"
)
var statusCache struct {
mu sync.Mutex
status *ipnstate.Status
expires time.Time
}
const statusCacheTTL = 5 * time.Second
func getCachedStatus(ctx context.Context, srv *tsnet.Server) (*ipnstate.Status, error) {
statusCache.mu.Lock()
if statusCache.status != nil && time.Now().Before(statusCache.expires) {
st := statusCache.status
statusCache.mu.Unlock()
return st, nil
}
statusCache.mu.Unlock()
lc, err := srv.LocalClient()
if err != nil {
return nil, err
}
st, err := lc.Status(ctx)
if err != nil {
return nil, err
}
statusCache.mu.Lock()
statusCache.status = st
statusCache.expires = time.Now().Add(statusCacheTTL)
statusCache.mu.Unlock()
return st, nil
}
func isTsnetTarget(host string) bool {
if ip, err := netip.ParseAddr(host); err == nil {
tsnetV4 := netip.MustParsePrefix("100.64.0.0/10")
tsnetV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
return tsnetV4.Contains(ip) || tsnetV6.Contains(ip)
}
return true
}
func getConnType(ctx context.Context, srv *tsnet.Server, remoteAddrStr string) string {
st, err := getCachedStatus(ctx, srv)
if err != nil {
return "unknown"
}
remoteHost, _, err := net.SplitHostPort(remoteAddrStr)
if err != nil {
return "unknown"
}
for _, peer := range st.Peer {
for _, addr := range peer.TailscaleIPs {
if addr.String() == remoteHost {
if peer.CurAddr != "" {
return "direct"
}
if peer.Relay != "" {
return fmt.Sprintf("derp(%s)", peer.Relay)
}
return "direct"
}
}
}
return "unknown"
}
+867
View File
@@ -0,0 +1,867 @@
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{}
refreshLinks 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),
refreshLinks: 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.
//
// Link resolution is kicked too. It normally runs every linkResolveInterval,
// but the GUI now lists only linked peers, so a user staring at an empty page
// after a DNS hiccup has no other way to ask for a retry.
func (m *PeerMonitor) RefreshNow() {
kick(m.refreshStatus)
kick(m.refreshPing)
kick(m.refreshLinks)
}
// kick delivers a coalescing wakeup: a pending signal is enough.
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)
case <-m.refreshLinks:
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
}
+45
View File
@@ -0,0 +1,45 @@
package core
import (
"errors"
"io"
"log/slog"
"net"
)
func pipeConns(a, b net.Conn) (toA, toB int64) {
done := make(chan struct{}, 2)
var aToB, bToA int64
go func() {
defer func() { done <- struct{}{} }()
n, err := io.Copy(a, b)
aToB = n
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
slog.Debug("pipe copy error", "direction", "b->a", "error", err)
}
if tc, ok := a.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
a.Close()
}
}()
go func() {
defer func() { done <- struct{}{} }()
n, err := io.Copy(b, a)
bToA = n
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
slog.Debug("pipe copy error", "direction", "a->b", "error", err)
}
if tc, ok := b.(*net.TCPConn); ok {
tc.CloseWrite()
} else {
b.Close()
}
}()
<-done
<-done
return aToB, bToA
}
+462
View File
@@ -0,0 +1,462 @@
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
}
// 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)
s.update(func(st *State) {
st.Peers = peers
})
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.mu.Unlock()
if srv != nil {
if err := srv.Close(); err != nil {
s.logger.Debug("closing tsnet server", "err", err)
}
}
s.notify()
}
+134
View File
@@ -0,0 +1,134 @@
package core
import (
"context"
"fmt"
"log/slog"
"net"
"tailscale.com/tsnet"
)
func runTCPForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, logger *slog.Logger) {
ip := getSelfTsnetAddr(srv)
ln, err := srv.Listen("tcp", fmt.Sprintf("%s:%d", ip.String(), rule.TailscalePort))
if err != nil {
logger.Error("failed to listen", "error", err)
return
}
logger.Debug("listening", slog.String("on", fmt.Sprintf("tailscale:%s:%d", ip.String(), rule.TailscalePort)))
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("accept error", "error", err)
continue
}
go handleTCPForward(ctx, srv, conn, rule, logger)
}
}
func handleTCPForward(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ForwardRule, logger *slog.Logger) {
remoteAddrStr := conn.RemoteAddr().String()
clog := logger.With(slog.String("remote", remoteAddrStr))
lc, err := srv.LocalClient()
if err == nil {
who, err := lc.WhoIs(ctx, remoteAddrStr)
if err == nil {
clog = clog.With(slog.String("user", who.UserProfile.LoginName))
}
}
connType := getConnType(ctx, srv, remoteAddrStr)
clog.Info("accepted connection",
slog.String("conn_type", connType),
slog.String("local_addr", rule.LocalAddr),
)
localConn, err := dialTCP(ctx, rule.LocalAddr)
if err != nil {
clog.Error("failed to dial local", "error", err)
conn.Close()
return
}
stop := context.AfterFunc(ctx, func() {
conn.Close()
localConn.Close()
})
defer stop()
toLocal, toTs := pipeConns(conn, localConn)
clog.Info("connection closed", slog.Int64("ts_rx_bytes", toLocal), slog.Int64("ts_tx_bytes", toTs))
}
func runTCPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, logger *slog.Logger) {
bindIP := rule.BindIP()
if rule.LANEnabled() && rule.LocalAddr != "" && rule.LocalAddr != "0.0.0.0" {
logger.Warn("lan_enable forces local_addr to 0.0.0.0, overriding")
}
addr := fmt.Sprintf("%s:%d", bindIP, rule.LocalPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
logger.Error("failed to listen locally", "error", err)
return
}
logger.Debug("listening", slog.String("on", addr))
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("accept error", "error", err)
continue
}
go handleTCPConnect(ctx, srv, conn, rule, logger)
}
}
func handleTCPConnect(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ConnectRule, logger *slog.Logger) {
clog := logger.With(slog.String("local_client", conn.RemoteAddr().String()))
// Resolve MagicDNS / split-DNS names through the tailnet resolver before
// dialing; tsnet's own Dial cannot resolve custom split-DNS suffixes.
dstAddr := rule.DstAddr
if resolved, rerr := resolveDialAddr(ctx, srv, rule.DstAddr); rerr != nil {
clog.Debug("failed to resolve dst via tailnet dns, dialing name directly",
slog.String("dst", rule.DstAddr), slog.String("error", rerr.Error()))
} else {
dstAddr = resolved
}
tsConn, err := dialTsnet(ctx, srv, "tcp", dstAddr)
if err != nil {
clog.Error("failed to dial tailscale", "error", err)
conn.Close()
return
}
stop := context.AfterFunc(ctx, func() {
conn.Close()
tsConn.Close()
})
defer stop()
clog.Info("accepted connection", slog.String("dst_addr", rule.DstAddr), slog.String("resolved", dstAddr))
toConn, toTs := pipeConns(conn, tsConn)
clog.Info("connection closed", slog.Int64("ts_rx_bytes", toTs), slog.Int64("ts_tx_bytes", toConn))
}
+225
View File
@@ -0,0 +1,225 @@
package core
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"time"
"tailscale.com/net/netcheck"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tslink/netdiag"
)
// TsDiagSource adapts a running tsnet server to [netdiag.TailscaleSource].
//
// The diagnostics package probes the network from scratch; this asks tailscale
// what it already believes. The two disagreeing is itself informative — for
// DERP latency and tailscale's own UPnP/PMP/PCP probe, tailscale's answer is
// the one that governs how the tunnel will actually behave.
type TsDiagSource struct {
srv *tsnet.Server
logger *slog.Logger
}
// NewTailscaleSource wraps srv. A nil logger falls back to slog.Default.
func NewTailscaleSource(srv *tsnet.Server, logger *slog.Logger) *TsDiagSource {
if logger == nil {
logger = slog.Default()
}
return &TsDiagSource{srv: srv, logger: logger}
}
// DefaultTailscaleSource returns a source for srv, or a nil interface when srv
// is nil, so callers can pass the result straight into netdiag.Options without
// tripping over a typed-nil interface.
func DefaultTailscaleSource(srv *tsnet.Server, logger *slog.Logger) netdiag.TailscaleSource {
if srv == nil {
return nil
}
return NewTailscaleSource(srv, logger)
}
// netcheckTimeout bounds one report. netcheck's own full run probes every DERP
// region, which takes a while on a slow link.
const netcheckTimeout = 15 * time.Second
// Netcheck runs tailscale's own network check and translates the result.
func (s *TsDiagSource) Netcheck(ctx context.Context) (rep *netdiag.TailscaleReport, err error) {
if s == nil || s.srv == nil {
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Summary: "Tailscale 未运行",
}, errors.New("tsnet server is nil")
}
lc, err := s.srv.LocalClient()
if err != nil {
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Err: err.Error(),
}, err
}
dm, err := lc.CurrentDERPMap(ctx)
if err != nil || dm == nil {
if err == nil {
err = errors.New("no DERP map available")
}
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Err: err.Error(),
Summary: "无法获取 DERP 列表,跳过 Tailscale 内部检查",
}, err
}
// A static monitor takes a one-shot snapshot of the interfaces without
// spawning the change-watching goroutines a long-lived Monitor would. That
// is what we want for a single report, and Close on a static monitor is a
// no-op.
mon := netmon.NewStatic()
client := &netcheck.Client{
NetMon: mon,
Logf: func(format string, args ...any) {
s.logger.With(slog.String("from", "netcheck")).
Debug(fmt.Sprintf(format, args...))
},
}
runCtx, cancel := context.WithTimeout(ctx, netcheckTimeout)
defer cancel()
// GetReport reaches into internal magicsock machinery; a panic there must
// degrade this one panel, not take the window down.
var raw *netcheck.Report
func() {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("netcheck panicked: %v", r)
}
}()
raw, err = client.GetReport(runCtx, dm, &netcheck.GetReportOpts{})
}()
if err != nil || raw == nil {
if err == nil {
err = errors.New("netcheck returned no report")
}
return &netdiag.TailscaleReport{
Status: netdiag.StatusSkipped,
Err: err.Error(),
}, err
}
return convertNetcheck(raw, dm), nil
}
// convertNetcheck maps tailscale's report onto the diagnostics contract.
func convertNetcheck(raw *netcheck.Report, dm *tailcfg.DERPMap) *netdiag.TailscaleReport {
out := &netdiag.TailscaleReport{
Available: true,
UDP: raw.UDP,
IPv4: raw.IPv4,
IPv6: raw.IPv6,
ICMPv4: raw.ICMPv4,
OSHasIPv6: raw.OSHasIPv6,
MappingVariesByDestIP: optBool(raw.MappingVariesByDestIP.Get()),
UPnP: optBool(raw.UPnP.Get()),
PMP: optBool(raw.PMP.Get()),
PCP: optBool(raw.PCP.Get()),
CaptivePortal: optBool(raw.CaptivePortal.Get()),
}
if raw.GlobalV4.IsValid() {
out.GlobalV4 = raw.GlobalV4.String()
}
if raw.GlobalV6.IsValid() {
out.GlobalV6 = raw.GlobalV6.String()
}
for id, latency := range raw.RegionLatency {
entry := netdiag.DERPLatency{
RegionID: id,
Latency: latency,
Preferred: id == raw.PreferredDERP,
}
if dm != nil {
if region, ok := dm.Regions[id]; ok && region != nil {
entry.RegionCode = region.RegionCode
entry.Name = region.RegionName
}
}
if entry.RegionCode == "" {
entry.RegionCode = strconv.Itoa(id)
}
if entry.Name == "" {
entry.Name = entry.RegionCode
}
if entry.Preferred {
out.PreferredDERP = entry.RegionCode
}
out.DERP = append(out.DERP, entry)
}
sort.Slice(out.DERP, func(i, j int) bool {
if out.DERP[i].Latency != out.DERP[j].Latency {
return out.DERP[i].Latency < out.DERP[j].Latency
}
return out.DERP[i].RegionID < out.DERP[j].RegionID
})
if out.PreferredDERP == "" && raw.PreferredDERP != 0 {
out.PreferredDERP = strconv.Itoa(raw.PreferredDERP)
}
out.Status, out.Summary = netcheckVerdict(out)
return out
}
// netcheckVerdict grades the report from the perspective of whether tailscale
// can carry traffic well, not whether every box is ticked.
func netcheckVerdict(r *netdiag.TailscaleReport) (netdiag.Status, string) {
switch {
case !r.UDP:
return netdiag.StatusFail,
"Tailscale 无法通过 UDP 与 DERP 通信,连接将非常不稳定"
case r.CaptivePortal != nil && *r.CaptivePortal:
return netdiag.StatusWarn,
"检测到门户劫持(Captive Portal),需要先在浏览器完成网络认证"
case len(r.DERP) == 0:
return netdiag.StatusWarn,
"没有任何 DERP 节点响应,中继回退可能不可用"
}
best := r.DERP[0]
summary := fmt.Sprintf("首选 DERP %s,延迟 %dms",
nonEmpty(r.PreferredDERP, best.RegionCode),
best.Latency.Milliseconds())
if r.MappingVariesByDestIP != nil && *r.MappingVariesByDestIP {
return netdiag.StatusWarn,
summary + ";NAT 映射随目标变化(对称型),直连打洞成功率低"
}
return netdiag.StatusOK, summary
}
func nonEmpty(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
// optBool converts tailscale's opt.Bool (value, ok) pair into a tri-state
// pointer: nil means tailscale could not determine the answer, which is
// different from determining "no".
func optBool(v, ok bool) *bool {
if !ok {
return nil
}
out := v
return &out
}
+8
View File
@@ -57,6 +57,14 @@ func InitTsNet(ctx context.Context, cfg *Core, logger *slog.Logger, withDebugLog
logger.With(slog.String("ip", ip.String())).Info("ip got from tsnet")
}
rawSuffix, err := GetMagicDNSSuffixFromStatus(status)
if err != nil {
logger.Debug("failed to extract MagicDNS suffix", slog.String("error", err.Error()))
} else {
SetMagicDNSSuffix(rawSuffix)
logger.Info("MagicDNS suffix extracted", slog.String("suffix", rawSuffix))
}
if cfg.AcceptRoutes {
lc, err := srv.LocalClient()
if err != nil {
+170
View File
@@ -0,0 +1,170 @@
package core
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"sync"
"time"
"tailscale.com/tsnet"
)
const udpForwardIdleTimeout = 2 * time.Minute
func runUDPForwarder(ctx context.Context, srv *tsnet.Server, rule ForwardRule, logger *slog.Logger) {
ip := getSelfTsnetAddr(srv)
ln, err := srv.Listen("udp", fmt.Sprintf("%s:%d", ip.String(), rule.TailscalePort))
if err != nil {
logger.Error("failed to listen", "error", err)
return
}
logger.Debug("listening", slog.String("on", fmt.Sprintf("tailscale:%s:%d", ip.String(), rule.TailscalePort)))
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("accept error", "error", err)
continue
}
go handleUDPForward(ctx, srv, conn, rule, logger)
}
}
func handleUDPForward(ctx context.Context, srv *tsnet.Server, conn net.Conn, rule ForwardRule, logger *slog.Logger) {
remoteAddrStr := conn.RemoteAddr().String()
clog := logger.With(slog.String("remote", remoteAddrStr))
lc, err := srv.LocalClient()
if err == nil {
who, err := lc.WhoIs(ctx, remoteAddrStr)
if err == nil {
clog = clog.With(slog.String("user", who.UserProfile.LoginName))
}
}
connType := getConnType(ctx, srv, remoteAddrStr)
clog.Info("accepted connection",
slog.String("conn_type", connType),
slog.String("local_addr", rule.LocalAddr),
)
localConn, err := dialUDP(ctx, rule.LocalAddr)
if err != nil {
clog.Error("failed to dial local", "error", err)
conn.Close()
return
}
stop := context.AfterFunc(ctx, func() {
conn.Close()
localConn.Close()
})
defer stop()
remoteIP, _, _ := net.SplitHostPort(remoteAddrStr)
var toTs, toLocal int64
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
buf := make([]byte, 65535)
for {
_ = conn.SetReadDeadline(time.Now().Add(udpForwardIdleTimeout))
n, err := conn.Read(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
clog.Debug("udp forward idle timeout on ts side")
} else if !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.EOF) {
clog.Debug("udp forward read from ts", "error", err)
}
localConn.Close()
return
}
_ = conn.SetReadDeadline(time.Time{})
toLocal += int64(n)
clog.Debug("inbound udp packet",
slog.String("from_ip", remoteIP),
slog.String("to_ip", rule.LocalAddr),
slog.Int("pkg_size", n),
)
if _, err := localConn.Write(buf[:n]); err != nil {
conn.Close()
return
}
}
}()
go func() {
defer wg.Done()
buf := make([]byte, 65535)
for {
_ = localConn.SetReadDeadline(time.Now().Add(udpForwardIdleTimeout))
n, err := localConn.Read(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
clog.Debug("udp forward idle timeout on local side")
} else if !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.EOF) {
clog.Debug("udp forward read from local", "error", err)
}
conn.Close()
return
}
_ = localConn.SetReadDeadline(time.Time{})
toTs += int64(n)
localIP, _, _ := net.SplitHostPort(localConn.RemoteAddr().String())
clog.Debug("outbound udp packet",
slog.String("from_ip", localIP),
slog.String("to_ip", remoteIP),
slog.Int("pkg_size", n),
)
if _, err := conn.Write(buf[:n]); err != nil {
localConn.Close()
return
}
}
}()
wg.Wait()
clog.Info("connection closed", slog.Int64("ts_rx_bytes", toLocal), slog.Int64("ts_tx_bytes", toTs))
}
func runUDPConnector(ctx context.Context, srv *tsnet.Server, rule ConnectRule, logger *slog.Logger) {
bindIP := rule.BindIP()
addr := fmt.Sprintf("%s:%d", bindIP, rule.LocalPort)
addrUDP, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
logger.Error("failed to resolve local addr", "error", err)
return
}
pc, err := net.ListenUDP("udp", addrUDP)
if err != nil {
logger.Error("failed to listen locally", "error", err)
return
}
logger.Debug("listening", slog.String("on", addr))
relay := &udpRelay{
listenConn: pc,
dialAddr: rule.DstAddr,
logger: logger,
direction: "tailscale",
srv: srv,
sessions: make(map[string]*udpSession),
}
relay.run(ctx)
}
+204
View File
@@ -0,0 +1,204 @@
package core
import (
"context"
"log/slog"
"net"
"sync"
"time"
"tailscale.com/tsnet"
)
const udpRelayMaxSessions = 1024
type udpSession struct {
conn net.Conn
remote net.Addr
mu sync.Mutex
lastUse time.Time
}
func (s *udpSession) touch() {
s.mu.Lock()
s.lastUse = time.Now()
s.mu.Unlock()
}
func (s *udpSession) idleSince(threshold time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastUse.Before(threshold)
}
type udpRelay struct {
listenConn net.PacketConn
dialAddr string
logger *slog.Logger
direction string
srv *tsnet.Server
mu sync.Mutex
sessions map[string]*udpSession
}
func (r *udpRelay) run(ctx context.Context) {
go func() {
<-ctx.Done()
r.listenConn.Close()
}()
go func() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.cleanup()
}
}
}()
buf := make([]byte, 65535)
for {
select {
case <-ctx.Done():
return
default:
}
n, from, err := r.listenConn.ReadFrom(buf)
if err != nil {
if ctx.Err() != nil {
return
}
r.logger.Error("udp read error", "error", err)
return
}
key := from.String()
var toIP string
r.mu.Lock()
sess, exists := r.sessions[key]
if !exists {
if len(r.sessions) >= udpRelayMaxSessions {
r.mu.Unlock()
r.logger.Warn("udp relay session limit reached, dropping packet",
slog.Int("limit", udpRelayMaxSessions),
slog.String("remote", key),
)
continue
}
host, _, err := net.SplitHostPort(r.dialAddr)
if err != nil {
r.mu.Unlock()
r.logger.Error("failed to parse dial addr", "error", err)
continue
}
inTsnet := isTsnetTarget(host)
r.mu.Unlock()
var dialed net.Conn
if inTsnet {
// Resolve MagicDNS / split-DNS names through the tailnet
// resolver before dialing; tsnet's own Dial cannot resolve
// custom split-DNS suffixes.
dialAddr := r.dialAddr
if resolved, rerr := resolveDialAddr(ctx, r.srv, r.dialAddr); rerr != nil {
r.logger.Debug("failed to resolve dst via tailnet dns, dialing name directly",
slog.String("dst", r.dialAddr), slog.String("error", rerr.Error()))
} else {
dialAddr = resolved
}
dialed, err = dialTsnet(ctx, r.srv, "udp", dialAddr)
} else {
dialed, err = dialUDP(ctx, r.dialAddr)
}
if err != nil {
r.logger.Error("failed to dial", "error", err)
continue
}
sess = &udpSession{conn: dialed, remote: from, lastUse: time.Now()}
r.mu.Lock()
if existing, dup := r.sessions[key]; dup {
dialed.Close()
sess = existing
sess.touch()
} else {
r.sessions[key] = sess
}
toIP = sess.conn.RemoteAddr().String()
r.mu.Unlock()
r.logger.Info("new udp session", slog.String("remote", key), slog.String("direction", r.direction))
go r.readSession(key, sess)
} else {
sess.touch()
toIP = sess.conn.RemoteAddr().String()
r.mu.Unlock()
}
fromIP, _, _ := net.SplitHostPort(from.String())
toIPHost, _, _ := net.SplitHostPort(toIP)
r.logger.Debug("outbound udp packet",
slog.String("from_ip", fromIP),
slog.String("to_ip", toIPHost),
slog.Int("pkg_size", n),
)
if _, err := sess.conn.Write(buf[:n]); err != nil {
r.logger.Error("failed to write", "error", err)
r.removeSession(key)
}
}
}
func (r *udpRelay) readSession(key string, sess *udpSession) {
buf := make([]byte, 65535)
for {
n, err := sess.conn.Read(buf)
if err != nil {
r.removeSession(key)
return
}
fromIP, _, _ := net.SplitHostPort(sess.conn.RemoteAddr().String())
toIP, _, _ := net.SplitHostPort(sess.remote.String())
r.logger.Info("udp packet",
slog.String("from_ip", fromIP),
slog.String("to_ip", toIP),
slog.Int("pkg_size", n),
)
if _, err := r.listenConn.WriteTo(buf[:n], sess.remote); err != nil {
r.logger.Error("failed to write back", "error", err)
r.removeSession(key)
return
}
sess.touch()
}
}
func (r *udpRelay) removeSession(key string) {
r.mu.Lock()
defer r.mu.Unlock()
if s, ok := r.sessions[key]; ok {
remote := s.remote.String()
s.conn.Close()
delete(r.sessions, key)
r.logger.Debug("udp session closed", slog.String("remote", remote))
}
}
func (r *udpRelay) cleanup() {
r.mu.Lock()
defer r.mu.Unlock()
threshold := time.Now().Add(-5 * time.Minute)
for key, s := range r.sessions {
if s.idleSince(threshold) {
remote := s.remote.String()
s.conn.Close()
delete(r.sessions, key)
r.logger.Debug("udp session cleaned up", slog.String("remote", remote))
}
}
}
+101 -132
View File
@@ -10,10 +10,8 @@ import (
"strings"
"time"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/client/local"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/dns/resolver"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
)
@@ -47,48 +45,46 @@ func StartTimeWatchDog(ctx context.Context, logger *slog.Logger) <-chan struct{}
return ch
}
func resolveAddr(ctx context.Context, srv *tsnet.Server, addr string) (*netip.Addr, error) {
lc, err := srv.LocalClient()
if err != nil {
return nil, err
}
stat, err := lc.Status(ctx)
if err != nil {
return nil, err
}
if ip, err := netip.ParseAddr(addr); err == nil {
for _, peer := range stat.Peer {
for _, ipRange := range peer.AllowedIPs.All() {
if ipRange.Contains(ip) {
return &peer.TailscaleIPs[0], nil
}
}
}
} else {
// addr is domain, resolve it
for _, peer := range stat.Peer {
dnsName := strings.TrimSuffix(peer.DNSName, ".")
if dnsName == addr {
return &peer.TailscaleIPs[0], nil
}
}
}
return nil, errors.New(fmt.Sprintf("addr '%s' not found in tsnet", addr))
}
func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger) ([]netip.Addr, error) {
// getPeerFromRules maps every connect rule's destination onto the tailnet peer
// that carries it. Alongside the peers it reports how many rules could not be
// resolved at all; those are retryable, unlike destinations that resolve to an
// address outside the tailnet (an ordinary public host), which are skipped for
// good. warn selects whether unresolved rules are logged as warnings — during
// startup the tailnet resolver may not have its split-DNS routes yet, so the
// first few rounds stay quiet.
func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger, warn bool) ([]netip.Addr, int) {
peerSet := make(map[netip.Addr]struct{})
unresolved := 0
for _, rrs := range rules {
for tag, rrs := range rules {
for _, rule := range rrs {
rule := rule
ap, err := netip.ParseAddrPort(rule.DstAddr)
tag := tag
ap, _, err := net.SplitHostPort(rule.DstAddr)
if err != nil {
logger.Debug("error parsing rule", "tag", tag, "dst", rule.DstAddr, "err", err)
continue
}
peerSet[ap.Addr()] = struct{}{}
addr, err := resolveAddr(ctx, srv, ap)
if err != nil {
if errors.Is(err, errNotTailnetPeer) {
logger.Debug("destination is outside the tailnet, skipping diagnostics",
"tag", tag, "dst", rule.DstAddr, "err", err)
continue
}
unresolved++
if warn {
logger.Warn("failed to resolve address", "tag", tag, "dst", rule.DstAddr, "err", err)
} else {
logger.Debug("failed to resolve address (tailnet DNS may still be settling)",
"tag", tag, "dst", rule.DstAddr, "err", err)
}
continue
}
logger.Debug("address found", "dst_addr", rule.DstAddr, "tag", tag, "address", addr)
peerSet[*addr] = struct{}{}
}
}
@@ -96,7 +92,7 @@ func getPeerFromRules(ctx context.Context, srv *tsnet.Server, rules map[string][
for peer := range peerSet {
result = append(result, peer)
}
return result, nil
return result, unresolved
}
func peerConnectivityLogic(ctx context.Context, lc *local.Client, relativePeers []netip.Addr, logger *slog.Logger) {
@@ -140,16 +136,19 @@ func peerConnectivityLogic(ctx context.Context, lc *local.Client, relativePeers
}
}
func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger, srv *tsnet.Server, rules map[string][]ConnectRule) {
relativePeers, err := getPeerFromRules(ctx, srv, rules, logger)
if err != nil {
return
}
logger.Debug("Peers loaded", "count", len(relativePeers))
const (
// peerDiagInterval is how often connectivity to each peer is re-checked.
peerDiagInterval = 120 * time.Second
// A tsnet server reports Running before the netmap's DNS configuration has
// been programmed into its resolver, and accept-routes is only applied once
// the server is up — so at startup a split-DNS destination can briefly fail
// to resolve even though it resolves fine moments later. Retry a handful of
// times before reporting anything as broken.
peerDiagWarmupTries = 6
peerDiagWarmupDelay = 2 * time.Second
)
if len(relativePeers) == 0 {
return
}
func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger, srv *tsnet.Server, rules map[string][]ConnectRule) {
go func() {
lc, err := srv.LocalClient()
if err != nil {
@@ -157,18 +156,42 @@ func StartPeerConnectivityDiagnostics(ctx context.Context, logger *slog.Logger,
return
}
ticker := time.NewTicker(120 * time.Second)
// Warm-up: keep retrying while destinations are still unresolvable, and
// only escalate to a warning on the final attempt.
var peers []netip.Addr
for try := 1; ; try++ {
last := try >= peerDiagWarmupTries
var unresolved int
peers, unresolved = getPeerFromRules(ctx, srv, rules, logger, last)
if unresolved == 0 || last {
break
}
logger.Debug("waiting for tailnet DNS before diagnosing peers",
"unresolved", unresolved, "attempt", try)
select {
case <-ctx.Done():
return
case <-time.After(peerDiagWarmupDelay):
}
}
logger.Debug("Peers loaded", "count", len(peers))
ticker := time.NewTicker(peerDiagInterval)
defer ticker.Stop()
peerConnectivityLogic(ctx, lc, relativePeers, logger) // execute now
for {
peerConnectivityLogic(ctx, lc, peers, logger)
select {
case <-ctx.Done():
return
case <-ticker.C:
peerConnectivityLogic(ctx, lc, relativePeers, logger)
}
// Re-resolve every round: destinations that failed at startup
// recover on their own, and split-DNS records may point elsewhere
// than they did two minutes ago.
peers, _ = getPeerFromRules(ctx, srv, rules, logger, true)
}
}()
}
@@ -182,7 +205,7 @@ func getSelfTsnetAddr(srv *tsnet.Server) netip.Addr {
return ip
}
func PresolveDstAddrWithSuffix(dst string, srv *tsnet.Server) (string, bool, error) {
func NormalizeDstAddrWithSuffix(ctx context.Context, srv *tsnet.Server, dst string) (string, bool, error) {
host, port, err := net.SplitHostPort(dst)
if err != nil {
return dst, false, err
@@ -192,98 +215,44 @@ func PresolveDstAddrWithSuffix(dst string, srv *tsnet.Server) (string, bool, err
return dst, false, nil
}
dnsMgr, ok := srv.Sys().DNSManager.GetOK()
suffix, ok := GetMagicDNSSuffix()
if !ok {
return dst, false, errors.New("DNS manager not available")
return dst, false, nil
}
addr, err := resolveHostViaResolver(dnsMgr.Resolver(), host)
qualified := host + "." + suffix
normalized := net.JoinHostPort(qualified, port)
// check domain exists before use. resolveAddr takes a bare host — passing
// the "host:port" form made every lookup here fail on the stray colon.
if strings.Contains(host, ".") {
_, err = resolveAddr(ctx, srv, qualified)
if err != nil {
// tsnet magicdns failed; fall back to system DNS for non-tailnet domains
addr, err = fallbackSystemDNS(host)
if err != nil {
return dst, false, err
return dst, false, nil
}
}
return net.JoinHostPort(addr.String(), port), true, nil
return normalized, true, nil
}
// fallbackSystemDNS resolves a hostname via the standard system resolver.
// Returns the first usable IPv4 address (preferred) or IPv6 address.
func fallbackSystemDNS(host string) (netip.Addr, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// normalizeDNSBudget caps how long the whole normalization pass may spend
// waiting on DNS. It runs before the connectors start listening, and on a cold
// start the tailnet resolver needs a few seconds before it answers — without a
// bound the listeners would not come up until then. A name that cannot be
// checked in time simply keeps its configured form, which is the same
// conclusion the check reaches for anything that is not a MagicDNS name.
const normalizeDNSBudget = 2 * time.Second
func NormalizeConnectRulesDstAddr(ctx context.Context, srv *tsnet.Server, rules map[string][]ConnectRule, logger *slog.Logger) {
ctx, cancel := context.WithTimeout(ctx, normalizeDNSBudget)
defer cancel()
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return netip.Addr{}, fmt.Errorf("system DNS resolution failed for %s: %w", host, err)
}
for _, ip := range ips {
if ip.Is4() {
return ip, nil
}
}
// no IPv4 found, pick the first IPv6
for _, ip := range ips {
if ip.Is6() {
return ip, nil
}
}
return netip.Addr{}, fmt.Errorf("no valid IPs returned for %s", host)
}
// resolveHostViaResolver resolves a hostname to a netip.Addr using the
// Tailscale DNS resolver. It queries A and AAAA records in a single
// message and follows CNAME chains (up to 8 levels deep).
func resolveHostViaResolver(resolver *resolver.Resolver, host string) (netip.Addr, error) {
name, err := dnsmessage.NewName(host + ".")
if err != nil {
return netip.Addr{}, fmt.Errorf("invalid hostname %s: %w", host, err)
}
msg := dnsmessage.Message{
Header: dnsmessage.Header{RecursionDesired: true},
Questions: []dnsmessage.Question{
{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
},
}
queryBytes, err := msg.Pack()
if err != nil {
return netip.Addr{}, fmt.Errorf("failed to pack DNS query: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
respBytes, err := resolver.Query(ctx, queryBytes, "udp", netip.AddrPort{})
if err != nil {
return netip.Addr{}, fmt.Errorf("DNS resolution failed for %s: %w", host, err)
}
var resp dnsmessage.Message
if err := resp.Unpack(respBytes); err != nil {
return netip.Addr{}, fmt.Errorf("failed to unpack DNS response: %w", err)
}
for _, ans := range resp.Answers {
switch r := ans.Body.(type) {
case *dnsmessage.AResource:
if ip := netip.AddrFrom4(r.A); ip.IsValid() {
return ip, nil
}
}
}
return netip.Addr{}, fmt.Errorf("no A/AAAA record found for %s", host)
}
func PresolveConnectRulesDstAddr(rules map[string][]ConnectRule, logger *slog.Logger, srv *tsnet.Server) {
for tag, rrs := range rules {
for i := range rrs {
rule := &rrs[i]
normalized, changed, err := PresolveDstAddrWithSuffix(rule.DstAddr, srv)
normalized, changed, err := NormalizeDstAddrWithSuffix(ctx, srv, rule.DstAddr)
if err != nil {
logger.Warn("failed to resolve dst_addr",
logger.Debug("failed to normalize dst_addr",
slog.String("tag", tag),
slog.String("dst", rule.DstAddr),
slog.String("error", err.Error()),
@@ -291,7 +260,7 @@ func PresolveConnectRulesDstAddr(rules map[string][]ConnectRule, logger *slog.Lo
continue
}
if changed {
logger.Debug("dst_addr resolved",
logger.Debug("dst_addr normalized with MagicDNS suffix",
slog.String("tag", tag),
slog.String("original", rule.DstAddr),
slog.String("normalized", normalized),
+14
View File
@@ -0,0 +1,14 @@
services:
tslink:
image: ghcr.io/saltedfishclub/tslink:latest
ports:
- "9000:9000"
- "25565:25565"
volumes:
- ./config.toml:/etc/tslink/config.toml:ro
- tslink_state:/var/lib/tslink
command: ["-c", "/etc/tslink/config.toml"]
restart: unless-stopped
volumes:
tslink_state:
+6 -1
View File
@@ -3,14 +3,17 @@ module tslink
go 1.26.3
require (
gioui.org v0.10.1
github.com/BurntSushi/toml v1.6.0
github.com/lmittmann/tint v1.1.3
github.com/mattn/go-colorable v0.1.13
golang.org/x/net v0.53.0
tailscale.com v1.98.2
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
gioui.org/shader v1.0.8 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/coder/websocket v1.8.12 // indirect
@@ -19,6 +22,7 @@ require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
@@ -44,7 +48,8 @@ require (
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.27.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
+13
View File
@@ -1,9 +1,16 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0=
gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
@@ -76,6 +83,10 @@ github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHj
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
@@ -193,6 +204,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
+767
View File
@@ -0,0 +1,767 @@
package gui
import (
"context"
"image"
"io"
"log/slog"
"strings"
"sync/atomic"
"time"
"gioui.org/app"
"gioui.org/font"
"gioui.org/io/clipboard"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/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
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 is the window currently on screen. It is replaced when the splash
// hands off to the shell, so background goroutines load it through
// [App.invalidate] rather than capturing a single window.
win atomic.Pointer[app.Window]
nav []navEntry
current pageID
overview *overviewPage
peers *peersPage
diag *diagPage
logs *logsPage
settings *settingsPage
splash *splashView
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: pageDiag, label: KNavDiag, icon: IconPulse},
{id: pageLogs, label: KNavLogs, icon: IconList},
{id: pageSettings, label: KNavSettings, icon: IconSliders},
}
a.overview = newOverviewPage()
a.peers = newPeersPage()
a.diag = newDiagPage(a)
a.logs = newLogsPage(a)
a.settings = newSettingsPage(a)
a.splash = newSplashView()
return a
}
// Run shows the GUI and returns when it closes.
//
// It opens two windows in sequence: a compact splash sized to its progress
// checklist during boot, then a full-size shell once the service is ready.
// Each window is created at its final size. Growing a window at runtime — which
// is what an in-place splash-to-shell transition would need — is unreliable
// across compositors (Wayland in particular refuses client-driven resizes on
// some of them), so opening a correctly sized window is the dependable path.
func (a *App) Run(ctx context.Context) error {
go a.watch(ctx)
go a.upgradeFonts()
// The splash runs until the service is ready, then closes itself and asks
// the caller to open the shell. Any other exit — the user closing the
// window, or ctx being cancelled — quits.
proceed, err := a.runWindow(ctx, false)
if err != nil || !proceed || ctx.Err() != nil {
return err
}
_, err = a.runWindow(ctx, true)
return err
}
// runWindow creates one window and drives its event loop: the compact splash
// (shell=false) or the full-size shell (shell=true).
//
// It reports proceed=true only for the splash's ready handoff — the service
// came up, so the splash closed itself and the caller should open the shell.
// A window closed by the user or by ctx cancellation returns proceed=false,
// which quits the app.
func (a *App) runWindow(ctx context.Context, shell bool) (proceed bool, err error) {
w := new(app.Window)
if shell {
w.Option(
app.Title("tslink"),
app.Size(shellWindowW, shellWindowH),
app.MinSize(shellMinW, shellMinH),
)
} else {
w.Option(
app.Title("tslink"),
app.Size(splashWindowW, splashWindowH),
app.MinSize(splashMinW, splashMinH),
)
}
a.win.Store(w)
// Ctrl+C at the terminal cancels ctx. Without this the supervisor tears
// down but the window survives — the GUI is the process, so cancelling it
// has to close the window too. Scoped to this window and stopped when the
// loop returns, so it never reaches across the handoff to the next one.
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-ctx.Done():
w.Perform(system.ActionClose)
case <-stop:
}
}()
// handoff records that we closed the splash because the service came up, so
// the resulting DestroyEvent means "open the shell" rather than "quit".
handoff := false
var ops op.Ops
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
return handoff, e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
a.applyFontUpgrade()
// The splash window always draws the splash, even on the frame
// where the service first reports ready: otherwise the shell would
// flash cramped in the compact window for one frame before handoff.
a.layout(gtx, !shell)
e.Frame(gtx.Ops)
if !shell && a.state().Ready() {
handoff = true
w.Perform(system.ActionClose)
}
}
}
}
// invalidate schedules a repaint of whichever window is currently shown. It is
// a no-op before the first window exists and is safe from any goroutine.
func (a *App) invalidate() {
if w := a.win.Load(); w != nil {
w.Invalidate()
}
}
// upgradeFonts parses the system CJK font off the UI goroutine. The splash
// screen exists partly to cover this: a 20 MB font collection takes long
// enough to parse that doing it inline would stall the first frame.
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:
a.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) {
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
a.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)
}
// reveal shows path in the platform file manager, off the UI goroutine so a
// slow or missing file manager cannot stall a frame. Failure is logged rather
// than surfaced: the file is already written and its path is already on screen,
// so there is nothing for the user to act on.
func (a *App) reveal(path string) {
go func() {
if err := RevealInFileManager(path, a.logger); err != nil {
a.logger.Warn("could not open the file manager", "path", path, "err", err)
}
}()
}
// notify shows a transient message at the bottom of the window.
func (a *App) notify(msg string, level StatusLevel) {
a.toastMsg = msg
a.toastLevel = level
a.toastUntil = time.Now().Add(3200 * time.Millisecond)
a.invalidate()
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
// layout draws one frame. forceSplash keeps the splash on screen even once the
// service is ready, which the compact splash window uses so the shell never
// flashes cramped in it before the handoff to the full-size window.
func (a *App) layout(gtx C, forceSplash bool) D {
th := a.th
paint.Fill(gtx.Ops, th.P.Bg)
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.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 forceSplash || !st.Ready() {
// The splash owns the whole window until the service is up.
return a.splash.Layout(a, gtx, st)
}
return a.shell(gtx, st)
}),
layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max
return a.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 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 {
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)
}),
)
})
}
// 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()
}
+572
View File
@@ -0,0 +1,572 @@
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
// MaxWindow caps how far back the x axis reaches. The axis is scaled to the
// data's own extent and only clamped by this, so the plot fills its width
// from the second sample onward instead of leaving the first N minutes of
// the window blank while history accumulates.
MaxWindow time.Duration
// Now is the wall clock, used only as a fallback when there is no data.
Now time.Time
// Unit labels the y axis.
Unit string
// 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
// tMin/tMax are the x domain resolved by the last Layout. HoverIndex maps
// the pointer through these rather than recomputing from ChartStyle, so the
// crosshair cannot disagree with the drawn line.
tMin, tMax time.Time
}
// minPlotSpan keeps the axis sane when every visible sample shares a timestamp,
// which happens on the very first frame after a refresh.
const minPlotSpan = 10 * time.Second
// domain resolves the x axis from the visible data, clamped to st.MaxWindow.
func domain(series []ChartSeries, st ChartStyle) (tMin, tMax time.Time) {
now := st.Now
if now.IsZero() {
now = time.Now()
}
window := st.MaxWindow
if window <= 0 {
window = 3 * time.Minute
}
var first, last time.Time
for _, s := range series {
if s.Hidden {
continue
}
for _, p := range s.Points {
if first.IsZero() || p.At.Before(first) {
first = p.At
}
if last.IsZero() || p.At.After(last) {
last = p.At
}
}
}
if first.IsZero() {
return now.Add(-window), now
}
// Never show more than the window, however much history is retained.
if last.Sub(first) > window {
first = last.Add(-window)
}
if last.Sub(first) < minPlotSpan {
first = last.Add(-minPlotSpan)
}
return first, last
}
// HoverIndex returns the sample index the pointer is nearest within s, or -1.
func (c *Chart) HoverIndex(series ChartSeries) int {
if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 0 {
return -1
}
span := c.tMax.Sub(c.tMin)
if span <= 0 {
return -1
}
frac := float64(c.hover.X-float32(c.plot.Min.X)) / float64(c.plot.Dx())
if frac < 0 || frac > 1 {
return -1
}
target := c.tMin.Add(time.Duration(frac * float64(span)))
best, bestDelta := -1, time.Duration(math.MaxInt64)
for i, p := range series.Points {
d := p.At.Sub(target)
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 > span/20 {
return -1
}
return best
}
// Layout draws the chart.
func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D {
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))
c.tMin, c.tMax = domain(series, st)
c.drawGrid(t, gtx, plot, yMax, c.tMax.Sub(c.tMin))
for _, s := range series {
if s.Hidden || len(s.Points) == 0 {
continue
}
c.drawSeries(t, gtx, plot, s, c.tMin, c.tMax, yMax, st.FillSingle && visibleCount(series) == 1)
}
c.drawCrosshair(t, gtx, plot, series, 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, span time.Duration) {
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(span)},
{0.5, "-" + FormatDuration(span/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, 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)
if i < 0 || !s.Points[i].OK {
continue
}
pt := pos(plot, c.tMin, c.tMax, yMax, s.Points[i])
d := gtx.Dp(7)
off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops)
Circle(gtx, d, s.Color)
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.
//
// Wrapping matters here: with the eight series the chart allows, the chips are
// far wider than the card, and a plain Flex would silently clip the trailing
// ones — the peers you could no longer toggle were exactly the ones you could
// no longer identify.
func (t *Theme) Legend(gtx C, entries []LegendEntry, click func(i int) layout.Widget) D {
if len(entries) == 0 {
return D{}
}
children := make([]layout.Widget, 0, len(entries))
for i := range entries {
children = append(children, click(i))
}
return WrapRow(gtx, 0, 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)}
})
}),
// Bounded: peer names can be long, and one runaway chip would push
// every following one onto its own line.
layout.Rigid(OneLine(t.Text(SizeCaption, fg, Truncate(e.Name, 22))).Layout),
layout.Rigid(func(gtx C) D {
if e.Value == "" {
return D{}
}
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}
}
+149
View File
@@ -0,0 +1,149 @@
package gui
import (
"image"
"testing"
"time"
"gioui.org/layout"
"tslink/netdiag"
)
// TestChartDomainFillsWithSparseData is the regression for the blank-chart bug:
// a handful of samples used to occupy the left 3% of a fixed 20-minute axis.
// The domain must track the data, not the clock.
func TestChartDomainFillsWithSparseData(t *testing.T) {
now := time.Now()
// 30 seconds of uptime at the 10s ping interval.
pts := []ChartPoint{
{At: now.Add(-20 * time.Second), Value: 10, OK: true},
{At: now.Add(-10 * time.Second), Value: 12, OK: true},
{At: now, Value: 11, OK: true},
}
series := []ChartSeries{{Points: pts}}
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
tMin, tMax := domain(series, st)
if got := tMax.Sub(tMin); got != 20*time.Second {
t.Fatalf("span = %v, want the data's own 20s extent", got)
}
if !tMin.Equal(pts[0].At) || !tMax.Equal(pts[2].At) {
t.Errorf("domain = [%v, %v], want the first and last sample", tMin, tMax)
}
}
func TestChartDomainClampsToWindow(t *testing.T) {
now := time.Now()
series := []ChartSeries{{Points: []ChartPoint{
{At: now.Add(-30 * time.Minute), Value: 10, OK: true},
{At: now, Value: 11, OK: true},
}}}
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
tMin, tMax := domain(series, st)
if got := tMax.Sub(tMin); got != 3*time.Minute {
t.Fatalf("span = %v, want it clamped to MaxWindow", got)
}
}
func TestChartDomainEdgeCases(t *testing.T) {
now := time.Now()
st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now}
// No data at all: fall back to the full window so the grid still renders.
tMin, tMax := domain(nil, st)
if got := tMax.Sub(tMin); got != 3*time.Minute {
t.Errorf("empty span = %v, want the full window", got)
}
// One sample would otherwise give a zero-width axis and divide by zero.
one := []ChartSeries{{Points: []ChartPoint{{At: now, Value: 5, OK: true}}}}
tMin, tMax = domain(one, st)
if got := tMax.Sub(tMin); got != minPlotSpan {
t.Errorf("single-point span = %v, want minPlotSpan", got)
}
// Hidden series must not widen the axis.
mixed := []ChartSeries{
{Hidden: true, Points: []ChartPoint{{At: now.Add(-2 * time.Minute), Value: 1, OK: true}}},
{Points: []ChartPoint{
{At: now.Add(-30 * time.Second), Value: 1, OK: true},
{At: now, Value: 2, OK: true},
}},
}
tMin, tMax = domain(mixed, st)
if got := tMax.Sub(tMin); got != 30*time.Second {
t.Errorf("span = %v, want only the visible series to count", got)
}
}
// TestWrapRowWraps checks that children exceeding the width land on new lines
// instead of being clipped, which is what a plain Flex did.
func TestWrapRowWraps(t *testing.T) {
const (
childW = 100
childH = 20
rowW = 250 // fits 2 children per line
n = 5
)
child := func(gtx C) D { return D{Size: image.Pt(childW, childH)} }
children := make([]layout.Widget, n)
for i := range children {
children[i] = child
}
gtx, _ := newTestContext(image.Pt(rowW, 500))
dims := WrapRow(gtx, 0, children)
// 5 children, 2 per line => 3 lines.
if want := 3 * childH; dims.Size.Y != want {
t.Errorf("height = %d, want %d (3 wrapped lines)", dims.Size.Y, want)
}
if dims.Size.X != rowW {
t.Errorf("width = %d, want the full %d", dims.Size.X, rowW)
}
}
func TestWrapRowSingleLine(t *testing.T) {
child := func(gtx C) D { return D{Size: image.Pt(50, 20)} }
gtx, _ := newTestContext(image.Pt(500, 500))
dims := WrapRow(gtx, 0, []layout.Widget{child, child, child})
if dims.Size.Y != 20 {
t.Errorf("height = %d, want a single 20px line", dims.Size.Y)
}
}
func TestWrapRowEmpty(t *testing.T) {
gtx, _ := newTestContext(image.Pt(100, 100))
if dims := WrapRow(gtx, 0, nil); dims.Size != (image.Point{}) {
t.Errorf("want zero dims for no children, got %v", dims.Size)
}
}
// TestUDPProbeLabel covers the naming rules for the UDP table: prefer the
// configured hostname over the resolved address, and keep the two rows of a
// dual-stack server distinguishable.
func TestUDPProbeLabel(t *testing.T) {
cases := []struct {
name string
in netdiag.UDPProbe
want string
}{
{"resolved v4", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米"},
"小米 stun.miwifi.com:3478 · IPv4"},
{"resolved v6", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米"},
"小米 stun.miwifi.com:3478 · IPv6"},
{"dns failure keeps the hostname", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478", Name: "X"},
"X a.example:3478"},
{"no name", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478"}, "a.example:3478"},
{"no host falls back to target", netdiag.UDPProbe{Target: "1.2.3.4:3478"}, "1.2.3.4:3478 · IPv4"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := udpProbeLabel(tc.in); got != tc.want {
t.Errorf("udpProbeLabel() = %q, want %q", got, tc.want)
}
})
}
}
+296
View File
@@ -0,0 +1,296 @@
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
}
if len(faces) == 0 {
return nil, nil
}
// A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping
// one avoids paying for five near-identical fallbacks on every glyph miss;
// pickCJKFace decides which one.
base := faces[pickCJKFace(faces)]
out := cjkWeightVariants(base)
logger.Debug("cjk font loaded",
"path", path,
"typeface", string(base.Font.Typeface),
"faces", len(out),
"bytes", st.Size(),
"took", time.Since(start).Round(time.Millisecond),
)
return out, nil
}
// scWeights are the family-name markers of the Simplified Chinese cut, in
// preference order. Pan-CJK collections order their faces JP first, so taking
// faces[0] blindly renders Han characters with Japanese glyph variants — legible,
// but visibly wrong to a Chinese reader.
var scMarkers = []string{"sc", "simplified", "cn", "hans"}
// pickCJKFace returns the index of the face to use, preferring the Simplified
// Chinese cut and falling back to the first face.
func pickCJKFace(faces []font.FontFace) int {
for _, marker := range scMarkers {
for i, f := range faces {
name := strings.ToLower(string(f.Font.Typeface))
// Match on a word/suffix boundary so "sc" does not hit "Sans".
for _, field := range strings.FieldsFunc(name, func(r rune) bool {
return r == ' ' || r == '-' || r == '_'
}) {
if field == marker || strings.HasSuffix(field, marker) {
return i
}
}
}
}
return 0
}
// cjkWeightVariants registers one parsed face under every weight the UI asks
// for.
//
// This exists because of how Gio resolves fonts. The theme pins every label's
// Typeface to "Go" (see NewTheme), and Gio never tells go-text which script it
// is shaping, so our explicitly-loaded CJK font is only reachable through
// fontscan's user-provided tier — which prunes candidates by weight before
// checking coverage. A face registered only at Normal is therefore invisible to
// any label that sets Font.Weight, and every section title, card header and
// button does exactly that. The result was Chinese body text rendering fine
// while every heading turned into tofu boxes.
//
// The variants share the same underlying Face, so CJK headings are not visually
// bolder than body text. That is a deliberate trade: identical weight beats
// missing glyphs, and synthetic emboldening is not available here.
func cjkWeightVariants(base font.FontFace) []font.FontFace {
weights := []font.Weight{font.Normal, font.Medium, font.SemiBold, font.Bold}
out := make([]font.FontFace, 0, len(weights))
for _, w := range weights {
f := base.Font
f.Weight = w
f.Style = font.Regular
out = append(out, font.FontFace{Font: f, Face: base.Face})
}
return out
}
// goCollection returns the built-in Go font faces. It exists so tests can
// build a theme without touching the host's font configuration.
func goCollection() []font.FontFace { return gofont.Collection() }
+604
View File
@@ -0,0 +1,604 @@
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
KNavDiag
KNavLogs
KNavSettings
// Service lifecycle.
KStateStarting
KStateConnecting
KStateRunning
KStateDegraded
KStateStopped
KStateError
KStateRetrying
// Splash steps.
KStepConfig
KStepFonts
KStepTsnet
KStepRules
KStepDiscovery
KStepMonitors
KStepReady
KSplashHint
KSplashStuckHint
KSplashExportLog
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
KOvForwardRules
KOvConnectRules
KOvUptime
KOvHealth
KOvQuickDiag
KOvNoIssues
// Peers page.
KPeersTitle
KPeersLinked
KPeersEmpty
KPeersResolving
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
// Local services (overview).
KSvcTitle
KSvcSubtitle
KSvcEmpty
KSvcBroadcast
// 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
KDiagEgressDivergentHTTP
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
// Settings.
KSetTheme
KSetThemeDark
KSetThemeLight
KSetLanguage
KSetAbout
KSetConfigPath
KSetVersion
KSetFont
KSetFontMissing
kCount
)
var zhStrings = [kCount]string{
KAppTitle: "tslink",
KAppSubtitle: "Tailscale 内网穿透",
KNavOverview: "概览",
KNavPeers: "节点",
KNavDiag: "网络诊断",
KNavLogs: "日志",
KNavSettings: "设置",
KStateStarting: "正在启动",
KStateConnecting: "正在连接",
KStateRunning: "运行中",
KStateDegraded: "降级运行",
KStateStopped: "已停止",
KStateError: "出错",
KStateRetrying: "正在重试",
KStepConfig: "读取配置",
KStepFonts: "加载字体",
KStepTsnet: "接入 Tailscale 网络",
KStepRules: "解析转发规则",
KStepDiscovery: "启动局域网发现",
KStepMonitors: "启动状态监控",
KStepReady: "准备就绪",
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
KSplashStuckHint: "当前步骤耗时异常,可导出日志以便排查",
KSplashExportLog: "导出日志",
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: "在线节点",
KOvForwardRules: "转发规则",
KOvConnectRules: "连接规则",
KOvUptime: "运行时长",
KOvHealth: "健康状况",
KOvQuickDiag: "运行网络诊断",
KOvNoIssues: "未发现问题",
KPeersTitle: "Tailscale 节点",
KPeersLinked: "已关联",
KPeersEmpty: "暂无节点",
KPeersResolving: "正在解析配置中的节点",
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: "最近",
KGraphLegendHint: "点击图例可隐藏对应节点",
KSvcTitle: "本机服务",
KSvcSubtitle: "tslink 在本机监听并转发到对应服务器",
KSvcEmpty: "配置中没有连接规则",
KSvcBroadcast: "已广播",
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: "STUN(UDP)本身就看到多个公网 IP,直连打洞会受影响",
KDiagEgressDivergentHTTP: "仅 HTTP 探测看到不同的公网 IP,STUN(UDP)出口一致,通常不影响打洞",
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: "附带诊断报告",
KSetTheme: "主题",
KSetThemeDark: "深色",
KSetThemeLight: "浅色",
KSetLanguage: "语言",
KSetAbout: "关于",
KSetConfigPath: "配置文件",
KSetVersion: "版本",
KSetFont: "中文字体",
KSetFontMissing: "未找到中文字体,界面已切换为英文",
}
var enStrings = [kCount]string{
KAppTitle: "tslink",
KAppSubtitle: "Tailscale link layer",
KNavOverview: "Overview",
KNavPeers: "Peers",
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",
KSplashStuckHint: "This step is taking unusually long — export the log to investigate",
KSplashExportLog: "Export log",
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",
KOvForwardRules: "Forward rules",
KOvConnectRules: "Connect rules",
KOvUptime: "Uptime",
KOvHealth: "Health",
KOvQuickDiag: "Run diagnostics",
KOvNoIssues: "No issues found",
KPeersTitle: "Tailscale peers",
KPeersLinked: "Linked",
KPeersEmpty: "No peers yet",
KPeersResolving: "Resolving the peers named in the config",
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",
KGraphLegendHint: "Click a legend entry to hide that peer",
KSvcTitle: "Local services",
KSvcSubtitle: "Listening on this machine, forwarded to each server",
KSvcEmpty: "No connect rules configured",
KSvcBroadcast: "Broadcast",
KDiagTitle: "Network diagnostics",
KDiagRun: "Run diagnostics",
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: "STUN (UDP) itself saw more than one public IP, so direct connections will suffer",
KDiagEgressDivergentHTTP: "Only the HTTP probes disagreed; the STUN (UDP) egress is consistent, so hole punching is usually unaffected",
KDiagGeoSkipped: "Geolocation skipped",
KDiagPreferredDERP: "Preferred DERP",
KDiagDerpLatency: "DERP latency",
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",
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 "?"
}
+359
View File
@@ -0,0 +1,359 @@
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
}
// 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
}
+1151
View File
File diff suppressed because it is too large Load Diff
+495
View File
@@ -0,0 +1,495 @@
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)
a.reveal(path)
}
}
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)
}
a.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)
}),
)
}),
)
})
}
+380
View File
@@ -0,0 +1,380 @@
package gui
import (
"image"
"strings"
"time"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/op"
"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
// svcCopy holds one clickable per service address, allocated on demand.
svcCopy map[string]*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()
}
servers := buildServices(st.Config, snap)
if p.copySelf.Clicked(gtx) {
a.copyToClipboard(gtx, selfAddrText(snap.Self), "")
}
items := []layout.Widget{
func(gtx C) D { return p.statRow(a, gtx, st, snap, servers) },
func(gtx C) D { return p.healthCard(a, gtx) },
func(gtx C) D { return p.selfCard(a, gtx, st, snap) },
func(gtx C) D { return p.servicesCard(a, gtx, servers) },
func(gtx C) D { return p.linkedCard(a, gtx, snap) },
}
return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D {
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)
}
// Single line, always. A value like "19 / 25" wraps at narrow
// tile widths where "8" does not, and one tile a whole line
// taller than its neighbours is what makes the row look broken.
return OneLine(l).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if hint != "" {
return OneLine(th.Caption(hint)).Layout(gtx)
}
// Reserve the hint line even when there is no hint. These tiles
// sit in a row, and Flex does not equalise child heights, so a
// tile that skipped this line came out shorter than its
// neighbours and the row looked misaligned.
macro := op.Record(gtx.Ops)
d := OneLine(th.Caption("X")).Layout(gtx)
macro.Stop()
return D{Size: image.Pt(0, d.Size.Y)}
}),
)
})
}
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, servers []serviceServer) 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)
}
}
services, broadcast := 0, 0
for _, srv := range servers {
services += len(srv.Services)
for _, svc := range srv.Services {
if svc.Broadcast {
broadcast++
}
}
}
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(services),
th.T(KSvcTitle),
itoa(broadcast)+" "+th.T(KSvcBroadcast),
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.HeadlineStatus)), 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),
})
}),
)
})
}
+500
View File
@@ -0,0 +1,500 @@
package gui
import (
"sort"
"strings"
"time"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
)
// chartWindow is the most latency history the graph shows. The monitor retains
// 20 minutes, but a spike that old tells you nothing about the session you are
// in right now, and stretching the axis over it flattens everything recent into
// noise. The axis scales to whatever data exists within this bound, so the plot
// is full from the second sample rather than after 20 minutes of uptime.
const chartWindow = 3 * time.Minute
// maxChartSeries caps how many peers are plotted at once. Beyond about eight
// lines a latency graph stops being readable, so linked peers win and the rest
// 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)
}
// Only nodes a config rule points at. The netmap contains every machine on
// the tailnet, most of which the user has no rule for and no interest in.
linked, _ := splitPeers(snap.Peers)
series := p.buildSeries(th, linked)
// Legend clicks toggle series visibility.
for i := range series {
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(linked)+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) })
}
} else {
items = append(items, func(gtx C) D {
// Link resolution is periodic and needs DNS, so on a fresh boot
// every peer is briefly unlinked. Saying "no peers" there would be
// wrong; the netmap may be full of machines we simply have no rule
// for yet.
hint := snap.Err
if hint == "" {
hint = snap.BackendState
}
title := th.T(KPeersEmpty)
if len(snap.Peers) > 0 {
title = th.T(KPeersResolving)
}
return th.EmptyState(gtx, IconNodes, title, 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
}
// Drop samples outside the window by age rather than by count: the
// monitor's ring is not evenly spaced, because a manual refresh injects
// an off-cycle sweep.
cutoff := time.Now().Add(-chartWindow)
pts := make([]ChartPoint, 0, len(pr.Samples))
for _, s := range pr.Samples {
if s.At.Before(cutoff) {
continue
}
pts = append(pts, ChartPoint{
At: s.At,
Value: float64(s.Latency) / float64(time.Millisecond),
OK: s.OK,
})
}
if len(pts) == 0 {
continue
}
out = append(out, namedSeries{
id: pr.ID,
s: ChartSeries{
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
plot := make([]ChartSeries, len(series))
for i, s := range series {
plot[i] = s.s
}
style := ChartStyle{
Height: 200,
MaxWindow: chartWindow,
Now: time.Now(),
Unit: "ms",
FillSingle: true,
}
card := th.Card()
card.Title = th.T(KGraphTitle)
// The axis follows the data, so the subtitle has to as well — a fixed
// "last 20 minutes" was a lie for the first 20 minutes of every run.
if len(plot) > 0 {
tMin, tMax := domain(plot, style)
card.Subtitle = th.T(KGraphWindow) + " " + FormatDuration(tMax.Sub(tMin))
}
card.Trailing = func(gtx C) D {
return th.IconButton(gtx, &p.refresh, IconRefresh, LevelNeutral)
}
return card.Layout(th, gtx, func(gtx C) D {
if len(series) == 0 {
return th.EmptyState(gtx, IconPulse, th.T(KGraphEmpty), "")
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return p.chart.Layout(th, gtx, style, 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
entries := make([]LegendEntry, 0, len(series))
for _, s := range series {
entries = append(entries, LegendEntry{
Name: s.s.Name,
Color: s.s.Color,
Hidden: p.hidden[s.id],
Value: lastValue(s.s.Points),
})
}
return th.Legend(gtx, entries, func(i int) layout.Widget {
click := p.legendClick(series[i].id)
return func(gtx C) D {
return click.Layout(gtx, func(gtx C) D {
return th.LegendChip(gtx, entries[i], click.Hovered())
})
}
})
}
func lastValue(points []ChartPoint) string {
for i := len(points) - 1; i >= 0; i-- {
if points[i].OK {
return FormatLatency(time.Duration(points[i].Value * float64(time.Millisecond)))
}
}
return "—"
}
func routeLabel(th *Theme, r core.PeerRoute) string {
switch r {
case core.RouteDirect:
return th.T(KPeerRouteDirect)
case core.RouteDERP:
return th.T(KPeerRouteDERP)
case core.RoutePeerRelay:
return th.T(KPeerRoutePeerRelay)
case core.RouteOffline:
return th.T(KPeerRouteOffline)
default:
return th.T(KPeerRouteUnknown)
}
}
func routeLevel(r core.PeerRoute) StatusLevel {
switch r {
case core.RouteDirect:
return LevelOK
case core.RouteDERP, core.RoutePeerRelay:
return LevelWarn
case core.RouteOffline:
return LevelFail
default:
return LevelNeutral
}
}
func (p *peersPage) peerCard(a *App, gtx C, st core.State, pr core.PeerInfo) D {
th := a.th
row := p.row(pr.ID)
if row.click.Clicked(gtx) {
row.expanded = !row.expanded
}
card := th.Card()
card.Pad = SpaceMD
if pr.Linked {
accent := th.SeriesColor(0)
if !pr.Online {
accent = th.P.TextDim
}
card.Accent = &accent
}
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return row.click.Layout(gtx, func(gtx C) D {
return p.peerHeader(a, gtx, pr, row.expanded)
})
}),
layout.Rigid(func(gtx C) D {
if !row.expanded {
return D{}
}
return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D {
return p.peerDetail(a, gtx, pr)
})
}),
)
})
}
func (p *peersPage) peerHeader(a *App, gtx C, pr core.PeerInfo, expanded bool) D {
th := a.th
level := LevelOK
if !pr.Online {
level = LevelNeutral
}
latency := "—"
latLevel := LevelNeutral
if pr.LatencyOK && pr.LastLatency > 0 {
latency = FormatLatency(pr.LastLatency)
latLevel = latencyLevel(pr.LastLatency)
} else if pr.Online {
latency = th.T(KUnknown)
}
points := make([]ChartPoint, 0, len(pr.Samples))
for _, s := range pr.Samples {
points = append(points, ChartPoint{
At: s.At,
Value: float64(s.Latency) / float64(time.Millisecond),
OK: s.OK,
})
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
// Never pulsing: one breathing dot per online peer would keep the
// whole window redrawing for as long as the page is open.
return th.StatusDot(gtx, level, false)
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return OneLine(th.Body(pr.DisplayName)).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if !pr.Linked {
return D{}
}
return layout.Inset{Left: 6}.Layout(gtx, func(gtx C) D {
return IconLink(gtx, gtx.Dp(12), th.P.Accent)
})
}),
)
}),
layout.Rigid(func(gtx C) D {
sub := pr.DNSName
if sub == "" && len(pr.TailscaleIPs) > 0 {
sub = pr.TailscaleIPs[0].String()
}
if len(pr.LinkTags) > 0 {
sub = strings.Join(pr.LinkTags, ", ") + " · " + sub
}
return OneLine(th.Caption(sub)).Layout(gtx)
}),
)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
return th.Sparkline(gtx, points, th.StatusColor(latLevel), 84, 22)
}),
HGap(SpaceMD),
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(70)
l := th.MonoLabel(SizeBody, th.StatusColor(latLevel), latency)
return l.Layout(gtx)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.Chip(gtx, ChipStyle{
Text: routeLabel(th, pr.Route),
Level: routeLevel(pr.Route),
})
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
icon := IconChevronRight
if expanded {
icon = IconChevronDown
}
return icon(gtx, gtx.Dp(14), th.P.TextDim)
}),
)
}
// latencyLevel colours a latency figure. The thresholds are chosen for the
// thing this tool carries: under 60ms a Minecraft session feels local, past
// 150ms block placement starts to feel wrong.
func latencyLevel(d time.Duration) StatusLevel {
switch {
case d <= 0:
return LevelNeutral
case d < 60*time.Millisecond:
return LevelOK
case d < 150*time.Millisecond:
return LevelWarn
default:
return LevelFail
}
}
func (p *peersPage) peerDetail(a *App, gtx C, pr core.PeerInfo) D {
th := a.th
addrs := make([]string, 0, len(pr.TailscaleIPs))
for _, ip := range pr.TailscaleIPs {
addrs = append(addrs, ip.String())
}
endpoint := pr.CurAddr
if endpoint == "" {
endpoint = pr.Relay
}
if endpoint == "" {
endpoint = "—"
}
rows := []KV{
{Key: th.T(KPeerAddresses), Value: strings.Join(addrs, " "), Mono: true},
{Key: th.T(KPeerEndpoint), Value: endpoint, Mono: true},
{Key: th.T(KPeerOS), Value: orDash(pr.OS)},
{
Key: th.T(KPeerAvg) + " / " + th.T(KPeerMin) + " / " + th.T(KPeerMax),
Value: FormatLatency(pr.AvgLatency) + " " +
FormatLatency(pr.MinLatency) + " " + FormatLatency(pr.MaxLatency),
Mono: true,
},
{
Key: th.T(KPeerJitter) + " / " + th.T(KPeerLoss),
Value: trimZero(pr.JitterMs, 1) + " ms · " + trimZero(pr.LossPct, 1) + " %",
Mono: true,
Level: lossLevel(pr.LossPct),
},
{
Key: th.T(KPeerRx) + " / " + th.T(KPeerTx),
Value: FormatBytes(pr.RxBytes) + " · " + FormatBytes(pr.TxBytes),
Mono: true,
},
{Key: th.T(KPeerLastHandshake), Value: RelTime(th, pr.LastHandshake, time.Now())},
}
if !pr.Online {
rows = append(rows, KV{
Key: th.T(KPeerLastSeen),
Value: RelTime(th, pr.LastSeen, time.Now()),
Level: LevelWarn,
})
}
if pr.ExitNode {
rows = append(rows, KV{Key: th.T(KPeerExitNode), Value: th.T(KYes), Level: LevelInfo})
}
return th.KVList(gtx, rows)
}
func lossLevel(pct float64) StatusLevel {
switch {
case pct <= 0:
return LevelNeutral
case pct < 5:
return LevelWarn
default:
return LevelFail
}
}
func orDash(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
return s
}
+144
View File
@@ -0,0 +1,144 @@
package gui
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
)
type settingsPage struct {
app *App
list widget.List
theme widget.Enum
lang widget.Enum
restart widget.Clickable
}
func newSettingsPage(a *App) *settingsPage {
p := &settingsPage{app: a}
p.list.Axis = layout.Vertical
p.theme.Value = "dark"
if !a.th.Dark {
p.theme.Value = "light"
}
p.lang.Value = "zh"
if a.th.Lang == LangEN {
p.lang.Value = "en"
}
return p
}
func (p *settingsPage) Layout(a *App, gtx C, st core.State) D {
th := a.th
if p.theme.Update(gtx) {
th.SetDark(p.theme.Value == "dark")
}
if p.lang.Update(gtx) {
if p.lang.Value == "en" {
th.Lang = LangEN
} else {
th.Lang = LangZH
}
}
if p.restart.Clicked(gtx) && a.opt.Supervisor != nil {
a.opt.Supervisor.Restart()
a.notify(th.T(KStateRetrying), LevelInfo)
}
items := []layout.Widget{
func(gtx C) D { return p.appearanceCard(a, gtx) },
func(gtx C) D { return p.aboutCard(a, gtx, st) },
}
return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D {
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, items[i])
})
}
func (p *settingsPage) appearanceCard(a *App, gtx C) D {
th := a.th
card := th.Card()
card.Title = th.T(KNavSettings)
return card.Layout(th, gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return p.settingRow(a, gtx, th.T(KSetTheme), "", func(gtx C) D {
return th.Segmented(gtx, &p.theme, []SegmentOption{
{Key: "dark", Label: th.T(KSetThemeDark), Count: -1},
{Key: "light", Label: th.T(KSetThemeLight), Count: -1},
})
})
}),
layout.Rigid(th.Divider),
layout.Rigid(func(gtx C) D {
hint := ""
if !th.HasCJK {
hint = th.T(KSetFontMissing)
}
return p.settingRow(a, gtx, th.T(KSetLanguage), hint, func(gtx C) D {
return th.Segmented(gtx, &p.lang, []SegmentOption{
{Key: "zh", Label: "中文", Count: -1},
{Key: "en", Label: "English", Count: -1},
})
})
}),
)
})
}
func (p *settingsPage) settingRow(a *App, gtx C, label, hint string, control layout.Widget) D {
th := a.th
return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(th.Body(label).Layout),
layout.Rigid(func(gtx C) D {
if hint == "" {
return D{}
}
return th.Caption(hint).Layout(gtx)
}),
)
}),
layout.Rigid(control),
)
})
}
func (p *settingsPage) aboutCard(a *App, gtx C, st core.State) D {
th := a.th
card := th.Card()
card.Title = th.T(KSetAbout)
card.Trailing = func(gtx C) D {
return th.Button(gtx, &p.restart, ButtonStyle{
Kind: ButtonSubtle, Text: th.T(KRetry), Icon: IconRefresh,
})
}
cfgPath := a.opt.ConfigPath
if a.opt.ConfigURL != "" {
cfgPath = a.opt.ConfigURL
}
fontPath := a.fonts.CJKPath
if fontPath == "" {
fontPath = th.T(KNone)
}
return card.Layout(th, gtx, func(gtx C) D {
rows := []KV{
{Key: th.T(KSetVersion), Value: orDash(a.opt.Version), Mono: true},
{Key: "Runtime", Value: runtimeInfo(), Mono: true},
{Key: th.T(KSetConfigPath), Value: orDash(cfgPath), Mono: true},
{Key: th.T(KSetFont), Value: fontPath, Mono: true},
{Key: th.T(KStateRunning), Value: st.Phase.String()},
}
if st.Err != "" {
rows = append(rows, KV{Key: th.T(KError), Value: st.Err, Level: LevelFail})
}
return th.KVList(gtx, rows)
})
}
+365
View File
@@ -0,0 +1,365 @@
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{}),
}
}
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, 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)
}
}
}
// TestSplashStuck covers the >20s branch, which swaps the footer hint and
// promotes the export button.
func TestSplashStuck(t *testing.T) {
a := testApp(t)
steps := splashTestSteps()
for i := range steps {
if steps[i].State == core.StepRunning {
steps[i].Started = time.Now().Add(-45 * time.Second)
}
}
st := core.State{
Phase: core.PhaseStarting,
Steps: steps,
StartedAt: time.Now().Add(-45 * time.Second),
}
if !stalled(st) {
t.Fatal("stalled() should report a step running past stuckAfter")
}
gtx, _ := newTestContext(image.Pt(460, 450))
a.splash.Layout(a, gtx, st)
}
func splashTestSteps() []core.BootStep {
now := time.Now()
return []core.BootStep{
{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{
// A resolved probe: Host names the server, Target is the
// address actually hit, and the label must show the former.
{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond, Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")},
// DNS failed, so Target still holds the hostname.
{Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google", Region: netdiag.RegionIntl, Err: "i/o timeout"},
},
},
NAT: netdiag.NATReport{
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")
}
}
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)
}
}
}
// TestLayoutForceSplash exercises the top-level frame that runWindow drives.
// The splash window passes forceSplash=true; the shell window passes false.
// With no supervisor the state is not ready, so both must fall to the splash
// branch and lay out without panicking — the guard for the compile-time change
// to layout's signature and the forceSplash branch it added.
func TestLayoutForceSplash(t *testing.T) {
a := testApp(t)
for _, forceSplash := range []bool{true, false} {
gtx, _ := newTestContext(image.Pt(int(shellWindowW), int(shellWindowH)))
a.layout(gtx, forceSplash)
}
}
// TestStatTilesUniformHeight guards the overview's top row. The tiles sit in a
// Flex, which does not equalise child heights, so anything that makes one tile
// taller — a wrapped value, a hint line present on some tiles but not others —
// visibly misaligns the row. Narrow widths are the interesting case: that is
// where "19 / 25" wraps and "8" does not.
func TestStatTilesUniformHeight(t *testing.T) {
a := testApp(t)
tiles := []struct{ value, label, hint string }{
{"19 / 25", "在线节点", "3 已关联"},
{"8", "本机服务", "5 已广播"},
{"8 / 0", "连接规则 / 转发规则", ""},
{"10s", "运行时长", ""},
}
for _, w := range []int{60, 80, 100, 140, 200, 300} {
var first int
for i, c := range tiles {
gtx, _ := newTestContext(image.Pt(w, 400))
gtx.Constraints.Min = image.Point{}
h := a.overview.statTile(a, gtx, c.value, c.label, c.hint, LevelNeutral, IconNodes).Size.Y
if i == 0 {
first = h
continue
}
if h != first {
t.Errorf("width=%d: tile %q is %dpx, tile %q is %dpx — the row must be flush",
w, c.label, h, tiles[0].label, first)
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
package gui
import (
"context"
"log/slog"
"os/exec"
"path/filepath"
"runtime"
"time"
)
// revealTimeout bounds the helper process. A missing or wedged file manager
// must not leave a goroutine parked forever.
const revealTimeout = 10 * time.Second
// RevealInFileManager opens the platform file manager with path selected,
// falling back to opening its containing directory.
//
// Writing a log file and only printing where it went is not much use to someone
// who is about to attach it to a bug report, so the export shows it instead of
// describing it.
//
// It blocks; callers should run it off the UI goroutine.
func RevealInFileManager(path string, logger *slog.Logger) error {
if logger == nil {
logger = slog.Default()
}
ctx, cancel := context.WithTimeout(context.Background(), revealTimeout)
defer cancel()
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
switch runtime.GOOS {
case "darwin":
// -R reveals rather than opens, so Finder highlights the file.
return exec.CommandContext(ctx, "open", "-R", abs).Run()
case "windows":
// explorer wants the comma glued to the flag, and exits non-zero even
// when it succeeds, so its status is deliberately ignored.
_ = exec.CommandContext(ctx, "explorer", "/select,"+abs).Run()
return nil
default:
// The freedesktop interface highlights the file; every major Linux file
// manager implements it. Fall back to opening the directory when the
// service is absent — dbus-send itself may not even be installed.
uri := "file://" + abs
dbus := exec.CommandContext(ctx, "dbus-send",
"--session", "--dest=org.freedesktop.FileManager1", "--type=method_call",
"/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems",
"array:string:"+uri, "string:tslink",
)
if err := dbus.Run(); err == nil {
return nil
} else {
logger.Debug("FileManager1.ShowItems unavailable, opening the directory",
"err", err)
}
return exec.CommandContext(ctx, "xdg-open", filepath.Dir(abs)).Run()
}
}
+384
View File
@@ -0,0 +1,384 @@
package gui
import (
"net"
"sort"
"strings"
"gioui.org/layout"
"gioui.org/widget"
"tslink/core"
)
// The services section answers "what did tslink open on this machine, and which
// server is behind it".
//
// It is built entirely from the parsed config plus the peer snapshot the app
// already holds — no multicast, no I/O on the render path. The previous LAN page
// listened for the same MOTD broadcasts tslink itself emits, which meant the
// list was assembled from packets: the same server appeared once per IP family,
// nothing deduplicated the two, and the rows were ordered by last-seen so a 1.5s
// broadcast cycle permuted them continuously. Deriving the list from config
// instead makes it exact and, because it is sorted on a stable key, still.
// serviceEntry is one local listener created by a connect rule.
type serviceEntry struct {
Name string // the rule's MOTD, or its config tag
Tag string // the [[connect.<tag>]] key
Proto string
Addr string // the local address a client points at
Port int
// Broadcast reports that this service is announced on the LAN, i.e. it
// shows up in Minecraft's server list without being typed in.
Broadcast bool
}
// serviceServer groups every local listener that targets one remote host.
type serviceServer struct {
// Host is the dst_addr hostname, already MagicDNS-qualified by the
// supervisor's NormalizeConnectRulesDstAddr pass.
Host string
// Peer is the tailnet node Host resolved to, when the peer monitor managed
// to resolve it. Nil for destinations outside the tailnet, which are
// legitimate config entries and must still render.
Peer *core.PeerInfo
Services []serviceEntry
}
// Online reports the peer's reachability, defaulting to true when the
// destination is not a tailnet peer and we therefore have nothing to say.
func (s serviceServer) Online() bool { return s.Peer == nil || s.Peer.Online }
// Title is the friendliest name available for the target.
func (s serviceServer) Title() string {
if s.Peer != nil && s.Peer.DisplayName != "" {
return s.Peer.DisplayName
}
return s.Host
}
// serviceGroup collects the servers that resolve to one tailnet peer. A homelab
// subnet router that fronts several hosts — say tsdns-homelab carrying every
// *.homelab.ice destination — appears once, with its hosts nested beneath it,
// instead of as a run of sibling rows the user has to recognise as one machine.
type serviceGroup struct {
// Peer is the node every server in the group resolved to, or nil when the
// group is a single standalone destination outside the tailnet.
Peer *core.PeerInfo
Servers []serviceServer
}
// Online mirrors serviceServer.Online: a group is down only when its peer is a
// known, offline tailnet node. Peerless (public) groups have nothing to report.
func (g serviceGroup) Online() bool { return g.Peer == nil || g.Peer.Online }
// Title is the group header: the peer's friendly name when it resolved, else the
// single host the group stands for.
func (g serviceGroup) Title() string {
if g.Peer != nil && g.Peer.DisplayName != "" {
return g.Peer.DisplayName
}
if len(g.Servers) > 0 {
return g.Servers[0].Title()
}
return ""
}
// groupServices collapses the per-host servers into per-peer groups. Servers
// that resolved to the same tailnet node join one group; a server with no peer —
// an ordinary public destination — is a group of its own.
//
// The input is already sorted by buildServices (by title, then host), and
// servers behind one peer share a title, so they arrive adjacent and already
// host-ordered. First appearance fixes each group's position, so the section
// keeps the stable order buildServices established and never reshuffles between
// frames.
func groupServices(servers []serviceServer) []serviceGroup {
groups := make([]serviceGroup, 0, len(servers))
byPeer := make(map[string]int) // peer ID -> index into groups
for _, srv := range servers {
if srv.Peer == nil {
groups = append(groups, serviceGroup{Servers: []serviceServer{srv}})
continue
}
if idx, ok := byPeer[srv.Peer.ID]; ok {
groups[idx].Servers = append(groups[idx].Servers, srv)
continue
}
byPeer[srv.Peer.ID] = len(groups)
groups = append(groups, serviceGroup{Peer: srv.Peer, Servers: []serviceServer{srv}})
}
return groups
}
// buildServices turns connect rules into the per-server view.
//
// Grouping is by destination host rather than by config tag: a server reached
// over both TCP and UDP is written as two tagged rules pointing at the same
// dst_addr, and the user thinks of that as one server with two services.
func buildServices(cfg *core.Config, snap core.PeerSnapshot) []serviceServer {
if cfg == nil {
return nil
}
// tag -> peer, via the links the monitor already resolved.
byTag := make(map[string]*core.PeerInfo)
for i := range snap.Peers {
pr := &snap.Peers[i]
for _, tag := range pr.LinkTags {
byTag[tag] = pr
}
}
grouped := make(map[string]*serviceServer)
for tag, rules := range cfg.Connect {
for _, rule := range rules {
host := rule.DstAddr
if h, _, err := net.SplitHostPort(rule.DstAddr); err == nil {
host = h
}
g, ok := grouped[host]
if !ok {
g = &serviceServer{Host: host, Peer: byTag[tag]}
grouped[host] = g
} else if g.Peer == nil {
g.Peer = byTag[tag]
}
g.Services = append(g.Services, serviceEntry{
Name: rule.LANMotdOr(tag),
Tag: tag,
Proto: rule.Protocol,
Addr: net.JoinHostPort(rule.BindIP(), itoa(rule.LocalPort)),
Port: rule.LocalPort,
Broadcast: rule.LANEnabled(),
})
}
}
out := make([]serviceServer, 0, len(grouped))
for _, g := range grouped {
// Stable within a server: port, then protocol for the tcp/udp pair that
// shares one.
sort.SliceStable(g.Services, func(i, j int) bool {
if g.Services[i].Port != g.Services[j].Port {
return g.Services[i].Port < g.Services[j].Port
}
return g.Services[i].Proto < g.Services[j].Proto
})
out = append(out, *g)
}
// Ordered by what is actually on screen, so the list reads alphabetically
// rather than by a hostname the user may never see. Host breaks ties and
// keeps the order total — map iteration is randomised, so without a full
// ordering the whole section would reshuffle every frame.
sort.SliceStable(out, func(i, j int) bool {
if ti, tj := out[i].Title(), out[j].Title(); ti != tj {
return ti < tj
}
return out[i].Host < out[j].Host
})
return out
}
// copyBtn lazily allocates a clickable per address.
func (p *overviewPage) copyBtn(addr string) *widget.Clickable {
if p.svcCopy == nil {
p.svcCopy = make(map[string]*widget.Clickable)
}
b, ok := p.svcCopy[addr]
if !ok {
b = new(widget.Clickable)
p.svcCopy[addr] = b
}
return b
}
func (p *overviewPage) servicesCard(a *App, gtx C, servers []serviceServer) D {
th := a.th
card := th.Card()
card.Title = th.T(KSvcTitle)
card.Subtitle = th.T(KSvcSubtitle)
groups := groupServices(servers)
return card.Layout(th, gtx, func(gtx C) D {
if len(groups) == 0 {
return th.EmptyState(gtx, IconServer, th.T(KSvcEmpty), "")
}
children := make([]layout.FlexChild, 0, len(groups)*2)
for i, g := range groups {
if i > 0 {
children = append(children, layout.Rigid(th.Divider))
}
children = append(children, layout.Rigid(func(gtx C) D {
return p.serviceGroupRow(a, gtx, g)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
// serviceGroupRow renders one peer group. A group standing for a single host —
// whether a resolved peer or a bare public destination — keeps the flat
// one-server layout, so the common case looks exactly as it did before. A peer
// that fronts several hosts gets a header of its own with each host nested
// beneath it.
func (p *overviewPage) serviceGroupRow(a *App, gtx C, g serviceGroup) D {
if g.Peer == nil || len(g.Servers) == 1 {
return p.serverGroup(a, gtx, g.Servers[0])
}
return p.peerGroup(a, gtx, g)
}
// peerGroup renders a tailnet node and every host reached through it: one status
// dot and name for the node, then each destination host as a nested block.
func (p *overviewPage) peerGroup(a *App, gtx C, g serviceGroup) D {
th := a.th
level := LevelOK
if !g.Online() {
level = LevelFail
}
return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D {
children := []layout.FlexChild{
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.StatusDot(gtx, level, false)
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return OneLine(th.Body(g.Title())).Layout(gtx)
}),
)
}),
}
for _, srv := range g.Servers {
children = append(children, layout.Rigid(func(gtx C) D {
return p.hostBlock(a, gtx, srv)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
// hostBlock renders one destination host nested under its peer group: the host
// name, then the services pointing at it. The peer's status and name already sit
// in the group header, so only the host and its ports repeat here.
func (p *overviewPage) hostBlock(a *App, gtx C, srv serviceServer) D {
th := a.th
return layout.Inset{Top: SpaceXS, Left: SpaceLG}.Layout(gtx, func(gtx C) D {
children := []layout.FlexChild{
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return IconServer(gtx, gtx.Dp(13), th.P.TextDim)
}),
HGap(SpaceSM),
layout.Flexed(1, func(gtx C) D {
return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, srv.Host)).Layout(gtx)
}),
)
}),
}
for _, svc := range srv.Services {
children = append(children, layout.Rigid(func(gtx C) D {
return p.serviceRow(a, gtx, svc)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
// serverGroup renders one target host and the services pointing at it.
func (p *overviewPage) serverGroup(a *App, gtx C, srv serviceServer) D {
th := a.th
level := LevelOK
if !srv.Online() {
level = LevelFail
}
return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D {
children := []layout.FlexChild{
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return th.StatusDot(gtx, level, false)
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return OneLine(th.Body(srv.Title())).Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
// Only worth showing when it differs from the title,
// i.e. when the peer resolved to a nicer name.
if srv.Peer == nil || srv.Title() == srv.Host {
return D{}
}
return OneLine(th.MonoLabel(SizeCaption, th.P.TextDim, srv.Host)).Layout(gtx)
}),
)
}),
}
for _, svc := range srv.Services {
children = append(children, layout.Rigid(func(gtx C) D {
return p.serviceRow(a, gtx, svc)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
// serviceRow is the name-over-address entry: the address is the thing a user
// actually needs to type somewhere else, so it gets a monospace line of its own
// rather than being folded into the label.
func (p *overviewPage) serviceRow(a *App, gtx C, svc serviceEntry) D {
th := a.th
btn := p.copyBtn(svc.Addr)
if btn.Clicked(gtx) {
a.copyToClipboard(gtx, svc.Addr, "")
}
return layout.Inset{Top: 4, Bottom: 4, Left: SpaceLG}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return IconLink(gtx, gtx.Dp(14), th.P.TextDim)
}),
HGap(SpaceMD),
layout.Flexed(1, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(OneLine(th.Body(svc.Name)).Layout),
layout.Rigid(func(gtx C) D {
if !svc.Broadcast {
return D{}
}
return layout.Inset{Left: SpaceSM}.Layout(gtx, func(gtx C) D {
return th.Chip(gtx, ChipStyle{
Text: th.T(KSvcBroadcast),
Level: LevelInfo,
})
})
}),
)
}),
layout.Rigid(func(gtx C) D {
return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, svc.Addr)).Layout(gtx)
}),
)
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
if svc.Proto == "" {
return D{}
}
return th.Chip(gtx, ChipStyle{Text: strings.ToUpper(svc.Proto)})
}),
HGap(SpaceSM),
layout.Rigid(func(gtx C) D {
return th.IconButton(gtx, btn, IconCopy, LevelNeutral)
}),
)
})
}
+207
View File
@@ -0,0 +1,207 @@
package gui
import (
"testing"
"tslink/core"
)
// TestBuildServicesGroupsByHost pins the two properties the old LAN page got
// wrong: one entry per server (not per rule, and not per IP family), and an
// order that does not depend on map iteration.
func TestBuildServicesGroupsByHost(t *testing.T) {
cfg := &core.Config{
Connect: map[string][]core.ConnectRule{
// Same destination host over two protocols: must collapse into one
// server carrying two services.
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}},
"l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}},
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565", LanMotd: "SFCraft"}},
// Not a tailnet host; it still has to render.
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
},
}
got := buildServices(cfg, core.PeerSnapshot{})
if len(got) != 3 {
t.Fatalf("want 3 servers, got %d: %+v", len(got), got)
}
// Sorted by host.
wantHosts := []string{"a.mc.example", "mc.lxns.net", "server.l4d2.example"}
for i, want := range wantHosts {
if got[i].Host != want {
t.Errorf("server[%d].Host = %q, want %q", i, got[i].Host, want)
}
}
l4d2 := got[2]
if len(l4d2.Services) != 2 {
t.Fatalf("l4d2 should carry both protocols, got %d", len(l4d2.Services))
}
if l4d2.Services[0].Proto != "tcp" || l4d2.Services[1].Proto != "udp" {
t.Errorf("services not ordered by protocol: %+v", l4d2.Services)
}
// No peer resolved: must not claim the server is down.
if !l4d2.Online() {
t.Error("a server with no resolved peer should not render as offline")
}
if l4d2.Title() != "server.l4d2.example" {
t.Errorf("Title() = %q, want the host", l4d2.Title())
}
// LANEnabled defaults to true only for minecraft.
mc := got[0]
if !mc.Services[0].Broadcast {
t.Error("a minecraft rule should be marked as broadcast")
}
if mc.Services[0].Name != "SFCraft" {
t.Errorf("Name = %q, want the lan_motd", mc.Services[0].Name)
}
if got[1].Services[0].Broadcast {
t.Error("a plain udp rule should not be marked as broadcast")
}
// Repeated builds must agree, or the section jitters between frames.
for i := 0; i < 20; i++ {
again := buildServices(cfg, core.PeerSnapshot{})
for j := range again {
if again[j].Host != got[j].Host {
t.Fatalf("ordering is unstable: %q vs %q", again[j].Host, got[j].Host)
}
}
}
}
// TestBuildServicesUsesPeer checks the enrichment path: a resolved peer supplies
// the display name and the online state.
func TestBuildServicesUsesPeer(t *testing.T) {
cfg := &core.Config{
Connect: map[string][]core.ConnectRule{
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565"}},
},
}
snap := core.PeerSnapshot{Peers: []core.PeerInfo{{
ID: "n1", DisplayName: "homelab", Online: false,
Linked: true, LinkTags: []string{"sfcraft"},
}}}
got := buildServices(cfg, snap)
if len(got) != 1 {
t.Fatalf("want 1 server, got %d", len(got))
}
if got[0].Title() != "homelab" {
t.Errorf("Title() = %q, want the peer display name", got[0].Title())
}
if got[0].Online() {
t.Error("an offline peer should make the server render as offline")
}
}
func TestBuildServicesNilConfig(t *testing.T) {
if got := buildServices(nil, core.PeerSnapshot{}); got != nil {
t.Errorf("want nil for a nil config, got %+v", got)
}
}
// TestGroupServices pins the grouping the overview relies on: hosts fronted by
// one tailnet node collapse into a single group, a public destination stays on
// its own, and the hosts within a group keep buildServices' stable order.
func TestGroupServices(t *testing.T) {
cfg := &core.Config{
Connect: map[string][]core.ConnectRule{
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "sfcraft.mc.homelab.ice:25565"}},
"mayday": {{Protocol: "minecraft", LocalPort: 25571, DstAddr: "mayday.mc.homelab.ice:25565"}},
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
// A public host resolves to no peer and must stand alone.
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
},
}
// All three homelab hosts resolve to one subnet router.
snap := core.PeerSnapshot{Peers: []core.PeerInfo{{
ID: "n1", DisplayName: "tsdns-homelab", Online: true,
Linked: true, LinkTags: []string{"sfcraft", "mayday", "l4d2_tcp"},
}}}
groups := groupServices(buildServices(cfg, snap))
if len(groups) != 2 {
t.Fatalf("want 2 groups, got %d: %+v", len(groups), groups)
}
var homelab, relay *serviceGroup
for i := range groups {
switch groups[i].Title() {
case "tsdns-homelab":
homelab = &groups[i]
case "mc.lxns.net":
relay = &groups[i]
}
}
if homelab == nil {
t.Fatal("no group titled tsdns-homelab")
}
if len(homelab.Servers) != 3 {
t.Fatalf("homelab group should carry 3 hosts, got %d", len(homelab.Servers))
}
// Hosts stay host-sorted so the group does not reshuffle between frames.
wantHosts := []string{"mayday.mc.homelab.ice", "server.l4d2.homelab.ice", "sfcraft.mc.homelab.ice"}
for i, w := range wantHosts {
if homelab.Servers[i].Host != w {
t.Errorf("homelab host[%d] = %q, want %q", i, homelab.Servers[i].Host, w)
}
}
if !homelab.Online() {
t.Error("a group behind an online peer must not render as offline")
}
if relay == nil {
t.Fatal("no standalone group for the public relay")
}
if relay.Peer != nil {
t.Error("a public destination must not be attached to a peer")
}
if len(relay.Servers) != 1 {
t.Errorf("standalone group should carry 1 host, got %d", len(relay.Servers))
}
// Grouping must be deterministic: repeated builds agree, or the section
// jitters between frames.
for i := 0; i < 20; i++ {
again := groupServices(buildServices(cfg, snap))
if len(again) != len(groups) {
t.Fatalf("group count is unstable: %d vs %d", len(again), len(groups))
}
for j := range again {
if again[j].Title() != groups[j].Title() {
t.Fatalf("group order is unstable: %q vs %q", again[j].Title(), groups[j].Title())
}
}
}
}
// TestGroupServicesSeparatesPeers checks that two distinct peers do not merge:
// grouping is by node identity, not by a shared DNS suffix.
func TestGroupServicesSeparatesPeers(t *testing.T) {
cfg := &core.Config{
Connect: map[string][]core.ConnectRule{
"a": {{Protocol: "tcp", LocalPort: 1000, DstAddr: "a.homelab.ice:1000"}},
"b": {{Protocol: "tcp", LocalPort: 2000, DstAddr: "b.homelab.ice:2000"}},
},
}
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
{ID: "n1", DisplayName: "box-a", Online: true, Linked: true, LinkTags: []string{"a"}},
{ID: "n2", DisplayName: "box-b", Online: true, Linked: true, LinkTags: []string{"b"}},
}}
groups := groupServices(buildServices(cfg, snap))
if len(groups) != 2 {
t.Fatalf("want 2 groups for 2 distinct peers, got %d", len(groups))
}
for _, g := range groups {
if len(g.Servers) != 1 {
t.Errorf("group %q should carry 1 host, got %d", g.Title(), len(g.Servers))
}
}
}
+312
View File
@@ -0,0 +1,312 @@
//go:build shots
package gui
import (
"image"
"image/png"
"log/slog"
"net/netip"
"os"
"testing"
"time"
"gioui.org/font"
"gioui.org/gpu/headless"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"tslink/core"
"tslink/netdiag"
)
// Offscreen renders of the changed UI, for eyeballing what no assertion can
// capture — glyph coverage at bold weights, legend wrapping, how full the chart
// looks with only a few samples.
//
// go test ./gui/ -tags shots -run TestShots
//
// Build-tagged so the normal suite stays GPU-free and font-config independent.
const shotDir = "/tmp/tslink-shots"
func shoot(t *testing.T, th *Theme, name string, size image.Point, w func(gtx C) D) {
t.Helper()
win, err := headless.NewWindow(size.X, size.Y)
if err != nil {
t.Skipf("no GPU backend: %v", err)
}
defer win.Release()
var r input.Router
ops := new(op.Ops)
// Two frames: the second takes the paths that depend on widget state.
for i := 0; i < 2; i++ {
ops.Reset()
gtx := layout.Context{
Ops: ops,
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
Now: time.Now(),
Source: r.Source(),
}
paint.Fill(gtx.Ops, th.P.Bg)
w(gtx)
if err := win.Frame(ops); err != nil {
t.Fatalf("frame: %v", err)
}
}
img := image.NewRGBA(image.Rectangle{Max: size})
if err := win.Screenshot(img); err != nil {
t.Fatalf("screenshot: %v", err)
}
f, err := os.Create(shotDir + "/" + name + ".png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
t.Logf("wrote %s/%s.png", shotDir, name)
}
// realTheme builds the theme the way the app does, including the host's CJK
// font. Unlike testTheme this deliberately depends on the local font config —
// that is the thing under inspection.
func realTheme(t *testing.T) *Theme {
t.Helper()
fonts := LoadFonts()
if !fonts.HasCJK {
t.Skip("no CJK font on this host")
}
faces, err := LoadCJKFaces(fonts.CJKPath, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("cjk: %v", err)
}
fonts.Collection = append(fonts.Collection, faces...)
th := NewTheme(fonts, true)
th.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
th.Lang = LangZH
return th
}
func shotApp(t *testing.T, th *Theme) *App {
a := testApp(t)
a.th = th
return a
}
func TestShots(t *testing.T) {
if err := os.MkdirAll(shotDir, 0o755); err != nil {
t.Fatal(err)
}
th := realTheme(t)
// --- 1. CJK at every weight the UI uses -------------------------------
// The bug was that only weight 400 had a CJK face, so everything below
// rendered as tofu boxes. All five lines must show Chinese glyphs.
t.Run("cjk-weights", func(t *testing.T) {
weights := []struct {
w font.Weight
name string
}{
{font.Normal, "Normal 正文:延迟图谱 已关联 本机服务"},
{font.Medium, "Medium 按钮:重试 导出日志 刷新"},
{font.SemiBold, "SemiBold 标题:网络诊断 节点延迟"},
{font.Bold, "Bold 强调:局域网 转发规则"},
}
shoot(t, th, "cjk-weights", image.Pt(560, 200), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
children := make([]layout.FlexChild, 0, len(weights)*2)
for _, w := range weights {
children = append(children, layout.Rigid(func(gtx C) D {
l := th.Text(SizeSubtitle, th.P.TextPri, w.name)
l.Font.Weight = w.w
return l.Layout(gtx)
}), VGap(SpaceSM))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
})
})
// --- 2. Splash, normal and stuck --------------------------------------
for _, tc := range []struct {
name string
age time.Duration
}{
{"splash", 10 * time.Second},
{"splash-stuck", 45 * time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
a := shotApp(t, th)
steps := splashTestSteps()
for i := range steps {
if steps[i].State == core.StepRunning {
steps[i].Started = time.Now().Add(-tc.age)
}
}
st := core.State{
Phase: core.PhaseStarting, Steps: steps,
StartedAt: time.Now().Add(-tc.age),
}
shoot(t, th, tc.name, image.Pt(460, 450), func(gtx C) D {
return a.splash.Layout(a, gtx, st)
})
})
}
// --- 3. Chart + legend with 8 series and only 30s of history ----------
// Previously this filled ~2.5% of the plot and clipped the legend.
t.Run("chart", func(t *testing.T) {
a := shotApp(t, th)
p := a.peers
series := p.buildSeries(th, shotPeers())
shoot(t, th, "chart", image.Pt(760, 400), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return p.chartCard(a, gtx, series)
})
})
})
// --- 3b. Stat tiles: equal height with and without a hint -------------
t.Run("stat-tiles", func(t *testing.T) {
a := shotApp(t, th)
st := readyState(t)
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
{ID: "n1", DisplayName: "a", Online: true, Linked: true},
}}
servers := buildServices(st.Config, snap)
shoot(t, th, "stat-tiles", image.Pt(1000, 160), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.overview.statRow(a, gtx, st, snap, servers)
})
})
})
// --- 4. Services card grouped per server ------------------------------
t.Run("services", func(t *testing.T) {
a := shotApp(t, th)
cfg := &core.Config{Connect: map[string][]core.ConnectRule{
"sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "sfcraft.mc.homelab.ice:25565", LanMotd: "SFCraft Vanilla | 原版生电 1.21.8"}},
"mayday": {{Protocol: "minecraft", LocalPort: 25571, DstAddr: "mayday.mc.homelab.ice:25565"}},
"voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}},
"l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
"l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}},
}}
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
// One subnet router fronts every *.homelab.ice host, so they collapse
// under a single tsdns-homelab header with the hosts nested beneath.
{ID: "n1", DisplayName: "tsdns-homelab", Online: true, Linked: true,
LinkTags: []string{"sfcraft", "mayday", "l4d2_tcp", "l4d2_udp"}},
}}
servers := buildServices(cfg, snap)
shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D {
return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return a.overview.servicesCard(a, gtx, servers)
})
})
})
}
// TestShotsDiag renders the UDP table, which must name servers rather than
// print bare resolved addresses.
func TestShotsDiag(t *testing.T) {
if err := os.MkdirAll(shotDir, 0o755); err != nil {
t.Fatal(err)
}
th := realTheme(t)
a := shotApp(t, th)
a.current = pageDiag
st := readyState(t)
a.diag.report = diagShotReport()
shoot(t, th, "diag-udp", image.Pt(1120, 900), func(gtx C) D {
return a.diag.Layout(a, gtx, st)
})
}
// diagShotReport is a healthy report whose only complaint is an HTTP-only
// egress split — the case that must read as a yellow "may affect", not a red
// "is affecting".
func diagShotReport() *netdiag.Report {
rep := &netdiag.Report{
StartedAt: time.Now().Add(-18 * time.Second),
Duration: 17 * time.Second,
UDP: netdiag.UDPReport{
Status: netdiag.StatusOK, Summary: "UDP 可用", V4OK: true,
CNReachable: 2, CNTotal: 2, IntlReachabl: 1, IntlTotal: 2,
Probes: []netdiag.UDPProbe{
{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米",
Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond,
Mapped: netip.MustParseAddrPort("1.2.3.4:54321")},
{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米",
Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond,
Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")},
{Host: "stun.chat.bilibili.com:3478", Target: "203.107.1.33:3478", Name: "哔哩哔哩",
Region: netdiag.RegionCN, OK: true, RTT: 21 * time.Millisecond,
Mapped: netip.MustParseAddrPort("1.2.3.4:54322")},
{Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google",
Region: netdiag.RegionIntl, Err: "i/o timeout"},
},
},
NAT: netdiag.NATReport{
Status: netdiag.StatusOK, Type: netdiag.NATFullCone,
Mapping: netdiag.BehaviorEndpointIndependent,
Filtering: netdiag.BehaviorUnknown,
},
Overseas: netdiag.OverseasReport{Status: netdiag.StatusOK, Summary: "境外可达"},
Egress: netdiag.EgressReport{
Observations: []netdiag.EgressObservation{
{Method: netdiag.MethodSTUN, Source: "stun.miwifi.com:3478", IP: netip.MustParseAddr("1.2.3.4")},
{Method: netdiag.MethodHTTPv4, Source: "https://example/ip", IP: netip.MustParseAddr("5.6.7.8")},
},
},
}
eg := &rep.Egress
eg.UniqueIPs = []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")}
eg.Divergent = true
eg.DivergentSTUN = false
eg.Status = netdiag.StatusWarn
eg.Summary = "出口 IP 不一致:IPv4 有 2 个(1.2.3.4、5.6.7.8),仅 HTTP 探测存在差异"
rep.Status = netdiag.StatusWarn
rep.Headline = "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接"
return rep
}
// shotPeers fabricates eight linked peers with ~30 seconds of history each —
// the short-uptime case the chart used to render almost entirely blank, and
// enough series to force the legend to wrap.
func shotPeers() []core.PeerInfo {
now := time.Now()
names := []string{
"sfcraft-homelab", "mayday", "l4d2-server", "voice-relay",
"mcp2-survival", "backup-node", "gateway-cn", "storage-nas",
}
peers := make([]core.PeerInfo, 0, len(names))
for i, n := range names {
var samples []core.PeerSample
for k := 0; k < 4; k++ {
samples = append(samples, core.PeerSample{
At: now.Add(time.Duration(-30+k*10) * time.Second),
Latency: time.Duration(18+i*9+k*4) * time.Millisecond,
OK: true,
})
}
peers = append(peers, core.PeerInfo{
ID: n, DisplayName: n, Online: true, Linked: true,
LinkTags: []string{n}, Route: core.RouteDirect,
LastLatency: samples[len(samples)-1].Latency, LatencyOK: true,
Samples: samples,
})
}
return peers
}
+317
View File
@@ -0,0 +1,317 @@
package gui
import (
"image"
"log/slog"
"math"
"time"
"gioui.org/f32"
"gioui.org/font"
"gioui.org/layout"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"tslink/core"
"gioui.org/op/paint"
)
// Window geometry. The splash is sized to just its progress bar and checklist —
// it has nothing else to show, and a loading screen floating in a 1120x740
// window reads as a broken main window rather than as progress. App.layout
// grows the window to the shell dimensions once the service is ready.
const (
splashWindowW unit.Dp = 460
splashWindowH unit.Dp = 450
splashMinW unit.Dp = 380
splashMinH unit.Dp = 380
shellWindowW unit.Dp = 1120
shellWindowH unit.Dp = 740
shellMinW unit.Dp = 880
shellMinH unit.Dp = 560
)
// stuckAfter is how long a single boot step may run before the splash offers
// the log export. Tailscale's first connection legitimately takes several
// seconds, so this has to be long enough not to cry wolf, but short enough that
// someone staring at a hung step is told what to do about it.
const stuckAfter = 20 * time.Second
// splashView is the loading screen. It covers the window until the service is
// up, which is also the window during which the CJK font is parsed and
// tailscale negotiates its first connection — both slow enough that showing a
// bare grey rectangle would read as a hang.
type splashView struct {
retry widget.Clickable
export widget.Clickable
// list keeps the panel reachable on short windows. Without it the retry
// button — the one control on this screen — falls off the bottom edge once
// the checklist and an error message are both showing.
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
}
}
// stalled reports whether a step has been running long enough to look stuck.
func stalled(st core.State) bool {
for _, step := range st.Steps {
if step.State == core.StepRunning && step.Elapsed() >= stuckAfter {
return true
}
}
return false
}
func (s *splashView) Layout(a *App, gtx C, st core.State) D {
th := a.th
paint.Fill(gtx.Ops, th.P.Bg)
if s.retry.Clicked(gtx) && a.opt.Supervisor != nil {
a.opt.Supervisor.Restart()
}
if s.export.Clicked(gtx) {
s.exportLogs(a)
}
// A single-element list: centred when it fits, scrollable when the window is
// too short for the checklist plus an error message.
return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D {
return layout.Center.Layout(gtx, func(gtx C) D {
gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(400))
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Inset{
Top: SpaceLG, Bottom: SpaceLG, Left: SpaceMD, Right: SpaceMD,
}.Layout(gtx, func(gtx C) D {
return s.panel(a, gtx, st)
})
})
})
}
// exportLogs writes the current buffer to a file and reports where it went.
// This is the splash's replacement for the live log tail: someone looking at a
// stuck boot needs the log in a file they can attach, not on screen.
func (s *splashView) exportLogs(a *App) {
if a.opt.Logs == nil {
return
}
content := a.opt.Logs.ExportText(core.ExportOptions{
Header: a.diagnosticHeader(),
// Debug and up: a stuck boot is exactly when the quiet records matter.
Query: core.LogQuery{MinLevel: slog.LevelDebug},
})
path, err := saveLogFile(content)
if err != nil {
a.notify(a.th.T(KError)+": "+err.Error(), LevelFail)
return
}
a.notify(path, LevelOK)
a.reveal(path)
}
func (s *splashView) panel(a *App, gtx C, st core.State) D {
th := a.th
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
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)
}),
)
}
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 {
// A running step shows its timer once it is slow enough to be
// worth watching; a finished one shows what it cost.
switch {
case step.State == core.StepRunning && step.Elapsed() >= time.Second:
case step.State == core.StepDone && step.Elapsed() >= 100*time.Millisecond:
default:
return D{}
}
col := th.P.TextDim
if step.State == core.StepRunning && step.Elapsed() >= stuckAfter {
col = th.P.Warn
}
return th.MonoLabel(SizeCaption, col, FormatLatency(step.Elapsed())).Layout(gtx)
}),
)
})
}
func (s *splashView) footer(a *App, gtx C, st core.State) D {
th := a.th
stuck := stalled(st)
return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
switch st.Phase {
case core.PhaseError:
// Error text and the retry button sit side by side: stacking
// them pushes the only control on this screen below the fold
// on a short window.
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:
hint, col := th.T(KSplashHint), th.P.TextDim
if stuck {
hint, col = th.T(KSplashStuckHint), th.P.Warn
}
l := th.Text(SizeCaption, col, hint)
l.Alignment = text.Middle
l.MaxLines = 3
return l.Layout(gtx)
}
}),
VGap(SpaceMD),
layout.Rigid(func(gtx C) D {
if a.opt.Logs == nil {
return D{}
}
// Promoted once something looks stuck: that is the moment the
// log is worth exporting.
kind := ButtonGhost
if stuck || st.Phase == core.PhaseError {
kind = ButtonSubtle
}
gtx.Constraints.Min.X = 0
return th.Button(gtx, &s.export, ButtonStyle{
Kind: kind,
Text: th.T(KSplashExportLog),
Icon: IconSave,
})
}),
)
})
}
+268
View File
@@ -0,0 +1,268 @@
package gui
import (
"image/color"
"gioui.org/font"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget/material"
)
// Spacing scale. Every gap in the UI is one of these; ad-hoc values are what
// makes an interface feel noisy.
const (
SpaceXS unit.Dp = 4
SpaceSM unit.Dp = 8
SpaceMD unit.Dp = 12
SpaceLG unit.Dp = 16
SpaceXL unit.Dp = 24
Space2XL unit.Dp = 32
)
// Corner radii.
const (
RadiusSM unit.Dp = 6
RadiusMD unit.Dp = 10
RadiusLG unit.Dp = 14
RadiusPill unit.Dp = 999
)
// Type scale. Only five sizes exist so hierarchy stays legible when a panel is
// dense with numbers.
const (
SizeDisplay unit.Sp = 23
SizeTitle unit.Sp = 17
SizeSubtitle unit.Sp = 14
SizeBody unit.Sp = 13
SizeCaption unit.Sp = 11.5
SizeMono unit.Sp = 12
)
// Palette holds every colour the UI is allowed to use.
type Palette struct {
// Surfaces, from furthest back to nearest front.
Bg color.NRGBA
BgElevated color.NRGBA
Surface color.NRGBA
SurfaceHi color.NRGBA
Border color.NRGBA
BorderHi color.NRGBA
// Three text weights carry the whole information hierarchy: primary for
// values, secondary for labels, dim for metadata.
TextPri color.NRGBA
TextSec color.NRGBA
TextDim color.NRGBA
Accent color.NRGBA
AccentDim color.NRGBA
AccentFg color.NRGBA
OK color.NRGBA
Warn color.NRGBA
Fail color.NRGBA
Info color.NRGBA
// Series colours for the latency chart, in assignment order. They are
// distinguishable at 2px stroke width and stay distinct in both themes.
Series []color.NRGBA
// Scrim dims the app behind the loading overlay.
Scrim color.NRGBA
}
func rgb(v uint32) color.NRGBA {
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xFF}
}
// DarkPalette is the default. The app is a diagnostic tool that people leave
// open in the background, so it defaults to the low-glare theme.
func DarkPalette() Palette {
return Palette{
Bg: rgb(0x0E1116),
BgElevated: rgb(0x141922),
Surface: rgb(0x1A202B),
SurfaceHi: rgb(0x222A38),
Border: rgb(0x252E3B),
BorderHi: rgb(0x364153),
TextPri: rgb(0xE7EBF3),
TextSec: rgb(0x9AA4B8),
TextDim: rgb(0x69738A),
Accent: rgb(0x4C8DFF),
AccentDim: rgb(0x27447A),
AccentFg: rgb(0xFFFFFF),
OK: rgb(0x3DCE87),
Warn: rgb(0xF0A93B),
Fail: rgb(0xFF6B6B),
Info: rgb(0x8B9BFF),
Series: []color.NRGBA{
rgb(0x4C8DFF), rgb(0x2DD4BF), rgb(0xA78BFA), rgb(0xFBBF24),
rgb(0xF472B6), rgb(0xA3E635), rgb(0x38BDF8), rgb(0xFB923C),
},
Scrim: color.NRGBA{R: 0x08, G: 0x0A, B: 0x0E, A: 0xC4},
}
}
// LightPalette mirrors the dark one for people working in bright rooms.
func LightPalette() Palette {
return Palette{
Bg: rgb(0xF6F7F9),
BgElevated: rgb(0xFFFFFF),
Surface: rgb(0xFFFFFF),
SurfaceHi: rgb(0xF0F2F6),
Border: rgb(0xE3E7ED),
BorderHi: rgb(0xCFD5DE),
TextPri: rgb(0x111826),
TextSec: rgb(0x4A5568),
TextDim: rgb(0x818C9E),
Accent: rgb(0x2563EB),
AccentDim: rgb(0xBFD3FA),
AccentFg: rgb(0xFFFFFF),
OK: rgb(0x0F9D58),
Warn: rgb(0xC77700),
Fail: rgb(0xD93636),
Info: rgb(0x4F5DD1),
Series: []color.NRGBA{
rgb(0x2563EB), rgb(0x0D9488), rgb(0x7C3AED), rgb(0xD97706),
rgb(0xDB2777), rgb(0x65A30D), rgb(0x0284C7), rgb(0xEA580C),
},
Scrim: color.NRGBA{R: 0x1A, G: 0x1F, B: 0x28, A: 0xB8},
}
}
// Theme bundles the Gio material theme with this app's design tokens.
type Theme struct {
*material.Theme
P Palette
Dark bool
// Mono is the typeface used for addresses, ports and log lines, where
// column alignment matters more than typographic polish.
Mono font.Typeface
// HasCJK reports whether a font with Chinese coverage was found. When it
// is false the UI falls back to English labels rather than rendering
// tofu boxes.
HasCJK bool
// Lang selects the label set.
Lang Lang
}
// NewTheme builds a theme from a shaper and font collection produced by
// [LoadFonts].
func NewTheme(fonts *FontSet, dark bool) *Theme {
mt := material.NewTheme()
mt.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
mt.TextSize = SizeBody
mt.Face = fonts.UI
mt.FingerSize = 26
th := &Theme{
Theme: mt,
Dark: dark,
Mono: fonts.Mono,
HasCJK: fonts.HasCJK,
Lang: LangEN,
}
if fonts.HasCJK {
th.Lang = LangZH
}
th.SetDark(dark)
return th
}
// SetDark switches palettes and keeps the embedded material palette in sync so
// stock Gio widgets pick up the right colours too.
func (t *Theme) SetDark(dark bool) {
t.Dark = dark
if dark {
t.P = DarkPalette()
} else {
t.P = LightPalette()
}
t.Theme.Palette = material.Palette{
Bg: t.P.Bg,
Fg: t.P.TextPri,
ContrastBg: t.P.Accent,
ContrastFg: t.P.AccentFg,
}
}
// T looks up a localised string. It is a method on Theme so call sites stay
// short: th.T(K.Peers).
func (t *Theme) T(k Key) string { return Tr(t.Lang, k) }
// StatusColor maps a traffic-light verdict onto the palette.
func (t *Theme) StatusColor(s StatusLevel) color.NRGBA {
switch s {
case LevelOK:
return t.P.OK
case LevelWarn:
return t.P.Warn
case LevelFail:
return t.P.Fail
case LevelInfo:
return t.P.Info
default:
return t.P.TextDim
}
}
// StatusLevel is the UI-side severity, deliberately decoupled from
// netdiag.Status so widgets do not depend on the diagnostics package.
type StatusLevel int
const (
LevelNeutral StatusLevel = iota
LevelOK
LevelWarn
LevelFail
LevelInfo
)
// SeriesColor returns a stable chart colour for index i.
func (t *Theme) SeriesColor(i int) color.NRGBA {
if len(t.P.Series) == 0 {
return t.P.Accent
}
return t.P.Series[i%len(t.P.Series)]
}
// WithAlpha returns c with its alpha scaled by a (0..1).
func WithAlpha(c color.NRGBA, a float32) color.NRGBA {
if a < 0 {
a = 0
}
if a > 1 {
a = 1
}
c.A = uint8(float32(c.A) * a)
return c
}
// Mix blends a into b by t (0 returns a, 1 returns b).
func Mix(a, b color.NRGBA, t float32) color.NRGBA {
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
lerp := func(x, y uint8) uint8 { return uint8(float32(x) + (float32(y)-float32(x))*t) }
return color.NRGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: lerp(a.A, b.A)}
}
+20
View File
@@ -0,0 +1,20 @@
package gui
import (
"runtime"
"time"
)
// runtimeInfo describes the host, for diagnostic bundle headers.
func runtimeInfo() string {
return runtime.GOOS + "/" + runtime.GOARCH + " go" + runtime.Version()[2:]
}
// timeSince is time.Since, wrapped so tests can reason about it and so call
// sites in layout code read consistently.
func timeSince(t time.Time) time.Duration {
if t.IsZero() {
return 0
}
return time.Since(t)
}
+955
View File
@@ -0,0 +1,955 @@
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
// 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)})
}
// 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)
}
// WrapRow lays children out left to right, starting a new line whenever the
// next child would not fit. gap is the vertical space between lines; horizontal
// spacing is left to the children's own insets.
//
// Gio's Flex does not wrap — it divides the available space among its children
// and lets the overflow clip — and gioui.org/x (which has outlay.FlowWrap) is
// not a dependency, so this measures each child with op.Record and packs the
// results greedily. Children are recorded once and replayed at their final
// offset, so the cost is one layout pass, not two.
func WrapRow(gtx C, gap unit.Dp, children []layout.Widget) D {
if len(children) == 0 {
return D{}
}
maxW := gtx.Constraints.Max.X
// Each child is measured against the full width but with no minimum, so a
// child wider than the row still gets a line to itself rather than a
// negative constraint.
cgtx := gtx
cgtx.Constraints.Min = image.Point{}
type placed struct {
call op.CallOp
dims D
x, y int
}
var (
items []placed
rowW, rowH int
total, lineNo int
)
vgap := gtx.Dp(gap)
for _, w := range children {
macro := op.Record(gtx.Ops)
dims := w(cgtx)
call := macro.Stop()
if rowW > 0 && rowW+dims.Size.X > maxW {
// Commit the line and start the next one.
total += rowH + vgap
rowW, rowH = 0, 0
lineNo++
}
items = append(items, placed{call: call, dims: dims, x: rowW, y: total})
rowW += dims.Size.X
rowH = max(rowH, dims.Size.Y)
}
total += rowH
for _, it := range items {
off := op.Offset(image.Pt(it.x, it.y)).Push(gtx.Ops)
it.call.Add(gtx.Ops)
off.Pop()
}
return D{Size: image.Pt(maxW, total)}
}
// Divider draws a hairline separator.
func (t *Theme) Divider(gtx C) D {
h := max(gtx.Dp(1), 1)
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]) + "…"
}
+6 -1
View File
@@ -28,6 +28,11 @@ func serviceLogic(configPath string, isTsnetDebug bool, configURL string, logger
os.Exit(1)
}
core.SetDoHServers(cfg.DNS.DoHServers)
if len(cfg.DNS.DoHServers) > 0 {
logger.Info("DNS-over-HTTPS fallback enabled", "servers", cfg.DNS.DoHServers)
}
ctx, cancelAll := context.WithCancel(context.Background())
defer cancelAll()
logger.Info("initializing tsnet server")
@@ -43,7 +48,7 @@ func serviceLogic(configPath string, isTsnetDebug bool, configURL string, logger
}
logger.Info("tsnet server initialized")
core.PresolveConnectRulesDstAddr(cfg.Connect, logger, srv)
core.NormalizeConnectRulesDstAddr(ctx, srv, cfg.Connect, logger)
core.StartForwarders(ctx, srv, cfg.Forward)
core.StartConnectors(ctx, srv, cfg.Connect)
+361
View File
@@ -0,0 +1,361 @@
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)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
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 {
ips := make([]netip.Addr, 0, len(os))
for _, o := range os {
ips = append(ips, o.IP)
}
return egDedupAddrs(ips)
}
// egDedupAddrs drops invalid and repeated addresses and sorts the rest.
func egDedupAddrs(ips []netip.Addr) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(ips))
var out []netip.Addr
for _, ip := range ips {
if !ip.IsValid() {
continue
}
if _, dup := seen[ip]; dup {
continue
}
seen[ip] = struct{}{}
out = append(out, 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
}
// egDivergentSTUN applies the same test to the STUN observations alone.
//
// Only these travel the UDP path Tailscale actually uses, so a split visible
// here is the one that costs you a direct connection. HTTP-only disagreement
// says something about the browser path, not the tunnel.
func egDivergentSTUN(obs []EgressObservation) bool {
var ips []netip.Addr
for _, o := range obs {
if o.Method != MethodSTUN || o.Err != "" || !o.IP.IsValid() {
continue
}
ips = append(ips, o.IP.Unmap())
}
return egDivergent(egDedupAddrs(ips))
}
// egFinish derives Status and the one-line Chinese Summary from the collected
// addresses. It is called again by [AnnotateGeo] once geolocation is known, so
// it must stay idempotent.
func egFinish(rep *EgressReport) {
v4, v6 := egSplitFamilies(rep.UniqueIPs)
switch {
case len(rep.UniqueIPs) == 0:
rep.Status = StatusFail
case rep.DivergentSTUN:
// The UDP egress itself varies, which is what actually costs a direct
// connection — a stronger claim than "some probe disagreed".
rep.Status = StatusFail
case rep.Divergent:
rep.Status = StatusWarn
default:
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)))
}
if rep.DivergentSTUN {
fmt.Fprintf(&b, "出口 IP 不一致:%s,STUN 探测本身就看到多个地址,代理、VPN 或多线接入正在拆分 UDP 流量,对端看到的地址取决于走哪条链路",
strings.Join(parts, ""))
} else {
// HTTP saw a split that STUN did not: the web path is proxied but
// the UDP path Tailscale uses may well be intact.
fmt.Fprintf(&b, "出口 IP 不一致:%s,仅 HTTP 探测存在差异,STUN(UDP)出口一致,多为浏览器代理或分流规则所致,通常不影响打洞",
strings.Join(parts, ""))
}
default:
var parts []string
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, "、")
}
+154
View File
@@ -0,0 +1,154 @@
package netdiag
import (
"net/netip"
"testing"
)
func obs(m EgressMethod, ip string) EgressObservation {
o := EgressObservation{Method: m}
if ip != "" {
o.IP = netip.MustParseAddr(ip)
}
return o
}
// TestEgressDivergenceSeverity pins the distinction the verdict depends on:
// STUN disagreeing with itself is a hard failure for hole punching, whereas
// HTTP-only disagreement is a proxy artefact and must stay a warning.
func TestEgressDivergenceSeverity(t *testing.T) {
cases := []struct {
name string
obs []EgressObservation
wantDivergent bool
wantSTUN bool
wantStatus Status
}{
{
name: "single egress",
obs: []EgressObservation{obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv4, "1.2.3.4")},
wantStatus: StatusOK,
},
{
name: "dual stack is not divergence",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv6, "2001:db8::1"),
},
wantStatus: StatusOK,
},
{
name: "http-only split warns",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodHTTPv4, "5.6.7.8"),
},
wantDivergent: true,
wantSTUN: false,
wantStatus: StatusWarn,
},
{
name: "stun split fails",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodSTUN, "5.6.7.8"),
},
wantDivergent: true,
wantSTUN: true,
wantStatus: StatusFail,
},
{
name: "proxy split alone stays a warning",
obs: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
obs(MethodHTTPProxy, "9.9.9.9"),
},
wantDivergent: true,
wantSTUN: false,
wantStatus: StatusWarn,
},
{
name: "no observations fails",
obs: []EgressObservation{obs(MethodSTUN, "")},
wantStatus: StatusFail,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rep := EgressReport{Observations: tc.obs}
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
if rep.Divergent != tc.wantDivergent {
t.Errorf("Divergent = %v, want %v", rep.Divergent, tc.wantDivergent)
}
if rep.DivergentSTUN != tc.wantSTUN {
t.Errorf("DivergentSTUN = %v, want %v", rep.DivergentSTUN, tc.wantSTUN)
}
if rep.Status != tc.wantStatus {
t.Errorf("Status = %v, want %v (summary: %s)", rep.Status, tc.wantStatus, rep.Summary)
}
})
}
}
// A STUN observation that errored carries no address and must not be mistaken
// for a second egress.
func TestEgressDivergentSTUNIgnoresErrors(t *testing.T) {
o := []EgressObservation{
obs(MethodSTUN, "1.2.3.4"),
{Method: MethodSTUN, Err: "timeout"},
}
if egDivergentSTUN(o) {
t.Error("a failed STUN probe must not count as a second egress IP")
}
}
// egFinish runs again after geolocation, so it must not drift.
func TestEgFinishIdempotent(t *testing.T) {
rep := EgressReport{Observations: []EgressObservation{
obs(MethodSTUN, "1.2.3.4"), obs(MethodSTUN, "5.6.7.8"),
}}
rep.UniqueIPs = egUniqueIPs(rep.Observations)
rep.Divergent = egDivergent(rep.UniqueIPs)
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
egFinish(&rep)
first, status := rep.Summary, rep.Status
egFinish(&rep)
if rep.Summary != first || rep.Status != status {
t.Errorf("egFinish is not idempotent:\n first: %s (%v)\nsecond: %s (%v)",
first, status, rep.Summary, rep.Status)
}
}
// TestHeadlineDivergence checks the two verdict strings the user sees.
//
// The report is otherwise healthy: earlier branches (blocked UDP, symmetric
// NAT, unreachable overseas) all outrank egress and would mask it.
func healthyReport(eg EgressReport) *Report {
return &Report{
UDP: UDPReport{V4OK: true},
NAT: NATReport{Type: NATFullCone},
Overseas: OverseasReport{Status: StatusOK},
Egress: eg,
}
}
func TestHeadlineDivergence(t *testing.T) {
strong := healthyReport(EgressReport{Divergent: true, DivergentSTUN: true})
if got, lvl := headline(strong); got != "STUN 检测到多个出口 IP,代理或分流工具正在影响连接" {
t.Errorf("strong headline = %q", got)
} else if lvl != StatusFail {
t.Errorf("strong headline severity = %v, want fail", lvl)
}
weak := healthyReport(EgressReport{Divergent: true})
if got, lvl := headline(weak); got != "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接" {
t.Errorf("weak headline = %q", got)
} else if lvl != StatusWarn {
t.Errorf("weak headline severity = %v, want warn", lvl)
}
}
+470
View File
@@ -0,0 +1,470 @@
package netdiag
// This file resolves public IP addresses to a rough location and network
// operator.
//
// PRIVACY: every lookup here sends the user's own public IP address to a
// third-party API (ipinfo.io, ip-api.com, api.ip.sb). Those services see the
// address, the timestamp and our source IP — which for a direct query is that
// very same address. Nothing else is sent: no hostname, no tailnet identity,
// no credentials. Callers who are not comfortable with that must set
// [Options.SkipGeo], which exists precisely for this reason, and no request in
// this file will be made. The ipinfo token, when configured, is passed as a
// query parameter to that provider only and is never logged or stored in a
// report.
//
// Providers are tried in order and the first usable answer wins. The order is
// not arbitrary: ipinfo.io is the most accurate but rate-limits hard without a
// token, ip-api.com is HTTP-only on the free tier yet stays reachable from
// mainland China, and api.ip.sb is the last resort.
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/netip"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
// geoTimeout bounds one provider query.
geoTimeout = 4 * time.Second
// geoMaxBody caps a provider response. Well-behaved answers are a few
// hundred bytes; the cap guards against a hijacked or error page.
geoMaxBody = 64 << 10
// geoMaxInflight bounds concurrent lookups in [AnnotateGeo]. Kept low
// because the free tiers of these APIs rate-limit per source IP.
geoMaxInflight = 4
)
// geoCGNAT is RFC 6598 shared address space. Tailscale also allocates node
// addresses out of it, and either way no geolocation provider can say anything
// useful about such an address.
var geoCGNAT = netip.MustParsePrefix("100.64.0.0/10")
// geoSkipErr is reported for addresses that are not globally routable.
const geoSkipErr = "私有地址,跳过查询"
func geoLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/geo"))
}
// geoSkippable reports whether ip is not worth (or not safe to) look up:
// loopback, RFC1918/ULA private, link-local, CGNAT, multicast or unspecified.
func geoSkippable(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsValid() {
return true
}
if ip.Is4() && geoCGNAT.Contains(ip) {
return true
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// geoProvider is one geolocation backend.
type geoProvider struct {
name string
// fetch fills a GeoInfo from the provider, or returns an error so the next
// provider is tried.
fetch func(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error)
}
// geoProviders returns the backends in the order they are tried.
func geoProviders() []geoProvider {
return []geoProvider{
{name: "ipinfo.io", fetch: geoFetchIPInfo},
{name: "ip-api.com", fetch: geoFetchIPAPI},
{name: "ip.sb", fetch: geoFetchIPSB},
}
}
// LookupGeo resolves one address to a location and operator.
//
// Providers are tried in order until one answers; the returned GeoInfo names
// the provider that did in its Provider field. When all of them fail, Provider
// is empty and Err holds the last error. Addresses that are not globally
// routable are never sent anywhere: they come back immediately with Err set to
// "私有地址,跳过查询".
//
// token is an optional ipinfo.io API token; it raises that provider's rate
// limit and is never logged.
//
// Each provider gets its own ~4s budget, so the whole call is bounded even if
// every backend hangs. See the privacy note at the top of this file.
func LookupGeo(ctx context.Context, ip netip.Addr, token string, logger *slog.Logger) GeoInfo {
log := geoLog(logger)
ip = ip.Unmap().WithZone("")
if geoSkippable(ip) {
return GeoInfo{IP: ip, Err: geoSkipErr}
}
var lastErr string
for _, p := range geoProviders() {
if ctx.Err() != nil {
return GeoInfo{IP: ip, Err: rchErrText(ctx.Err())}
}
pctx, cancel := context.WithTimeout(ctx, geoTimeout)
info, err := p.fetch(pctx, ip, token)
cancel()
if err != nil {
lastErr = fmt.Sprintf("%s: %s", p.name, rchErrText(err))
log.With(
slog.String("ip", ip.String()),
slog.String("provider", p.name),
slog.String("error", rchErrText(err)),
).Debug("geo provider failed")
continue
}
info.IP = ip
info.Provider = p.name
log.With(
slog.String("ip", ip.String()),
slog.String("provider", p.name),
slog.String("country", info.Country),
slog.String("asn", info.ASN),
).Debug("resolved ip location")
return info
}
if lastErr == "" {
lastErr = "no geolocation provider answered"
}
return GeoInfo{IP: ip, Err: lastErr}
}
// AnnotateGeo fills rep.Geo and rep.Countries for every address in
// rep.UniqueIPs and recomputes rep.Summary. It is a no-op when the report has
// no addresses.
//
// Lookups run concurrently but at most [geoMaxInflight] at a time, since the
// free tiers rate-limit per source IP. Results are sorted by address and the
// country list is deduplicated, so repeated runs render identically.
//
// This function performs third-party network requests; see the privacy note at
// the top of this file and [Options.SkipGeo].
func AnnotateGeo(ctx context.Context, rep *EgressReport, token string, logger *slog.Logger) {
if rep == nil || len(rep.UniqueIPs) == 0 {
return
}
log := geoLog(logger)
var (
mu sync.Mutex
out = make([]GeoInfo, 0, len(rep.UniqueIPs))
wg sync.WaitGroup
sem = make(chan struct{}, geoMaxInflight)
)
for _, ip := range rep.UniqueIPs {
wg.Add(1)
go func(ip netip.Addr) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
mu.Lock()
out = append(out, GeoInfo{IP: ip, Err: rchErrText(ctx.Err())})
mu.Unlock()
return
}
info := LookupGeo(ctx, ip, token, log)
mu.Lock()
out = append(out, info)
mu.Unlock()
}(ip)
}
wg.Wait()
sort.Slice(out, func(i, j int) bool { return out[i].IP.Compare(out[j].IP) < 0 })
rep.Geo = out
rep.Countries = geoCountries(out)
egFinish(rep)
log.With(
slog.Int("addrs", len(rep.Geo)),
slog.String("countries", strings.Join(rep.Countries, ",")),
).Debug("annotated egress addresses with geolocation")
}
// geoCountries returns the sorted, deduplicated set of countries seen,
// preferring the ISO code and falling back to the localised name when a
// provider only supplied that.
func geoCountries(gs []GeoInfo) []string {
seen := make(map[string]struct{}, len(gs))
var out []string
for _, g := range gs {
name := strings.TrimSpace(g.Country)
if name == "" {
name = strings.TrimSpace(g.CountryName)
}
if name == "" {
continue
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
sort.Strings(out)
return out
}
// ---------------------------------------------------------------------------
// Provider implementations
// ---------------------------------------------------------------------------
// geoGetJSON fetches url and decodes the body into v. The client uses the
// unforced network and honours the environment proxy: unlike the egress
// probes, we do not care which path the query takes, only that it succeeds.
func geoGetJSON(ctx context.Context, target string, v any) error {
client := newDiagClient("", true, geoTimeout)
defer client.CloseIdleConnections()
hdr := http.Header{}
hdr.Set("Accept", "application/json")
code, body, _, err := diagGet(ctx, client, target, geoMaxBody, hdr)
if err != nil {
return err
}
if code < 200 || code > 299 {
return fmt.Errorf("unexpected status %d", code)
}
if err := json.Unmarshal(body, v); err != nil {
return fmt.Errorf("bad json: %w", err)
}
return nil
}
// geoIPInfoResp is the subset of ipinfo.io's answer we use.
type geoIPInfoResp struct {
IP string `json:"ip"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
CountryName string `json:"country_name"` // only on paid plans
Loc string `json:"loc"`
Org string `json:"org"`
Timezone string `json:"timezone"`
Bogon bool `json:"bogon"`
}
// geoFetchIPInfo queries ipinfo.io. The token, when non-empty, only raises the
// rate limit; it is appended as a query parameter and never logged.
func geoFetchIPInfo(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error) {
target := "https://ipinfo.io/" + url.PathEscape(ip.String()) + "/json"
if token != "" {
target += "?token=" + url.QueryEscape(token)
}
var r geoIPInfoResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if r.Bogon {
return GeoInfo{}, fmt.Errorf("provider reports bogon address")
}
if r.Country == "" && r.Org == "" && r.City == "" {
return GeoInfo{}, fmt.Errorf("empty answer")
}
asn, org := geoSplitOrg(r.Org)
return GeoInfo{
Country: strings.TrimSpace(r.Country),
CountryName: strings.TrimSpace(r.CountryName),
Region: strings.TrimSpace(r.Region),
City: strings.TrimSpace(r.City),
Org: org,
ASN: asn,
Loc: strings.TrimSpace(r.Loc),
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoIPAPIResp is ip-api.com's answer for the field set we request.
type geoIPAPIResp struct {
Status string `json:"status"`
Message string `json:"message"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
RegionName string `json:"regionName"`
City string `json:"city"`
ISP string `json:"isp"`
Org string `json:"org"`
AS string `json:"as"`
Timezone string `json:"timezone"`
}
// geoFetchIPAPI queries ip-api.com. The free tier is HTTP-only, which is also
// why it keeps working from mainland China where the HTTPS providers often do
// not. Answers are requested in Chinese to match the rest of the UI.
func geoFetchIPAPI(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
target := "http://ip-api.com/json/" + url.PathEscape(ip.String()) +
"?lang=zh-CN&fields=status,message,country,countryCode,regionName,city,isp,org,as,timezone"
var r geoIPAPIResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if !strings.EqualFold(r.Status, "success") {
msg := strings.TrimSpace(r.Message)
if msg == "" {
msg = r.Status
}
return GeoInfo{}, fmt.Errorf("query failed: %s", msg)
}
asn, asOrg := geoSplitOrg(r.AS)
org := strings.TrimSpace(r.Org)
if org == "" {
org = strings.TrimSpace(r.ISP)
}
if org == "" {
org = asOrg
}
return GeoInfo{
Country: strings.TrimSpace(r.CountryCode),
CountryName: strings.TrimSpace(r.Country),
Region: strings.TrimSpace(r.RegionName),
City: strings.TrimSpace(r.City),
Org: org,
ASN: asn,
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoIPSBResp is api.ip.sb's answer. ASN comes back as a bare number, so it is
// decoded loosely and normalised by [geoASNText].
type geoIPSBResp struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
Region string `json:"region"`
City string `json:"city"`
ISP string `json:"isp"`
ASN any `json:"asn"`
ASNOrg string `json:"asn_organization"`
Timezone string `json:"timezone"`
Latitude any `json:"latitude"`
Longitude any `json:"longitude"`
}
// geoFetchIPSB queries api.ip.sb, the last-resort provider.
func geoFetchIPSB(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
target := "https://api.ip.sb/geoip/" + url.PathEscape(ip.String())
var r geoIPSBResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if r.CountryCode == "" && r.Country == "" && r.ISP == "" {
return GeoInfo{}, fmt.Errorf("empty answer")
}
org := strings.TrimSpace(r.ISP)
if org == "" {
org = strings.TrimSpace(r.ASNOrg)
}
return GeoInfo{
Country: strings.TrimSpace(r.CountryCode),
CountryName: strings.TrimSpace(r.Country),
Region: strings.TrimSpace(r.Region),
City: strings.TrimSpace(r.City),
Org: org,
ASN: geoASNText(r.ASN),
Loc: geoLocText(r.Latitude, r.Longitude),
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoSplitOrg splits an "AS4134 Chinanet" style string into the ASN and the
// operator name. Either half may be missing, in which case the whole string is
// treated as the operator name.
func geoSplitOrg(s string) (asn, org string) {
s = strings.TrimSpace(s)
if s == "" {
return "", ""
}
head, rest, _ := strings.Cut(s, " ")
if geoLooksLikeASN(head) {
return head, strings.TrimSpace(rest)
}
return "", s
}
// geoLooksLikeASN reports whether s is an "AS####" token.
func geoLooksLikeASN(s string) bool {
if len(s) < 3 || !strings.EqualFold(s[:2], "AS") {
return false
}
_, err := strconv.ParseUint(s[2:], 10, 32)
return err == nil
}
// geoASNText normalises a JSON asn field (number or string) to "AS####".
func geoASNText(v any) string {
var s string
switch n := v.(type) {
case nil:
return ""
case float64:
if n <= 0 {
return ""
}
s = strconv.FormatFloat(n, 'f', -1, 64)
case string:
s = strings.TrimSpace(n)
default:
return ""
}
if s == "" || s == "0" {
return ""
}
if geoLooksLikeASN(s) {
return strings.ToUpper(s[:2]) + s[2:]
}
if _, err := strconv.ParseUint(s, 10, 32); err != nil {
return ""
}
return "AS" + s
}
// geoLocText renders a latitude/longitude pair in ipinfo's "lat,lon" form so
// the Loc field means the same thing whichever provider answered.
func geoLocText(lat, lon any) string {
f := func(v any) (string, bool) {
switch n := v.(type) {
case float64:
return strconv.FormatFloat(n, 'f', -1, 64), true
case string:
s := strings.TrimSpace(n)
return s, s != ""
default:
return "", false
}
}
a, okA := f(lat)
b, okB := f(lon)
if !okA || !okB {
return ""
}
return a + "," + b
}
+381
View File
@@ -0,0 +1,381 @@
package netdiag
import (
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"sort"
"strings"
"sync"
"time"
)
// ifDialTimeout bounds each source-address discovery dial. The dial is to a
// UDP address, so no packet leaves the machine and the kernel answers from its
// routing table immediately; the timeout only guards against a pathological
// resolver or a wedged network stack.
const ifDialTimeout = 2 * time.Second
// ifMaxInflight bounds how many interfaces are inspected concurrently.
const ifMaxInflight = 8
// ifDefaultV4Target and ifDefaultV6Target are well-known anycast resolvers used
// purely as "somewhere on the default route" destinations.
const (
ifDefaultV4Target = "8.8.8.8:80"
ifDefaultV6Target = "[2001:4860:4860::8888]:80"
)
// tailscaleV6Prefix is the ULA range Tailscale assigns to every node.
var tailscaleV6Prefix = netip.MustParsePrefix("fd7a:115c:a1e0::/48")
// cgnatPrefix is RFC 6598 shared address space. Tailscale allocates its IPv4
// node addresses out of 100.64.0.0/10 as well, which is why an address here
// needs the interface name to be classified precisely; see [classifyOnIface].
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
var (
ulaPrefix = netip.MustParsePrefix("fc00::/7")
linkLocalV4Pfx = netip.MustParsePrefix("169.254.0.0/16")
rfc1918Prefixes = []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
}
)
// ifTailscaleIfaceNames are the interface-name prefixes Tailscale (and the
// wireguard/utun devices it rides on) uses across platforms.
var ifTailscaleIfaceNames = []string{"tailscale", "ts", "utun", "wg"}
func ifLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/iface"))
}
// ClassifyAddr buckets an address by reachable scope.
//
// Addresses in 100.64.0.0/10 are reported as [AddrCGNAT] because the range
// alone cannot distinguish a carrier-grade NAT lease from a Tailscale node
// address. [EnumerateInterfaces] refines that verdict using the interface name.
func ClassifyAddr(a netip.Addr) AddrKind {
a = a.Unmap()
switch {
case !a.IsValid():
return AddrLinkLocal // degenerate input; never reachable
case a.IsLoopback():
return AddrLoopback
case a.Is4() && cgnatPrefix.Contains(a):
return AddrCGNAT
case a.Is6() && tailscaleV6Prefix.Contains(a):
return AddrTailscale
case a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || (a.Is4() && linkLocalV4Pfx.Contains(a)):
return AddrLinkLocal
case a.Is4() && isRFC1918(a):
return AddrPrivateV4
case a.Is6() && ulaPrefix.Contains(a):
return AddrULA
case a.Is4():
return AddrGlobalV4
default:
return AddrGlobalV6
}
}
func isRFC1918(a netip.Addr) bool {
for _, p := range rfc1918Prefixes {
if p.Contains(a) {
return true
}
}
return false
}
// classifyOnIface applies [ClassifyAddr] and then corrects the one case the
// address alone cannot decide: Tailscale hands out IPv4 addresses from the
// CGNAT range 100.64.0.0/10, so a 100.x address sitting on an interface named
// tailscale*/ts*/utun*/wg* is a tailnet address rather than a carrier NAT
// lease. The heuristic is name-based because the alternative (asking tailscaled)
// would make this package depend on tailscale.com.
func classifyOnIface(a netip.Addr, iface string) AddrKind {
kind := ClassifyAddr(a)
if kind == AddrCGNAT && isTailscaleIfaceName(iface) {
return AddrTailscale
}
return kind
}
func isTailscaleIfaceName(name string) bool {
n := strings.ToLower(name)
for _, p := range ifTailscaleIfaceNames {
if strings.HasPrefix(n, p) {
return true
}
}
return false
}
// EnumerateInterfaces lists every address bound to every local interface and
// determines which source addresses the kernel would use for default routes.
func EnumerateInterfaces(ctx context.Context, logger *slog.Logger) InterfaceReport {
log := ifLog(logger)
var rep InterfaceReport
ifaces, err := net.Interfaces()
if err != nil {
log.With(slog.String("error", err.Error())).Error("failed to enumerate interfaces")
rep.Err = err.Error()
rep.Status = StatusFail
rep.Summary = "无法枚举本机网络接口"
return rep
}
var (
mu sync.Mutex
addrs []LocalAddr
nIface int
)
sem := make(chan struct{}, ifMaxInflight)
var wg sync.WaitGroup
// Source discovery is independent of enumeration, so run both in parallel.
var v4Src, v6Src netip.Addr
wg.Add(2)
go func() {
defer wg.Done()
v4Src = defaultSource(ctx, "udp4", ifDefaultV4Target, log)
}()
go func() {
defer wg.Done()
v6Src = defaultSource(ctx, "udp6", ifDefaultV6Target, log)
}()
for _, iface := range ifaces {
if ctx.Err() != nil {
break
}
wg.Add(1)
go func(iface net.Interface) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
got := ifaceAddrs(iface, log)
mu.Lock()
if len(got) > 0 {
nIface++
}
addrs = append(addrs, got...)
mu.Unlock()
}(iface)
}
wg.Wait()
rep.Addrs = addrs
rep.DefaultV4Src = v4Src
rep.DefaultV6Src = v6Src
sortLocalAddrs(rep.Addrs)
hasGlobalV6Addr := false
for i := range rep.Addrs {
a := &rep.Addrs[i]
if a.Kind == AddrGlobalV6 {
hasGlobalV6Addr = true
}
if (v4Src.IsValid() && a.Addr == v4Src) || (v6Src.IsValid() && a.Addr == v6Src) {
a.IsDefaultSrc = true
}
}
rep.HasGlobalV6 = hasGlobalV6Addr && v6Src.IsValid() && ClassifyAddr(v6Src) == AddrGlobalV6
finishInterfaceReport(&rep, nIface)
log.With(
slog.Int("interfaces", nIface),
slog.Int("addrs", len(rep.Addrs)),
slog.String("v4_src", addrText(rep.DefaultV4Src)),
slog.String("v6_src", addrText(rep.DefaultV6Src)),
slog.String("status", rep.Status.String()),
).Debug("enumerated local interfaces")
return rep
}
// ifaceAddrs converts one interface's bound addresses into [LocalAddr] entries.
// Errors are logged and swallowed: one unreadable interface must not blank the
// whole panel.
func ifaceAddrs(iface net.Interface, log *slog.Logger) []LocalAddr {
raw, err := iface.Addrs()
if err != nil {
log.With(
slog.String("iface", iface.Name),
slog.String("error", err.Error()),
).Debug("failed to read interface addresses")
return nil
}
hw := ""
if len(iface.HardwareAddr) > 0 {
hw = strings.ToLower(iface.HardwareAddr.String())
}
up := iface.Flags&net.FlagUp != 0
out := make([]LocalAddr, 0, len(raw))
for _, a := range raw {
pfx, ok := toPrefix(a)
if !ok {
continue
}
addr := pfx.Addr().Unmap()
// Keep the zone off the reported address so equality against the
// default-source lookup and the sort order stay stable.
addr = addr.WithZone("")
out = append(out, LocalAddr{
Iface: iface.Name,
Addr: addr,
Prefix: netip.PrefixFrom(addr, pfx.Bits()),
Kind: classifyOnIface(addr, iface.Name),
Up: up,
MTU: iface.MTU,
Hardware: hw,
})
}
return out
}
// toPrefix normalises the net.Addr values iface.Addrs returns (*net.IPNet on
// every supported platform, *net.IPAddr on a few).
func toPrefix(a net.Addr) (netip.Prefix, bool) {
switch v := a.(type) {
case *net.IPNet:
addr, ok := netip.AddrFromSlice(v.IP)
if !ok {
return netip.Prefix{}, false
}
addr = addr.Unmap()
ones, _ := v.Mask.Size()
if ones <= 0 || ones > addr.BitLen() {
ones = addr.BitLen()
}
return netip.PrefixFrom(addr, ones), true
case *net.IPAddr:
addr, ok := netip.AddrFromSlice(v.IP)
if !ok {
return netip.Prefix{}, false
}
addr = addr.Unmap()
return netip.PrefixFrom(addr, addr.BitLen()), true
default:
addr, err := netip.ParsePrefix(a.String())
if err != nil {
return netip.Prefix{}, false
}
return addr, true
}
}
// defaultSource asks the kernel which local address it would use to reach a
// destination on the default route. Dialling a UDP address only installs a
// route lookup on the socket; nothing is transmitted. Failure is expected and
// normal (notably for udp6 on IPv4-only hosts) and never populates Err.
func defaultSource(ctx context.Context, network, target string, log *slog.Logger) netip.Addr {
dctx, cancel := context.WithTimeout(ctx, ifDialTimeout)
defer cancel()
var d net.Dialer
conn, err := d.DialContext(dctx, network, target)
if err != nil {
log.With(
slog.String("network", network),
slog.String("error", err.Error()),
).Debug("no default source address")
return netip.Addr{}
}
defer conn.Close()
ua, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok {
return netip.Addr{}
}
addr, ok := netip.AddrFromSlice(ua.IP)
if !ok {
return netip.Addr{}
}
return addr.Unmap().WithZone("")
}
// sortLocalAddrs orders entries by interface name, then IPv4 before IPv6, then
// by address, so repeated refreshes render identically.
func sortLocalAddrs(as []LocalAddr) {
sort.Slice(as, func(i, j int) bool {
x, y := as[i], as[j]
if x.Iface != y.Iface {
return x.Iface < y.Iface
}
if x.Addr.Is4() != y.Addr.Is4() {
return x.Addr.Is4()
}
return x.Addr.Compare(y.Addr) < 0
})
}
// finishInterfaceReport derives Status and Summary from the collected data.
func finishInterfaceReport(rep *InterfaceReport, nIface int) {
if len(rep.Addrs) == 0 {
rep.Status = StatusFail
if rep.Err == "" {
rep.Err = "no local addresses found"
}
rep.Summary = "未发现任何本机地址"
return
}
// A private v4 address still routes out through NAT, but only if the kernel
// actually picked a default source for it.
var globalCapable bool
for _, a := range rep.Addrs {
switch a.Kind {
case AddrGlobalV4, AddrGlobalV6, AddrCGNAT, AddrTailscale:
globalCapable = true
case AddrPrivateV4:
globalCapable = globalCapable || rep.DefaultV4Src.IsValid()
}
}
if globalCapable {
rep.Status = StatusOK
} else {
rep.Status = StatusWarn
}
var b strings.Builder
fmt.Fprintf(&b, "%d 个接口 / %d 个地址", nIface, len(rep.Addrs))
if rep.DefaultV4Src.IsValid() {
fmt.Fprintf(&b, "IPv4 出口 %s", rep.DefaultV4Src)
} else {
b.WriteString(",无 IPv4 出口")
}
if rep.DefaultV6Src.IsValid() {
fmt.Fprintf(&b, "IPv6 出口 %s", rep.DefaultV6Src)
} else {
b.WriteString(",无 IPv6 出口")
}
rep.Summary = b.String()
}
// addrText renders an address for logging, using "-" for the invalid zero
// value so log lines stay readable.
func addrText(a netip.Addr) string {
if !a.IsValid() {
return "-"
}
return a.String()
}
+389
View File
@@ -0,0 +1,389 @@
package netdiag
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// pasteUserAgent identifies tslink to the paste services. 0x0.st rejects the
// Go default user agent with 403, so this is not merely cosmetic.
const pasteUserAgent = "tslink/1.0 (+diagnostics)"
// pasteTimeout bounds a single upload attempt, including connect, write and
// the read of the response body.
const pasteTimeout = 20 * time.Second
// MaxPasteBytes is the largest payload accepted by [Upload]. Bigger dumps are
// rejected rather than truncated: the tail of a log is usually the part the
// helper needs, and silently dropping it wastes everyone's time.
const MaxPasteBytes = 1 << 20
// pasteReadLimit caps how much of a response body is read back. A URL is a
// couple hundred bytes; anything larger is an error page.
const pasteReadLimit = 64 << 10
// PasteTarget is one supported paste service.
type PasteTarget struct {
Key string // stable id used by the UI
Name string // human label
Note string // short caveat: retention, region reachability
}
// pasteService is a target plus the code that performs the upload.
type pasteService struct {
PasteTarget
upload func(ctx context.Context, text string) (string, error)
}
// pasteServices is the internal registry, in preference order.
var pasteServices = []pasteService{
{
PasteTarget: PasteTarget{
Key: "0x0",
Name: "0x0.st",
Note: "保留 30 天以上(按大小递减),境内访问可能较慢",
},
upload: uploadNullPointer,
},
{
PasteTarget: PasteTarget{
Key: "paste_rs",
Name: "paste.rs",
Note: "无固定保留期,容量满后自动淘汰旧内容",
},
upload: uploadPasteRS,
},
{
PasteTarget: PasteTarget{
Key: "dpaste",
Name: "dpaste.org",
Note: "保留 7 天后自动删除",
},
upload: uploadDpaste,
},
{
PasteTarget: PasteTarget{
Key: "termbin",
Name: "termbin.com",
Note: "纯 TCP (9999)HTTPS 被墙时仍可用;保留约 1 个月",
},
upload: uploadTermbin,
},
}
// PasteTargets lists the supported services in preference order.
func PasteTargets() []PasteTarget {
out := make([]PasteTarget, 0, len(pasteServices))
for _, s := range pasteServices {
out = append(out, s.PasteTarget)
}
return out
}
// PasteResult is a successful upload.
type PasteResult struct {
URL string
Target string
Bytes int
Uploaded time.Time
}
// ErrPasteEmpty is returned when there is nothing to upload.
var ErrPasteEmpty = errors.New("netdiag: refusing to upload empty text")
// ErrPasteTooLarge is returned when the payload exceeds [MaxPasteBytes].
var ErrPasteTooLarge = fmt.Errorf("netdiag: text exceeds the %d byte paste limit", MaxPasteBytes)
// Upload sends text to the named target and returns the resulting public URL.
// An empty targetKey tries every target in [PasteTargets] order and returns the
// first success; when all of them fail the returned error names each failure.
//
// The caller MUST redact the text before calling: uploading is an outbound
// publication of user data to a third party. core.LogBuffer.ExportText performs
// that redaction (leave ExportOptions.NoRedact false). The resulting paste is
// PUBLIC — anyone holding the URL can read it, and most of these services offer
// no way to delete it afterwards.
//
// Every attempt is bounded by a ~20s timeout and honours ctx.
func Upload(ctx context.Context, targetKey, text string, logger *slog.Logger) (*PasteResult, error) {
if logger == nil {
logger = slog.Default()
}
logger = logger.With(slog.String("from", "paste"))
if strings.TrimSpace(text) == "" {
return nil, ErrPasteEmpty
}
if len(text) > MaxPasteBytes {
return nil, fmt.Errorf("%w (got %d bytes); filter the log before sharing", ErrPasteTooLarge, len(text))
}
candidates := pasteServices
if targetKey != "" {
svc, ok := lookupPasteService(targetKey)
if !ok {
return nil, fmt.Errorf("netdiag: unknown paste target %q", targetKey)
}
candidates = []pasteService{svc}
}
var failures []string
for _, svc := range candidates {
if err := ctx.Err(); err != nil {
return nil, err
}
res, err := attemptPaste(ctx, svc, text)
if err != nil {
logger.With(
slog.String("target", svc.Key),
slog.String("error", err.Error()),
).Debug("paste upload failed")
failures = append(failures, fmt.Sprintf("%s: %v", svc.Key, err))
continue
}
logger.With(
slog.String("target", res.Target),
slog.String("url", res.URL),
slog.Int("bytes", res.Bytes),
).Info("uploaded diagnostic paste")
return res, nil
}
if len(candidates) == 1 {
return nil, fmt.Errorf("netdiag: upload to %s failed: %s", candidates[0].Key, strings.TrimPrefix(failures[0], candidates[0].Key+": "))
}
return nil, fmt.Errorf("netdiag: every paste target failed: %s", strings.Join(failures, "; "))
}
// attemptPaste runs one upload under its own timeout and validates the URL the
// service handed back.
func attemptPaste(ctx context.Context, svc pasteService, text string) (*PasteResult, error) {
ctx, cancel := context.WithTimeout(ctx, pasteTimeout)
defer cancel()
raw, err := svc.upload(ctx, text)
if err != nil {
return nil, err
}
clean, err := normalisePasteURL(raw)
if err != nil {
return nil, err
}
return &PasteResult{
URL: clean,
Target: svc.Key,
Bytes: len(text),
Uploaded: time.Now(),
}, nil
}
// lookupPasteService finds a service by its stable key.
func lookupPasteService(key string) (pasteService, bool) {
for _, s := range pasteServices {
if s.Key == key {
return s, true
}
}
return pasteService{}, false
}
// ---------------------------------------------------------------------------
// Response validation
// ---------------------------------------------------------------------------
// normalisePasteURL trims a service response down to a single http(s) URL.
// termbin answers with a bare host such as "termbin.com/abcd", so a missing
// scheme is tolerated and upgraded to https. Anything that smells like an HTML
// error page is rejected outright.
func normalisePasteURL(raw string) (string, error) {
s := strings.TrimSpace(raw)
// termbin pads its reply with NULs and terminal escapes.
s = strings.Trim(s, "\x00\r\n\t ")
if s == "" {
return "", errors.New("empty response")
}
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
s = strings.TrimSpace(s[:i])
}
if looksLikeHTML(s) {
return "", fmt.Errorf("service returned an error page: %s", snippet(s))
}
if len(s) > 512 {
return "", fmt.Errorf("response is not a URL: %s", snippet(s))
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
u, err := url.Parse(s)
if err != nil {
return "", fmt.Errorf("response is not a URL: %s", snippet(raw))
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("response has unexpected scheme %q", u.Scheme)
}
if u.Host == "" || !strings.Contains(u.Host, ".") {
return "", fmt.Errorf("response has no usable host: %s", snippet(s))
}
return u.String(), nil
}
// looksLikeHTML reports whether s is the beginning of an HTML document rather
// than a URL.
func looksLikeHTML(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
return strings.HasPrefix(lower, "<") ||
strings.Contains(lower, "<html") ||
strings.Contains(lower, "<!doctype")
}
// snippet shortens an untrusted response for inclusion in an error message.
func snippet(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 120 {
return s[:120] + "…"
}
return s
}
// ---------------------------------------------------------------------------
// HTTP plumbing
// ---------------------------------------------------------------------------
// pasteHTTPClient is shared by the HTTP-based targets. The per-attempt context
// timeout is the real deadline; the client timeout is a backstop.
var pasteHTTPClient = &http.Client{
Timeout: pasteTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return nil
},
}
// doPaste issues one request and returns the (size-limited) response body.
func doPaste(ctx context.Context, method, endpoint, contentType string, body []byte) (string, error) {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("User-Agent", pasteUserAgent)
req.Header.Set("Accept", "text/plain, */*")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.ContentLength = int64(len(body))
resp, err := pasteHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, pasteReadLimit))
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("http %d: %s", resp.StatusCode, snippet(string(data)))
}
return string(data), nil
}
// uploadNullPointer posts to 0x0.st as multipart/form-data.
func uploadNullPointer(ctx context.Context, text string) (string, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", "tslink-log.txt")
if err != nil {
return "", err
}
if _, err := io.WriteString(part, text); err != nil {
return "", err
}
if err := mw.Close(); err != nil {
return "", err
}
return doPaste(ctx, http.MethodPost, "https://0x0.st/", mw.FormDataContentType(), buf.Bytes())
}
// uploadPasteRS posts the raw text to paste.rs.
func uploadPasteRS(ctx context.Context, text string) (string, error) {
return doPaste(ctx, http.MethodPost, "https://paste.rs/", "text/plain; charset=utf-8", []byte(text))
}
// uploadDpaste posts a urlencoded form to dpaste.org and asks for a bare URL
// back rather than the JSON representation.
func uploadDpaste(ctx context.Context, text string) (string, error) {
form := url.Values{
"content": {text},
"lexer": {"text"},
"format": {"url"},
"expires": {"604800"},
}
return doPaste(ctx, http.MethodPost, "https://dpaste.org/api/",
"application/x-www-form-urlencoded", []byte(form.Encode()))
}
// ---------------------------------------------------------------------------
// termbin (raw TCP)
// ---------------------------------------------------------------------------
// termbinAddr is the netcat-style endpoint termbin.com exposes.
const termbinAddr = "termbin.com:9999"
// uploadTermbin writes the text over a plain TCP connection, half-closes the
// write side so the server knows the paste is complete, then reads the URL it
// replies with. No TLS is involved, which is exactly why this target survives
// environments where the HTTPS paste sites are unreachable.
func uploadTermbin(ctx context.Context, text string) (string, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", termbinAddr)
if err != nil {
return "", err
}
defer conn.Close()
// Bound the whole exchange, and make sure a hung read is interrupted when
// the caller cancels: a blocked socket must never freeze the GUI.
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(pasteTimeout))
}
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
tcp, ok := conn.(*net.TCPConn)
if !ok {
return "", errors.New("termbin: connection is not tcp")
}
if _, err := io.WriteString(tcp, text); err != nil {
return "", fmt.Errorf("termbin: write: %w", err)
}
// Half-close: termbin only answers once it sees EOF on its read side.
if err := tcp.CloseWrite(); err != nil {
return "", fmt.Errorf("termbin: close write: %w", err)
}
data, err := io.ReadAll(io.LimitReader(tcp, pasteReadLimit))
if err != nil {
return "", fmt.Errorf("termbin: read: %w", err)
}
if ctxErr := ctx.Err(); ctxErr != nil {
return "", ctxErr
}
return string(data), nil
}
+1294
View File
File diff suppressed because it is too large Load Diff
+457
View File
@@ -0,0 +1,457 @@
package netdiag
// This file answers one question: can traffic from this machine reach the
// wider internet, and does the answer change depending on how it leaves?
//
// Every probe is run twice-ish over deliberately different paths — forced
// IPv4, forced IPv6, and through whatever HTTP proxy the environment
// advertises. The divergence between those paths is the signal: a user running
// a proxy tool wants to see that the direct path is dead and the proxied one
// works (or the reverse), not have the two averaged into one green tick.
//
// The mainland-China targets are baselines. They separate "this machine has no
// internet at all" from "this machine has internet but cannot leave the
// country", which are two completely different things to fix.
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
)
// diagUserAgent identifies our probes to the servers we poke. Some captive
// portals and CDNs behave differently for an empty UA, and an honest one makes
// the traffic recognisable in a packet capture.
const diagUserAgent = "tslink-netdiag/1.0"
// diagMaxRedirects is the hard cap on redirects followed by any diagnostic
// client. A redirect chain is usually a portal bouncing us around; three hops
// is enough to land on it and few enough to stay inside the probe timeout.
const diagMaxRedirects = 3
const (
// rchTimeout bounds a single reachability probe end to end.
rchTimeout = 5 * time.Second
// rchMaxInflight bounds concurrent reachability probes.
rchMaxInflight = 6
// rchMaxBody caps how much of a response body we read. The targets answer
// 204 with no body at all; the cap only exists so a hijacking portal
// serving a huge page cannot stall the probe.
rchMaxBody = 64 << 10
)
func rchLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/reach"))
}
// ---------------------------------------------------------------------------
// Shared HTTP plumbing (used by reach.go, egress.go and geo.go)
// ---------------------------------------------------------------------------
// uaTransport stamps [diagUserAgent] onto every request that does not already
// carry one. RoundTrippers must not mutate the request they are handed, so the
// request is cloned first.
type uaTransport struct {
base http.RoundTripper
}
func (t uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") != "" {
return t.base.RoundTrip(req)
}
clone := req.Clone(req.Context())
clone.Header.Set("User-Agent", diagUserAgent)
return t.base.RoundTrip(clone)
}
// newDiagClient builds a single-use HTTP client for one diagnostic probe.
//
// network forces the dial family: "tcp4", "tcp6", or "" to let the resolver
// and the kernel pick. Forcing the family is what makes an IPv4-only failure
// distinguishable from an IPv6-only one.
//
// useProxy selects [http.ProxyFromEnvironment] when true and no proxy at all
// when false. The false case is an explicit bypass, not a default: running the
// same target both ways is how proxy interference becomes visible.
//
// timeout bounds the whole request, including dial, TLS handshake and body
// read. Redirects are capped at [diagMaxRedirects] and the User-Agent is set
// to [diagUserAgent].
//
// The client keeps no idle connections; callers may still call
// CloseIdleConnections when they are done with it.
func newDiagClient(network string, useProxy bool, timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: timeout}
var proxy func(*http.Request) (*url.URL, error)
if useProxy {
proxy = http.ProxyFromEnvironment
}
tr := &http.Transport{
Proxy: proxy,
DialContext: func(ctx context.Context, defaultNetwork, addr string) (net.Conn, error) {
if network != "" {
defaultNetwork = network
}
return dialer.DialContext(ctx, defaultNetwork, addr)
},
DisableKeepAlives: true,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: time.Second,
}
return &http.Client{
Transport: uaTransport{base: tr},
Timeout: timeout,
CheckRedirect: func(_ *http.Request, via []*http.Request) error {
if len(via) >= diagMaxRedirects {
return fmt.Errorf("stopped after %d redirects", diagMaxRedirects)
}
return nil
},
}
}
// diagGet performs one GET and returns the status code, at most maxBody bytes
// of the body, and the time to a complete response. ctx must already carry the
// caller's deadline; nothing here blocks past it.
func diagGet(ctx context.Context, client *http.Client, target string, maxBody int64, header http.Header) (int, []byte, time.Duration, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return 0, nil, 0, err
}
for k, vs := range header {
for _, v := range vs {
req.Header.Add(k, v)
}
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return 0, nil, time.Since(start), err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBody))
rtt := time.Since(start)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return resp.StatusCode, body, rtt, readErr
}
return resp.StatusCode, body, rtt, nil
}
// diagProxyConfigured reports whether the environment advertises a proxy for
// target. Only the boolean is ever surfaced: a proxy URL may embed credentials
// and must never reach a log line or a report field.
func diagProxyConfigured(target string) bool {
u, err := url.Parse(target)
if err != nil {
return false
}
req := &http.Request{URL: u, Header: http.Header{}}
p, err := http.ProxyFromEnvironment(req)
return err == nil && p != nil
}
// ---------------------------------------------------------------------------
// Overseas reachability
// ---------------------------------------------------------------------------
// rchTarget is one reachability probe definition.
type rchTarget struct {
name string
url string
region Region
network string // "tcp4", "tcp6" or "" for unforced
viaProxy bool
want int // expected status code
}
// rchTargets is the probe list. Cloudflare's generate_204 is hit four ways
// because the paths fail independently.
//
// The direct and proxied Cloudflare probes deliberately use the same scheme:
// the point of running one target both ways is that the proxy setting is the
// only variable. Comparing plaintext-direct against TLS-proxied would let a
// middlebox that hijacks HTTP but passes HTTPS masquerade as "only the proxy
// works". The plaintext probe is kept separately, because that is exactly the
// signal a captive portal produces.
//
// The last two entries are mainland-China baselines.
func rchTargets() []rchTarget {
return []rchTarget{
{name: "Cloudflare 204 (IPv4)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent},
{name: "Cloudflare 204 (IPv6)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp6", want: http.StatusNoContent},
{name: "Cloudflare 204 (代理)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, viaProxy: true, want: http.StatusNoContent},
{name: "Cloudflare 204 (明文/门户检测)", url: "http://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent},
{name: "Gstatic 204", url: "http://www.gstatic.com/generate_204", region: RegionIntl, want: http.StatusNoContent},
{name: "Google 204", url: "https://www.google.com/generate_204", region: RegionIntl, want: http.StatusNoContent},
{name: "小米 204(国内基准)", url: "http://connect.rom.miui.com/generate_204", region: RegionCN, want: http.StatusNoContent},
{name: "百度(国内基准)", url: "https://www.baidu.com", region: RegionCN, want: http.StatusOK},
}
}
// ProbeOverseas checks whether traffic can leave for the wider internet.
//
// Every target is probed concurrently with its own ~5s budget, so the whole
// section finishes in about that time no matter how many probes hang. Probes
// against mainland-China targets act as a baseline: when they succeed and the
// international ones do not, the line is up but egress is filtered, which is a
// warning rather than a failure.
//
// A response that arrives with an unexpected status is recorded, not
// discarded — a captive portal or an injected block page is precisely what the
// user needs to see.
func ProbeOverseas(ctx context.Context, logger *slog.Logger) OverseasReport {
log := rchLog(logger)
targets := rchTargets()
var (
mu sync.Mutex
probes = make([]ReachProbe, 0, len(targets))
wg sync.WaitGroup
sem = make(chan struct{}, rchMaxInflight)
)
for _, t := range targets {
wg.Add(1)
go func(t rchTarget) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
mu.Lock()
probes = append(probes, rchCancelled(t, ctx.Err()))
mu.Unlock()
return
}
p := rchProbeOne(ctx, t, log)
mu.Lock()
probes = append(probes, p)
mu.Unlock()
}(t)
}
wg.Wait()
rchSortProbes(probes)
rep := OverseasReport{Probes: probes}
rchSummarize(&rep)
log.With(
slog.Int("probes", len(rep.Probes)),
slog.String("status", rep.Status.String()),
).Debug("finished overseas reachability probes")
return rep
}
// rchCancelled builds the placeholder entry for a probe that never started
// because the run was cancelled. The row still renders, which is better than a
// silently shorter table.
func rchCancelled(t rchTarget, err error) ReachProbe {
msg := "cancelled"
if err != nil {
msg = err.Error()
}
return ReachProbe{
Name: t.name,
URL: t.url,
Region: t.region,
ViaProxy: t.viaProxy,
Network: t.network,
Err: msg,
}
}
// rchProbeOne runs a single probe. It never returns an error: a failure is a
// datapoint, recorded in the probe's Err field.
func rchProbeOne(ctx context.Context, t rchTarget, log *slog.Logger) ReachProbe {
// ViaProxy must record what happened, not what was intended. A client
// built with http.ProxyFromEnvironment sends the request direct when no
// proxy is configured, and counting that as proof the proxy path works is
// how the summary ends up asserting "only the proxy link is usable" on a
// machine with no proxy at all.
usedProxy := t.viaProxy && diagProxyConfigured(t.url)
p := ReachProbe{
Name: t.name,
URL: t.url,
Region: t.region,
ViaProxy: usedProxy,
Network: t.network,
}
if t.viaProxy && !usedProxy {
p.Name = t.name + "(环境未配置代理,实际直连)"
}
pctx, cancel := context.WithTimeout(ctx, rchTimeout)
defer cancel()
client := newDiagClient(t.network, usedProxy, rchTimeout)
defer client.CloseIdleConnections()
code, _, rtt, err := diagGet(pctx, client, t.url, rchMaxBody, nil)
p.RTT = rtt
p.StatusCode = code
switch {
case err != nil:
p.Err = rchErrText(err)
case code == t.want:
p.OK = true
default:
// Reachable, but something answered on the target's behalf.
p.Err = fmt.Sprintf("unexpected status %d (want %d), 可能存在门户劫持或内容注入", code, t.want)
}
log.With(
slog.String("name", t.name),
slog.String("network", rchNetworkText(t.network)),
slog.Bool("via_proxy", t.viaProxy),
slog.Int("status", p.StatusCode),
slog.Duration("rtt", p.RTT),
slog.Bool("ok", p.OK),
).Debug("reachability probe done")
return p
}
// rchErrText flattens a transport error into a short message. The URL is
// stripped because url.Error embeds the full target (and, for a proxied
// request, potentially proxy credentials) into its Error string.
func rchErrText(err error) string {
var ue *url.Error
if errors.As(err, &ue) && ue.Err != nil {
err = ue.Err
}
msg := err.Error()
switch {
case errors.Is(err, context.DeadlineExceeded):
return "timeout"
case errors.Is(err, context.Canceled):
return "cancelled"
}
if i := strings.IndexByte(msg, '\n'); i >= 0 {
msg = msg[:i]
}
return msg
}
func rchNetworkText(n string) string {
if n == "" {
return "auto"
}
return n
}
// rchSortProbes orders international probes before the CN baselines and is
// otherwise stable on URL/network/proxy, so consecutive refreshes render in
// exactly the same order.
func rchSortProbes(ps []ReachProbe) {
rank := func(r Region) int {
if r == RegionIntl {
return 0
}
return 1
}
sort.Slice(ps, func(i, j int) bool {
x, y := ps[i], ps[j]
if rank(x.Region) != rank(y.Region) {
return rank(x.Region) < rank(y.Region)
}
if x.URL != y.URL {
return x.URL < y.URL
}
if x.Network != y.Network {
return x.Network < y.Network
}
if x.ViaProxy != y.ViaProxy {
return !x.ViaProxy
}
return x.Name < y.Name
})
}
// rchSummarize derives Status and a one-line Chinese Summary.
//
// Any single successful international probe is enough for [StatusOK]: hosts
// without IPv6 are the norm, so a failed v6 probe alongside a working v4 one
// must not drag the verdict down. Only the CN baselines succeeding means the
// local network is fine but the wider internet is not reachable
// ([StatusWarn]); nothing succeeding at all is [StatusFail].
func rchSummarize(rep *OverseasReport) {
var (
intlOK, intlTotal int
cnOK, cnTotal int
proxyOK bool
directIntlOK bool
v6OK bool
hijacked int
)
for _, p := range rep.Probes {
if p.Region == RegionIntl {
intlTotal++
if p.OK {
intlOK++
if p.ViaProxy {
proxyOK = true
} else {
directIntlOK = true
}
if p.Network == "tcp6" {
v6OK = true
}
}
} else {
cnTotal++
if p.OK {
cnOK++
}
}
if !p.OK && p.StatusCode > 0 {
hijacked++
}
}
var b strings.Builder
switch {
case intlOK > 0:
rep.Status = StatusOK
fmt.Fprintf(&b, "境外可达(%d/%d 个境外目标成功)", intlOK, intlTotal)
switch {
case proxyOK && !directIntlOK:
b.WriteString(",仅代理链路可用,直连被阻断")
case directIntlOK && !proxyOK && diagProxyConfigured("https://cp.cloudflare.com/generate_204"):
b.WriteString(",直连可用但代理链路失败")
}
if !v6OK {
b.WriteString("IPv6 不可用(不影响判定)")
}
case cnOK > 0:
rep.Status = StatusWarn
fmt.Fprintf(&b, "境外不可达(0/%d),但本地网络正常:国内基准 %d/%d 通过,问题在跨境链路而非本机网络", intlTotal, cnOK, cnTotal)
default:
rep.Status = StatusFail
fmt.Fprintf(&b, "境内外目标均无法访问(0/%d),本机可能完全没有网络", intlTotal+cnTotal)
}
if hijacked > 0 {
fmt.Fprintf(&b, ";%d 个目标返回了非预期状态码,疑似门户或注入", hijacked)
}
rep.Summary = b.String()
}
+505
View File
@@ -0,0 +1,505 @@
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, rep.HeadlineStatus = 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, together with that
// sentence's own severity. The ordering is by how badly each condition breaks
// the thing this app exists to do — carry game traffic between peers — not by
// section order.
//
// The severity is returned separately because Report.Status is the worst of
// every section: an unrelated port-mapping failure would otherwise render a
// "may be affecting" headline in the same red as "is affecting", which is
// exactly the overstatement this split exists to prevent.
func headline(r *Report) (string, Status) {
switch {
case r.NAT.Type == NATUDPBlocked:
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继", StatusFail
case !r.UDP.V4OK && !r.UDP.V6OK:
return "UDP 探测全部失败,请检查防火墙或网络策略", StatusFail
case r.NAT.Type == NATSymmetric:
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继", StatusFail
case r.Overseas.Status == StatusFail:
return "无法访问任何外部网络", StatusFail
case r.Overseas.Status == StatusWarn:
return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响", StatusWarn
case r.Egress.DivergentSTUN:
// STUN itself saw several egress addresses: the UDP path Tailscale uses
// really does vary per flow.
return "STUN 检测到多个出口 IP,代理或分流工具正在影响连接", StatusFail
case r.Egress.Divergent:
// Only the web path disagreed; UDP may well be intact.
return "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接", StatusWarn
case r.PortMap.Status == StatusWarn && r.NAT.Type == NATPortRestrict:
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般", StatusWarn
case r.Status == StatusOK:
return "网络状况良好,具备直连条件", StatusOK
default:
return "诊断完成,存在若干需要注意的项目", r.Status
}
}
// ---------------------------------------------------------------------------
// 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()
}
// Name the server, then the address actually probed — a shared bundle
// has to be readable without the reader resolving IPs by hand.
target := p.Host
if target == "" {
target = p.Target
} else if p.Target != "" && p.Target != p.Host {
target += " (" + p.Target + ")"
}
w(" %-4s %-46s %-5s %s\n", status, target, p.Region, detail)
}
b.WriteByte('\n')
// --- 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.DivergentSTUN {
w("!! STUN(UDP) 本身看到多个公网 IP,直连打洞会受影响\n")
} else if r.Egress.Divergent {
w("!! 仅 HTTP 探测得到了不同的公网 IP,STUN(UDP) 出口一致,通常不影响打洞\n")
}
for _, o := range r.Egress.Observations {
val := o.IP.String()
if !o.IP.IsValid() {
val = "(" + o.Err + ")"
}
w(" %-11s %-5s %-40s %s\n", o.Method, o.Region, o.Source, val)
}
for _, g := range r.Egress.Geo {
if g.Err != "" && g.Provider == "" {
w(" %-40s %s\n", g.IP.String(), g.Err)
continue
}
parts := []string{}
for _, p := range []string{g.CountryName, g.Country, g.Region, g.City} {
if p != "" {
parts = append(parts, p)
}
}
w(" %-40s %s | %s %s (via %s)\n",
g.IP.String(), strings.Join(parts, " "), g.ASN, g.Org, g.Provider)
}
b.WriteByte('\n')
// --- tailscale ---------------------------------------------------------
w("--- Tailscale 内部状态 [%s] ---\n", r.Tailscale.Status)
if r.Tailscale.Summary != "" {
w("%s\n", r.Tailscale.Summary)
}
if r.Tailscale.Available {
w("UDP: %v IPv4: %v IPv6: %v ICMPv4: %v\n",
r.Tailscale.UDP, r.Tailscale.IPv4, r.Tailscale.IPv6, r.Tailscale.ICMPv4)
w("UPnP: %s PMP: %s PCP: %s\n",
triState(r.Tailscale.UPnP), triState(r.Tailscale.PMP), triState(r.Tailscale.PCP))
w("映射随目标变化: %s 门户劫持: %s\n",
triState(r.Tailscale.MappingVariesByDestIP), triState(r.Tailscale.CaptivePortal))
if r.Tailscale.GlobalV4 != "" {
w("GlobalV4: %s\n", r.Tailscale.GlobalV4)
}
if r.Tailscale.GlobalV6 != "" {
w("GlobalV6: %s\n", r.Tailscale.GlobalV6)
}
w("首选 DERP: %s\n", r.Tailscale.PreferredDERP)
derp := append([]DERPLatency(nil), r.Tailscale.DERP...)
sort.Slice(derp, func(i, j int) bool { return derp[i].Latency < derp[j].Latency })
for i, d := range derp {
if i >= 8 {
break
}
mark := " "
if d.Preferred {
mark = "*"
}
w(" %s %-6s %-24s %s\n", mark, d.RegionCode, d.Name,
d.Latency.Round(time.Millisecond))
}
}
if r.Tailscale.Err != "" {
w("错误: %s\n", r.Tailscale.Err)
}
return b.String()
}
func writeService(b *strings.Builder, name string, s ServiceProbe) {
status := "不支持"
if s.Available {
status = "支持"
}
line := " " + name + ": " + status
if s.ExternalIP.IsValid() {
line += " 外部地址 " + s.ExternalIP.String()
}
if s.Detail != "" {
line += " " + s.Detail
}
if s.Err != "" {
line += " (" + s.Err + ")"
}
b.WriteString(line + "\n")
}
func triState(v *bool) string {
if v == nil {
return "未知"
}
if *v {
return "是"
}
return "否"
}
+55
View File
@@ -0,0 +1,55 @@
package netdiag
// This file holds the STUN probe target lists. The default list deliberately
// mixes mainland-China and international servers: when a proxy, a split tunnel
// or the GFW is in play the two groups disagree, and that disagreement is the
// diagnostic signal we are after. Probing only one side would hide it.
// DefaultSTUNServers returns the built-in probe list, covering both mainland
// China (RegionCN) and international (RegionIntl) targets.
//
// A fresh slice is returned on every call so callers may reorder or trim it
// without affecting anyone else.
func DefaultSTUNServers() []STUNServer {
return []STUNServer{
// Mainland China. These answer fast from inside the country and are the
// baseline for "does UDP work at all on this line".
{Host: "stun.miwifi.com:3478", Name: "小米", Region: RegionCN},
{Host: "stun.chat.bilibili.com:3478", Name: "哔哩哔哩", Region: RegionCN},
{Host: "stun.qq.com:3478", Name: "腾讯", Region: RegionCN},
{Host: "stun.hitv.com:3478", Name: "芒果TV", Region: RegionCN},
// Anycast: usually lands on an in-country PoP, so it is grouped with CN
// even though the operator is not Chinese.
{Host: "turn.cloudflare.com:3478", Name: "Cloudflare(任播)", Region: RegionCN},
// International. Failures here while the CN group succeeds mean egress
// to the wider internet is filtered rather than UDP being dead.
{Host: "stun.l.google.com:19302", Name: "Google", Region: RegionIntl},
{Host: "stun.cloudflare.com:3478", Name: "Cloudflare", Region: RegionIntl},
{Host: "stun.nextcloud.com:3478", Name: "Nextcloud", Region: RegionIntl},
{Host: "stun.voip.blackberry.com:3478", Name: "BlackBerry", Region: RegionIntl},
{Host: "stun.sipnet.net:3478", Name: "SipNet", Region: RegionIntl},
{Host: "stun.stunprotocol.org:3478", Name: "StunProtocol", Region: RegionIntl},
{Host: "stun.voipgate.com:3478", Name: "VoIPGate", Region: RegionIntl},
}
}
// RFC5780Servers returns the subset of targets known to implement RFC 5780
// behaviour discovery, i.e. they advertise OTHER-ADDRESS and actually honour
// CHANGE-REQUEST by answering from a second IP and/or port.
//
// Only these servers can drive the filtering-behaviour test in [ClassifyNAT].
// Most large providers — Google and Cloudflare among them — answer plain
// binding requests perfectly well but silently ignore CHANGE-REQUEST and never
// send OTHER-ADDRESS, so a probe against them looks identical to a firewall
// dropping the reply. Classification therefore has to degrade gracefully: when
// none of these servers answers, filtering behaviour stays
// [BehaviorUnknown] and the NAT type is reported as [NATUnknown] with an
// explanatory note rather than being guessed.
func RFC5780Servers() []STUNServer {
return []STUNServer{
{Host: "stun.stunprotocol.org:3478", Name: "StunProtocol", Region: RegionIntl},
{Host: "stun.sipnet.net:3478", Name: "SipNet", Region: RegionIntl},
{Host: "stun.voipgate.com:3478", Name: "VoIPGate", Region: RegionIntl},
}
}
+1369
View File
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
package netdiag
import (
"bytes"
"encoding/binary"
"encoding/hex"
"net/netip"
"testing"
)
// rfc5769TxID is the transaction ID from the RFC 5769 sample messages; the
// hand-computed XOR-MAPPED-ADDRESS vectors below are derived from it.
var rfc5769TxID = [12]byte{0xb7, 0xe7, 0xa7, 0x01, 0xbc, 0x34, 0xd6, 0x86, 0xfa, 0x87, 0xdf, 0xae}
// stunTestTLV encodes one attribute with its 4-byte alignment padding.
func stunTestTLV(typ uint16, val []byte) []byte {
out := make([]byte, 4, 4+len(val)+3)
binary.BigEndian.PutUint16(out[0:2], typ)
binary.BigEndian.PutUint16(out[2:4], uint16(len(val)))
out = append(out, val...)
if pad := (4 - len(val)%4) % 4; pad > 0 {
out = append(out, make([]byte, pad)...)
}
return out
}
// stunTestRaw frames body as a STUN message with a correct length field.
func stunTestRaw(typ uint16, txid [12]byte, body []byte) []byte {
out := make([]byte, stunHeaderSize, stunHeaderSize+len(body))
binary.BigEndian.PutUint16(out[0:2], typ)
binary.BigEndian.PutUint16(out[2:4], uint16(len(body)))
binary.BigEndian.PutUint32(out[4:8], stunMagicCookie)
copy(out[8:20], txid[:])
return append(out, body...)
}
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("bad hex %q: %v", s, err)
}
return b
}
func TestSTUNEncodeParseRoundTrip(t *testing.T) {
tests := []struct {
name string
msg stunMessage
attrs int
}{
{
name: "bare request",
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID},
attrs: 0,
},
{
name: "change request",
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID, Attrs: []stunAttr{
{Type: stunAttrChangeRequest, Value: []byte{0, 0, 0, stunChangeIP | stunChangePort}},
}},
attrs: 1,
},
{
name: "response with odd-length software",
msg: stunMessage{Type: stunBindingSuccess, TxID: rfc5769TxID, Attrs: []stunAttr{
{Type: stunAttrSoftware, Value: []byte("tslink/1")},
{Type: stunAttrXORMappedAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("192.0.2.1:32853"), true, rfc5769TxID)},
{Type: stunAttrOtherAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("198.51.100.7:3479"), false, rfc5769TxID)},
{Type: 0x7fff, Value: []byte{1, 2, 3, 4, 5}}, // unknown, needs padding
}},
attrs: 4,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
raw := tc.msg.encode()
if len(raw)%4 != 0 {
t.Fatalf("encoded message is not 4-byte aligned: %d", len(raw))
}
if got := binary.BigEndian.Uint32(raw[4:8]); got != stunMagicCookie {
t.Fatalf("magic cookie = %#x", got)
}
got, err := parseSTUNMessage(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if got.Type != tc.msg.Type || got.TxID != tc.msg.TxID {
t.Fatalf("header mismatch: got %#x/%x", got.Type, got.TxID)
}
if len(got.Attrs) != tc.attrs {
t.Fatalf("attrs = %d, want %d", len(got.Attrs), tc.attrs)
}
for i, a := range tc.msg.Attrs {
if got.Attrs[i].Type != a.Type {
t.Errorf("attr %d type = %#x, want %#x", i, got.Attrs[i].Type, a.Type)
}
if !bytes.Equal(got.Attrs[i].Value, a.Value) {
t.Errorf("attr %d value = %x, want %x", i, got.Attrs[i].Value, a.Value)
}
}
})
}
}
func TestSTUNDecodeXORMappedAddress(t *testing.T) {
tests := []struct {
name string
attr uint16
// hand-computed payload: reserved, family, xor-port, xor-address
payload string
want string
}{
{
// 192.0.2.1 ^ 2112a442 = e112a643, port 32853 ^ 0x2112 = 0xa147
name: "v4",
attr: stunAttrXORMappedAddress,
payload: "0001a147e112a643",
want: "192.0.2.1:32853",
},
{
// same, delivered under the legacy 0x8020 attribute type
name: "v4 legacy attr",
attr: stunAttrXORMappedAddrAlt,
payload: "0001a147e112a643",
want: "192.0.2.1:32853",
},
{
// 2001:db8:1234:5678:11:2233:4455:6677 ^ (cookie || txid)
name: "v6",
attr: stunAttrXORMappedAddress,
payload: "0002a1470113a9faa5d3f179bc25f4b5bed2b9d9",
want: "[2001:db8:1234:5678:11:2233:4455:6677]:32853",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
payload := mustHex(t, tc.payload)
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(tc.attr, payload))
msg, err := parseSTUNMessage(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
got, ok := msg.mappedAddr()
if !ok {
t.Fatal("no mapped address decoded")
}
if got.String() != tc.want {
t.Fatalf("mapped = %s, want %s", got, tc.want)
}
// encoding it again must reproduce the same bytes
if back := stunEncodeAddr(got, true, rfc5769TxID); !bytes.Equal(back, payload) {
t.Fatalf("re-encoded = %x, want %x", back, payload)
}
})
}
}
func TestSTUNDecodePlainMappedAddress(t *testing.T) {
payload := mustHex(t, "00010d96c0000201") // 192.0.2.1:3478, no XOR
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrMappedAddress, payload))
msg, err := parseSTUNMessage(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
got, ok := msg.mappedAddr()
if !ok || got.String() != "192.0.2.1:3478" {
t.Fatalf("mapped = %v (ok=%v), want 192.0.2.1:3478", got, ok)
}
}
func TestSTUNParseTolerance(t *testing.T) {
good := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
tests := []struct {
name string
raw []byte
wantErr bool
wantAttrs int
wantMap string
}{
{
name: "unknown attributes are skipped",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(stunTestTLV(0x7f01, []byte{9}), good, stunTestTLV(0xfffe, []byte("xyz")))),
wantAttrs: 3,
wantMap: "192.0.2.1:32853",
},
{
name: "missing trailing padding tolerated",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, []byte{0x80, 0x22, 0x00, 0x03, 'a', 'b', 'c'})),
wantAttrs: 2,
wantMap: "192.0.2.1:32853",
},
{
name: "fingerprint after mapped address",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, stunTestTLV(stunAttrFingerprint, []byte{1, 2, 3, 4}))),
wantAttrs: 2,
wantMap: "192.0.2.1:32853",
},
{
name: "header shorter than 20 bytes",
raw: []byte{0x01, 0x01, 0x00, 0x00},
wantErr: true,
},
{
name: "trailing bytes beyond declared length ignored",
raw: append(stunTestRaw(stunBindingSuccess, rfc5769TxID, nil), 0x00),
wantErr: false,
},
{
name: "truncated attribute value",
raw: func() []byte {
b := stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00, 0x10, 0x00, 0x01})
return b
}(),
wantErr: true,
},
{
name: "truncated attribute header",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00}),
wantErr: true,
},
{
name: "address attribute shorter than its family requires",
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0002a1470113a9fa"))),
wantAttrs: 1,
wantMap: "", // v6 payload truncated: reported as absent, not fatal
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
msg, err := parseSTUNMessage(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatal("expected an error, got none")
}
return
}
if err != nil {
t.Fatalf("parse: %v", err)
}
if tc.wantAttrs != 0 && len(msg.Attrs) != tc.wantAttrs {
t.Fatalf("attrs = %d, want %d", len(msg.Attrs), tc.wantAttrs)
}
got, ok := msg.mappedAddr()
if tc.wantMap == "" {
if ok {
t.Fatalf("expected no mapped address, got %s", got)
}
return
}
if !ok || got.String() != tc.wantMap {
t.Fatalf("mapped = %v (ok=%v), want %s", got, ok, tc.wantMap)
}
})
}
}
func TestSTUNResponseFor(t *testing.T) {
other := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
body := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
tests := []struct {
name string
raw []byte
txid [12]byte
want bool
}{
{"matching success", stunTestRaw(stunBindingSuccess, rfc5769TxID, body), rfc5769TxID, true},
{"matching error response", stunTestRaw(stunBindingError, rfc5769TxID, nil), rfc5769TxID, true},
{"txid mismatch", stunTestRaw(stunBindingSuccess, other, body), rfc5769TxID, false},
{"request is not a response", stunTestRaw(stunBindingRequest, rfc5769TxID, nil), rfc5769TxID, false},
{"garbage", []byte("not a stun packet"), rfc5769TxID, false},
{"empty", nil, rfc5769TxID, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
msg, ok := stunResponseFor(tc.raw, tc.txid)
if ok != tc.want {
t.Fatalf("ok = %v, want %v", ok, tc.want)
}
if ok && msg == nil {
t.Fatal("accepted response but returned nil message")
}
})
}
}
func TestSTUNBindingRequestMsg(t *testing.T) {
plain := stunBindingRequestMsg(0)
if len(plain.Attrs) != 0 {
t.Fatalf("plain request carries %d attributes", len(plain.Attrs))
}
if plain.TxID == ([12]byte{}) {
t.Fatal("transaction id was not randomised")
}
if other := stunBindingRequestMsg(0); other.TxID == plain.TxID {
t.Fatal("two requests share a transaction id")
}
cr := stunBindingRequestMsg(stunChangeIP | stunChangePort)
v, ok := cr.attr(stunAttrChangeRequest)
if !ok || len(v) != 4 || v[3] != 0x06 {
t.Fatalf("change-request attribute = %x (ok=%v)", v, ok)
}
}
func TestSTUNServerLists(t *testing.T) {
var cn, intl int
hosts := map[string]bool{}
for _, s := range DefaultSTUNServers() {
if hosts[s.Host] {
t.Errorf("duplicate host %s", s.Host)
}
hosts[s.Host] = true
if s.Name == "" {
t.Errorf("%s has no name", s.Host)
}
switch s.Region {
case RegionCN:
cn++
case RegionIntl:
intl++
default:
t.Errorf("%s has unknown region %q", s.Host, s.Region)
}
}
if cn == 0 || intl == 0 {
t.Fatalf("default list must span both regions, got cn=%d intl=%d", cn, intl)
}
for _, s := range RFC5780Servers() {
if !hosts[s.Host] {
t.Errorf("rfc5780 server %s missing from the default list", s.Host)
}
}
}
func concat(parts ...[]byte) []byte {
var out []byte
for _, p := range parts {
out = append(out, p...)
}
return out
}
+496
View File
@@ -0,0 +1,496 @@
// 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 {
// Host is the configured "hostname:port", kept alongside the resolved
// Target so the UI can name the server rather than an anonymous address.
Host string
// Target is the address actually probed, "ip:port". A server reachable over
// both families yields one probe per family, and only this tells them apart.
Target string
Name string
Region Region
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
// DivergentSTUN narrows Divergent to the case that actually breaks NAT
// traversal: STUN itself — plain UDP, the same path Tailscale punches
// through — saw more than one address in a family. That means the UDP
// egress genuinely varies per flow.
//
// Divergence seen only by the HTTP probes is a weaker signal. An HTTP proxy
// or split-tunnel rule can rewrite web traffic while leaving UDP alone, so
// it warrants a warning, not a verdict.
DivergentSTUN bool
// Countries is the set of distinct countries seen, sorted.
Countries []string
Status Status
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
// HeadlineStatus is the severity of Headline specifically, which is not
// always Status. Status is the worst of every section, so a report with an
// unrelated failure elsewhere would otherwise paint a merely-cautionary
// headline in alarm red and overstate what was actually found.
HeadlineStatus Status
// Status is the worst status across all sections.
Status Status
}
// 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 }