Compare commits
22
Commits
3edddb406b
...
sfcraft
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dec7284f3 | ||
|
|
b24cfcd560 | ||
|
|
7eb35f82b1 | ||
|
|
ae79082481 | ||
|
|
5b3a7e147c | ||
|
|
ac27db76f9 | ||
|
|
5dc1759d80 | ||
|
|
20097a02a1 | ||
|
|
e1cfd61d46 | ||
|
|
bfd3bcdff5 | ||
|
|
91722048cf | ||
|
|
3c8d685385 | ||
|
|
6b06cb03f8 | ||
|
|
c725d40d80 | ||
|
|
30330a1525 | ||
|
|
aaf56fef69 | ||
|
|
74618fff4c | ||
|
|
cb6ebb03e6 | ||
|
|
35f92fa781 | ||
|
|
7c5904cc47 | ||
|
|
125bc4bf3d | ||
|
|
390946e0a2 |
+197
-4
@@ -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
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
.idea/**
|
||||
.gocache/**
|
||||
tslink
|
||||
config.toml
|
||||
tslink-gui
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
builds:
|
||||
- id: tslink
|
||||
main: .
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
@@ -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)。
|
||||
|
||||
@@ -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-HTTPS(RFC 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 IGD(SSDP + SOAP)、NAT-PMP(RFC 6886)与 PCP(RFC 6887)探测,能拿到路由器型号与外部地址 |
|
||||
| **境外连通性** | 以 `cp.cloudflare.com/generate_204` 为主,辅以 gstatic / Google,并用国内基准(小米 / 百度)区分「完全没网」与「只是出不了境」 |
|
||||
| **出口 IP 与归属地** | 通过 STUN(裸 UDP,绕过 HTTP 代理)、强制 IPv4、强制 IPv6、以及走系统代理四种方式分别探测,再用 ipinfo.io(失败时回退 ip-api.com / ip.sb)查询归属地 |
|
||||
| **Tailscale 内部状态** | 直接调用 tailscale 自己的 netcheck,取得 DERP 各区域延迟、首选中继、门户劫持判定,以及它自己看到的 UPnP/PMP/PCP 结果 |
|
||||
|
||||
STUN 服务器**同时包含国内与境外**两组(小米、B 站、腾讯、芒果 TV、Cloudflare 任播 / Google、Cloudflare、Nextcloud、BlackBerry、SipNet、StunProtocol)。这不只是为了容错:当本机启用了代理或分流工具时,不同探测路径会得到**不同的公网 IP**,诊断页会把这种分歧单独标出来——这通常正是「为什么对端连不上我」的答案。
|
||||
|
||||
> 归属地查询会把你的公网 IP 发送给第三方服务。不希望如此时,勾选「不查询归属地」即可跳过。
|
||||
>
|
||||
> 出口 IP 的「不一致」判定按 IPv4 / IPv6 分别计算,双栈主机同时拥有一个 v4 和一个 v6 出口属于正常情况,不会被误报。
|
||||
|
||||
### 导出与分享日志
|
||||
|
||||
日志页提供三种导出方式,都会附带一段环境信息头(版本、系统、配置来源、运行阶段、节点数),以及最近一次的完整诊断报告:
|
||||
|
||||
- **复制到剪贴板**
|
||||
- **保存到文件**:写入用户主目录,文件名形如 `tslink-log-20260726-084500.txt`
|
||||
- **上传并分享**:依次尝试 0x0.st、paste.rs、dpaste.org、termbin.com,成功后返回链接并自动复制
|
||||
|
||||
导出默认开启「隐去密钥」,会移除 `auth_key` 等凭据以及形如 `tskey-...` 的字符串。**上传是公开的**——任何拿到链接的人都能看到内容,其中包含你的公网 IP 与内网地址,请自行判断。
|
||||
|
||||
### 从源码构建
|
||||
|
||||
Windows 无需额外依赖。macOS 需要 Xcode Command Line Tools。Linux 需要 X11 / Wayland / EGL 的开发头文件:
|
||||
|
||||
```bash
|
||||
sudo apt install -y pkg-config libwayland-dev libx11-dev libx11-xcb-dev \
|
||||
libxkbcommon-dev libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev \
|
||||
libffi-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxxf86vm-dev
|
||||
|
||||
go build -o tslink-gui ./cmd/tslink-gui
|
||||
```
|
||||
|
||||
界面语言默认跟随中文字体的可用性:找不到任何中文字体时自动切换为英文,以免显示成方块(也可在设置里手动切换)。
|
||||
|
||||
由于 Gio 依赖 CGO,GUI 无法像无界面版那样交叉编译,需要在目标平台上分别构建。
|
||||
@@ -0,0 +1,147 @@
|
||||
// Command tslink-gui is the desktop front-end for tslink.
|
||||
//
|
||||
// It runs the same service the headless binary does — see the root main.go —
|
||||
// but supervises it in-process so the window can show tailnet peer health,
|
||||
// latency history, 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()
|
||||
}
|
||||
+9
-1
@@ -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]]
|
||||
@@ -25,4 +33,4 @@ lan_motd = "Minecraft via Tailscale"
|
||||
[[connect.udp_example]]
|
||||
protocol = "udp"
|
||||
local_port = 24454
|
||||
dst_addr = "any-client-in.ts.net:24454"
|
||||
dst_addr = "any-client-in.ts.net:24454"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
if err != nil {
|
||||
// tsnet magicdns failed; fall back to system DNS for non-tailnet domains
|
||||
addr, err = fallbackSystemDNS(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 {
|
||||
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),
|
||||
|
||||
@@ -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:
|
||||
@@ -3,14 +3,17 @@ module tslink
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
gioui.org v0.10.1
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/mattn/go-colorable v0.1.13
|
||||
golang.org/x/net v0.53.0
|
||||
tailscale.com v1.98.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
gioui.org/shader v1.0.8 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
@@ -19,6 +22,7 @@ require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gaissmai/bart v0.26.1 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
|
||||
github.com/go-text/typesetting v0.3.4 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
@@ -44,7 +48,8 @@ require (
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/image v0.27.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
gioui.org v0.10.1 h1:Dvp6iDk9RKuZk19jxhOmb4p673CLVvb656LyMxQ+uO0=
|
||||
gioui.org v0.10.1/go.mod h1:MZJZsdEPkTBzChdqeE8CiiQhreUQBj43qusDxQNDf7k=
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
@@ -76,6 +83,10 @@ github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHj
|
||||
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
|
||||
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
|
||||
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
|
||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
|
||||
@@ -193,6 +204,8 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
|
||||
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
|
||||
+767
@@ -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
@@ -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}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"gioui.org/layout"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
|
||||
"tslink/core"
|
||||
)
|
||||
|
||||
type settingsPage struct {
|
||||
app *App
|
||||
list widget.List
|
||||
|
||||
theme widget.Enum
|
||||
lang widget.Enum
|
||||
restart widget.Clickable
|
||||
}
|
||||
|
||||
func newSettingsPage(a *App) *settingsPage {
|
||||
p := &settingsPage{app: a}
|
||||
p.list.Axis = layout.Vertical
|
||||
p.theme.Value = "dark"
|
||||
if !a.th.Dark {
|
||||
p.theme.Value = "light"
|
||||
}
|
||||
p.lang.Value = "zh"
|
||||
if a.th.Lang == LangEN {
|
||||
p.lang.Value = "en"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *settingsPage) Layout(a *App, gtx C, st core.State) D {
|
||||
th := a.th
|
||||
|
||||
if p.theme.Update(gtx) {
|
||||
th.SetDark(p.theme.Value == "dark")
|
||||
}
|
||||
if p.lang.Update(gtx) {
|
||||
if p.lang.Value == "en" {
|
||||
th.Lang = LangEN
|
||||
} else {
|
||||
th.Lang = LangZH
|
||||
}
|
||||
}
|
||||
if p.restart.Clicked(gtx) && a.opt.Supervisor != nil {
|
||||
a.opt.Supervisor.Restart()
|
||||
a.notify(th.T(KStateRetrying), LevelInfo)
|
||||
}
|
||||
|
||||
items := []layout.Widget{
|
||||
func(gtx C) D { return p.appearanceCard(a, gtx) },
|
||||
func(gtx C) D { return p.aboutCard(a, gtx, st) },
|
||||
}
|
||||
return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D {
|
||||
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, items[i])
|
||||
})
|
||||
}
|
||||
|
||||
func (p *settingsPage) appearanceCard(a *App, gtx C) D {
|
||||
th := a.th
|
||||
card := th.Card()
|
||||
card.Title = th.T(KNavSettings)
|
||||
return card.Layout(th, gtx, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return p.settingRow(a, gtx, th.T(KSetTheme), "", func(gtx C) D {
|
||||
return th.Segmented(gtx, &p.theme, []SegmentOption{
|
||||
{Key: "dark", Label: th.T(KSetThemeDark), Count: -1},
|
||||
{Key: "light", Label: th.T(KSetThemeLight), Count: -1},
|
||||
})
|
||||
})
|
||||
}),
|
||||
layout.Rigid(th.Divider),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
hint := ""
|
||||
if !th.HasCJK {
|
||||
hint = th.T(KSetFontMissing)
|
||||
}
|
||||
return p.settingRow(a, gtx, th.T(KSetLanguage), hint, func(gtx C) D {
|
||||
return th.Segmented(gtx, &p.lang, []SegmentOption{
|
||||
{Key: "zh", Label: "中文", Count: -1},
|
||||
{Key: "en", Label: "English", Count: -1},
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *settingsPage) settingRow(a *App, gtx C, label, hint string, control layout.Widget) D {
|
||||
th := a.th
|
||||
return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(th.Body(label).Layout),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if hint == "" {
|
||||
return D{}
|
||||
}
|
||||
return th.Caption(hint).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(control),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *settingsPage) aboutCard(a *App, gtx C, st core.State) D {
|
||||
th := a.th
|
||||
card := th.Card()
|
||||
card.Title = th.T(KSetAbout)
|
||||
card.Trailing = func(gtx C) D {
|
||||
return th.Button(gtx, &p.restart, ButtonStyle{
|
||||
Kind: ButtonSubtle, Text: th.T(KRetry), Icon: IconRefresh,
|
||||
})
|
||||
}
|
||||
|
||||
cfgPath := a.opt.ConfigPath
|
||||
if a.opt.ConfigURL != "" {
|
||||
cfgPath = a.opt.ConfigURL
|
||||
}
|
||||
fontPath := a.fonts.CJKPath
|
||||
if fontPath == "" {
|
||||
fontPath = th.T(KNone)
|
||||
}
|
||||
|
||||
return card.Layout(th, gtx, func(gtx C) D {
|
||||
rows := []KV{
|
||||
{Key: th.T(KSetVersion), Value: orDash(a.opt.Version), Mono: true},
|
||||
{Key: "Runtime", Value: runtimeInfo(), Mono: true},
|
||||
{Key: th.T(KSetConfigPath), Value: orDash(cfgPath), Mono: true},
|
||||
{Key: th.T(KSetFont), Value: fontPath, Mono: true},
|
||||
{Key: th.T(KStateRunning), Value: st.Phase.String()},
|
||||
}
|
||||
if st.Err != "" {
|
||||
rows = append(rows, KV{Key: th.T(KError), Value: st.Err, Level: LevelFail})
|
||||
}
|
||||
return th.KVList(gtx, rows)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -0,0 +1,268 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget/material"
|
||||
)
|
||||
|
||||
// Spacing scale. Every gap in the UI is one of these; ad-hoc values are what
|
||||
// makes an interface feel noisy.
|
||||
const (
|
||||
SpaceXS unit.Dp = 4
|
||||
SpaceSM unit.Dp = 8
|
||||
SpaceMD unit.Dp = 12
|
||||
SpaceLG unit.Dp = 16
|
||||
SpaceXL unit.Dp = 24
|
||||
Space2XL unit.Dp = 32
|
||||
)
|
||||
|
||||
// Corner radii.
|
||||
const (
|
||||
RadiusSM unit.Dp = 6
|
||||
RadiusMD unit.Dp = 10
|
||||
RadiusLG unit.Dp = 14
|
||||
RadiusPill unit.Dp = 999
|
||||
)
|
||||
|
||||
// Type scale. Only five sizes exist so hierarchy stays legible when a panel is
|
||||
// dense with numbers.
|
||||
const (
|
||||
SizeDisplay unit.Sp = 23
|
||||
SizeTitle unit.Sp = 17
|
||||
SizeSubtitle unit.Sp = 14
|
||||
SizeBody unit.Sp = 13
|
||||
SizeCaption unit.Sp = 11.5
|
||||
SizeMono unit.Sp = 12
|
||||
)
|
||||
|
||||
// Palette holds every colour the UI is allowed to use.
|
||||
type Palette struct {
|
||||
// Surfaces, from furthest back to nearest front.
|
||||
Bg color.NRGBA
|
||||
BgElevated color.NRGBA
|
||||
Surface color.NRGBA
|
||||
SurfaceHi color.NRGBA
|
||||
|
||||
Border color.NRGBA
|
||||
BorderHi color.NRGBA
|
||||
|
||||
// Three text weights carry the whole information hierarchy: primary for
|
||||
// values, secondary for labels, dim for metadata.
|
||||
TextPri color.NRGBA
|
||||
TextSec color.NRGBA
|
||||
TextDim color.NRGBA
|
||||
|
||||
Accent color.NRGBA
|
||||
AccentDim color.NRGBA
|
||||
AccentFg color.NRGBA
|
||||
|
||||
OK color.NRGBA
|
||||
Warn color.NRGBA
|
||||
Fail color.NRGBA
|
||||
Info color.NRGBA
|
||||
|
||||
// Series colours for the latency chart, in assignment order. They are
|
||||
// distinguishable at 2px stroke width and stay distinct in both themes.
|
||||
Series []color.NRGBA
|
||||
|
||||
// Scrim dims the app behind the loading overlay.
|
||||
Scrim color.NRGBA
|
||||
}
|
||||
|
||||
func rgb(v uint32) color.NRGBA {
|
||||
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xFF}
|
||||
}
|
||||
|
||||
// DarkPalette is the default. The app is a diagnostic tool that people leave
|
||||
// open in the background, so it defaults to the low-glare theme.
|
||||
func DarkPalette() Palette {
|
||||
return Palette{
|
||||
Bg: rgb(0x0E1116),
|
||||
BgElevated: rgb(0x141922),
|
||||
Surface: rgb(0x1A202B),
|
||||
SurfaceHi: rgb(0x222A38),
|
||||
|
||||
Border: rgb(0x252E3B),
|
||||
BorderHi: rgb(0x364153),
|
||||
|
||||
TextPri: rgb(0xE7EBF3),
|
||||
TextSec: rgb(0x9AA4B8),
|
||||
TextDim: rgb(0x69738A),
|
||||
|
||||
Accent: rgb(0x4C8DFF),
|
||||
AccentDim: rgb(0x27447A),
|
||||
AccentFg: rgb(0xFFFFFF),
|
||||
|
||||
OK: rgb(0x3DCE87),
|
||||
Warn: rgb(0xF0A93B),
|
||||
Fail: rgb(0xFF6B6B),
|
||||
Info: rgb(0x8B9BFF),
|
||||
|
||||
Series: []color.NRGBA{
|
||||
rgb(0x4C8DFF), rgb(0x2DD4BF), rgb(0xA78BFA), rgb(0xFBBF24),
|
||||
rgb(0xF472B6), rgb(0xA3E635), rgb(0x38BDF8), rgb(0xFB923C),
|
||||
},
|
||||
|
||||
Scrim: color.NRGBA{R: 0x08, G: 0x0A, B: 0x0E, A: 0xC4},
|
||||
}
|
||||
}
|
||||
|
||||
// LightPalette mirrors the dark one for people working in bright rooms.
|
||||
func LightPalette() Palette {
|
||||
return Palette{
|
||||
Bg: rgb(0xF6F7F9),
|
||||
BgElevated: rgb(0xFFFFFF),
|
||||
Surface: rgb(0xFFFFFF),
|
||||
SurfaceHi: rgb(0xF0F2F6),
|
||||
|
||||
Border: rgb(0xE3E7ED),
|
||||
BorderHi: rgb(0xCFD5DE),
|
||||
|
||||
TextPri: rgb(0x111826),
|
||||
TextSec: rgb(0x4A5568),
|
||||
TextDim: rgb(0x818C9E),
|
||||
|
||||
Accent: rgb(0x2563EB),
|
||||
AccentDim: rgb(0xBFD3FA),
|
||||
AccentFg: rgb(0xFFFFFF),
|
||||
|
||||
OK: rgb(0x0F9D58),
|
||||
Warn: rgb(0xC77700),
|
||||
Fail: rgb(0xD93636),
|
||||
Info: rgb(0x4F5DD1),
|
||||
|
||||
Series: []color.NRGBA{
|
||||
rgb(0x2563EB), rgb(0x0D9488), rgb(0x7C3AED), rgb(0xD97706),
|
||||
rgb(0xDB2777), rgb(0x65A30D), rgb(0x0284C7), rgb(0xEA580C),
|
||||
},
|
||||
|
||||
Scrim: color.NRGBA{R: 0x1A, G: 0x1F, B: 0x28, A: 0xB8},
|
||||
}
|
||||
}
|
||||
|
||||
// Theme bundles the Gio material theme with this app's design tokens.
|
||||
type Theme struct {
|
||||
*material.Theme
|
||||
P Palette
|
||||
Dark bool
|
||||
|
||||
// Mono is the typeface used for addresses, ports and log lines, where
|
||||
// column alignment matters more than typographic polish.
|
||||
Mono font.Typeface
|
||||
|
||||
// HasCJK reports whether a font with Chinese coverage was found. When it
|
||||
// is false the UI falls back to English labels rather than rendering
|
||||
// tofu boxes.
|
||||
HasCJK bool
|
||||
|
||||
// Lang selects the label set.
|
||||
Lang Lang
|
||||
}
|
||||
|
||||
// NewTheme builds a theme from a shaper and font collection produced by
|
||||
// [LoadFonts].
|
||||
func NewTheme(fonts *FontSet, dark bool) *Theme {
|
||||
mt := material.NewTheme()
|
||||
mt.Shaper = text.NewShaper(text.WithCollection(fonts.Collection))
|
||||
mt.TextSize = SizeBody
|
||||
mt.Face = fonts.UI
|
||||
mt.FingerSize = 26
|
||||
|
||||
th := &Theme{
|
||||
Theme: mt,
|
||||
Dark: dark,
|
||||
Mono: fonts.Mono,
|
||||
HasCJK: fonts.HasCJK,
|
||||
Lang: LangEN,
|
||||
}
|
||||
if fonts.HasCJK {
|
||||
th.Lang = LangZH
|
||||
}
|
||||
th.SetDark(dark)
|
||||
return th
|
||||
}
|
||||
|
||||
// SetDark switches palettes and keeps the embedded material palette in sync so
|
||||
// stock Gio widgets pick up the right colours too.
|
||||
func (t *Theme) SetDark(dark bool) {
|
||||
t.Dark = dark
|
||||
if dark {
|
||||
t.P = DarkPalette()
|
||||
} else {
|
||||
t.P = LightPalette()
|
||||
}
|
||||
t.Theme.Palette = material.Palette{
|
||||
Bg: t.P.Bg,
|
||||
Fg: t.P.TextPri,
|
||||
ContrastBg: t.P.Accent,
|
||||
ContrastFg: t.P.AccentFg,
|
||||
}
|
||||
}
|
||||
|
||||
// T looks up a localised string. It is a method on Theme so call sites stay
|
||||
// short: th.T(K.Peers).
|
||||
func (t *Theme) T(k Key) string { return Tr(t.Lang, k) }
|
||||
|
||||
// StatusColor maps a traffic-light verdict onto the palette.
|
||||
func (t *Theme) StatusColor(s StatusLevel) color.NRGBA {
|
||||
switch s {
|
||||
case LevelOK:
|
||||
return t.P.OK
|
||||
case LevelWarn:
|
||||
return t.P.Warn
|
||||
case LevelFail:
|
||||
return t.P.Fail
|
||||
case LevelInfo:
|
||||
return t.P.Info
|
||||
default:
|
||||
return t.P.TextDim
|
||||
}
|
||||
}
|
||||
|
||||
// StatusLevel is the UI-side severity, deliberately decoupled from
|
||||
// netdiag.Status so widgets do not depend on the diagnostics package.
|
||||
type StatusLevel int
|
||||
|
||||
const (
|
||||
LevelNeutral StatusLevel = iota
|
||||
LevelOK
|
||||
LevelWarn
|
||||
LevelFail
|
||||
LevelInfo
|
||||
)
|
||||
|
||||
// SeriesColor returns a stable chart colour for index i.
|
||||
func (t *Theme) SeriesColor(i int) color.NRGBA {
|
||||
if len(t.P.Series) == 0 {
|
||||
return t.P.Accent
|
||||
}
|
||||
return t.P.Series[i%len(t.P.Series)]
|
||||
}
|
||||
|
||||
// WithAlpha returns c with its alpha scaled by a (0..1).
|
||||
func WithAlpha(c color.NRGBA, a float32) color.NRGBA {
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if a > 1 {
|
||||
a = 1
|
||||
}
|
||||
c.A = uint8(float32(c.A) * a)
|
||||
return c
|
||||
}
|
||||
|
||||
// Mix blends a into b by t (0 returns a, 1 returns b).
|
||||
func Mix(a, b color.NRGBA, t float32) color.NRGBA {
|
||||
if t < 0 {
|
||||
t = 0
|
||||
}
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
lerp := func(x, y uint8) uint8 { return uint8(float32(x) + (float32(y)-float32(x))*t) }
|
||||
return color.NRGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: lerp(a.A, b.A)}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runtimeInfo describes the host, for diagnostic bundle headers.
|
||||
func runtimeInfo() string {
|
||||
return runtime.GOOS + "/" + runtime.GOARCH + " go" + runtime.Version()[2:]
|
||||
}
|
||||
|
||||
// timeSince is time.Since, wrapped so tests can reason about it and so call
|
||||
// sites in layout code read consistently.
|
||||
func timeSince(t time.Time) time.Duration {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(t)
|
||||
}
|
||||
+955
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "、")
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,470 @@
|
||||
package netdiag
|
||||
|
||||
// This file resolves public IP addresses to a rough location and network
|
||||
// operator.
|
||||
//
|
||||
// PRIVACY: every lookup here sends the user's own public IP address to a
|
||||
// third-party API (ipinfo.io, ip-api.com, api.ip.sb). Those services see the
|
||||
// address, the timestamp and our source IP — which for a direct query is that
|
||||
// very same address. Nothing else is sent: no hostname, no tailnet identity,
|
||||
// no credentials. Callers who are not comfortable with that must set
|
||||
// [Options.SkipGeo], which exists precisely for this reason, and no request in
|
||||
// this file will be made. The ipinfo token, when configured, is passed as a
|
||||
// query parameter to that provider only and is never logged or stored in a
|
||||
// report.
|
||||
//
|
||||
// Providers are tried in order and the first usable answer wins. The order is
|
||||
// not arbitrary: ipinfo.io is the most accurate but rate-limits hard without a
|
||||
// token, ip-api.com is HTTP-only on the free tier yet stays reachable from
|
||||
// mainland China, and api.ip.sb is the last resort.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// geoTimeout bounds one provider query.
|
||||
geoTimeout = 4 * time.Second
|
||||
// geoMaxBody caps a provider response. Well-behaved answers are a few
|
||||
// hundred bytes; the cap guards against a hijacked or error page.
|
||||
geoMaxBody = 64 << 10
|
||||
// geoMaxInflight bounds concurrent lookups in [AnnotateGeo]. Kept low
|
||||
// because the free tiers of these APIs rate-limit per source IP.
|
||||
geoMaxInflight = 4
|
||||
)
|
||||
|
||||
// geoCGNAT is RFC 6598 shared address space. Tailscale also allocates node
|
||||
// addresses out of it, and either way no geolocation provider can say anything
|
||||
// useful about such an address.
|
||||
var geoCGNAT = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
// geoSkipErr is reported for addresses that are not globally routable.
|
||||
const geoSkipErr = "私有地址,跳过查询"
|
||||
|
||||
func geoLog(logger *slog.Logger) *slog.Logger {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return logger.With(slog.String("from", "netdiag/geo"))
|
||||
}
|
||||
|
||||
// geoSkippable reports whether ip is not worth (or not safe to) look up:
|
||||
// loopback, RFC1918/ULA private, link-local, CGNAT, multicast or unspecified.
|
||||
func geoSkippable(ip netip.Addr) bool {
|
||||
ip = ip.Unmap()
|
||||
if !ip.IsValid() {
|
||||
return true
|
||||
}
|
||||
if ip.Is4() && geoCGNAT.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified()
|
||||
}
|
||||
|
||||
// geoProvider is one geolocation backend.
|
||||
type geoProvider struct {
|
||||
name string
|
||||
// fetch fills a GeoInfo from the provider, or returns an error so the next
|
||||
// provider is tried.
|
||||
fetch func(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error)
|
||||
}
|
||||
|
||||
// geoProviders returns the backends in the order they are tried.
|
||||
func geoProviders() []geoProvider {
|
||||
return []geoProvider{
|
||||
{name: "ipinfo.io", fetch: geoFetchIPInfo},
|
||||
{name: "ip-api.com", fetch: geoFetchIPAPI},
|
||||
{name: "ip.sb", fetch: geoFetchIPSB},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupGeo resolves one address to a location and operator.
|
||||
//
|
||||
// Providers are tried in order until one answers; the returned GeoInfo names
|
||||
// the provider that did in its Provider field. When all of them fail, Provider
|
||||
// is empty and Err holds the last error. Addresses that are not globally
|
||||
// routable are never sent anywhere: they come back immediately with Err set to
|
||||
// "私有地址,跳过查询".
|
||||
//
|
||||
// token is an optional ipinfo.io API token; it raises that provider's rate
|
||||
// limit and is never logged.
|
||||
//
|
||||
// Each provider gets its own ~4s budget, so the whole call is bounded even if
|
||||
// every backend hangs. See the privacy note at the top of this file.
|
||||
func LookupGeo(ctx context.Context, ip netip.Addr, token string, logger *slog.Logger) GeoInfo {
|
||||
log := geoLog(logger)
|
||||
ip = ip.Unmap().WithZone("")
|
||||
|
||||
if geoSkippable(ip) {
|
||||
return GeoInfo{IP: ip, Err: geoSkipErr}
|
||||
}
|
||||
|
||||
var lastErr string
|
||||
for _, p := range geoProviders() {
|
||||
if ctx.Err() != nil {
|
||||
return GeoInfo{IP: ip, Err: rchErrText(ctx.Err())}
|
||||
}
|
||||
pctx, cancel := context.WithTimeout(ctx, geoTimeout)
|
||||
info, err := p.fetch(pctx, ip, token)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = fmt.Sprintf("%s: %s", p.name, rchErrText(err))
|
||||
log.With(
|
||||
slog.String("ip", ip.String()),
|
||||
slog.String("provider", p.name),
|
||||
slog.String("error", rchErrText(err)),
|
||||
).Debug("geo provider failed")
|
||||
continue
|
||||
}
|
||||
info.IP = ip
|
||||
info.Provider = p.name
|
||||
log.With(
|
||||
slog.String("ip", ip.String()),
|
||||
slog.String("provider", p.name),
|
||||
slog.String("country", info.Country),
|
||||
slog.String("asn", info.ASN),
|
||||
).Debug("resolved ip location")
|
||||
return info
|
||||
}
|
||||
|
||||
if lastErr == "" {
|
||||
lastErr = "no geolocation provider answered"
|
||||
}
|
||||
return GeoInfo{IP: ip, Err: lastErr}
|
||||
}
|
||||
|
||||
// AnnotateGeo fills rep.Geo and rep.Countries for every address in
|
||||
// rep.UniqueIPs and recomputes rep.Summary. It is a no-op when the report has
|
||||
// no addresses.
|
||||
//
|
||||
// Lookups run concurrently but at most [geoMaxInflight] at a time, since the
|
||||
// free tiers rate-limit per source IP. Results are sorted by address and the
|
||||
// country list is deduplicated, so repeated runs render identically.
|
||||
//
|
||||
// This function performs third-party network requests; see the privacy note at
|
||||
// the top of this file and [Options.SkipGeo].
|
||||
func AnnotateGeo(ctx context.Context, rep *EgressReport, token string, logger *slog.Logger) {
|
||||
if rep == nil || len(rep.UniqueIPs) == 0 {
|
||||
return
|
||||
}
|
||||
log := geoLog(logger)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
out = make([]GeoInfo, 0, len(rep.UniqueIPs))
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, geoMaxInflight)
|
||||
)
|
||||
|
||||
for _, ip := range rep.UniqueIPs {
|
||||
wg.Add(1)
|
||||
go func(ip netip.Addr) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
mu.Lock()
|
||||
out = append(out, GeoInfo{IP: ip, Err: rchErrText(ctx.Err())})
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
info := LookupGeo(ctx, ip, token, log)
|
||||
mu.Lock()
|
||||
out = append(out, info)
|
||||
mu.Unlock()
|
||||
}(ip)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].IP.Compare(out[j].IP) < 0 })
|
||||
rep.Geo = out
|
||||
rep.Countries = geoCountries(out)
|
||||
egFinish(rep)
|
||||
|
||||
log.With(
|
||||
slog.Int("addrs", len(rep.Geo)),
|
||||
slog.String("countries", strings.Join(rep.Countries, ",")),
|
||||
).Debug("annotated egress addresses with geolocation")
|
||||
}
|
||||
|
||||
// geoCountries returns the sorted, deduplicated set of countries seen,
|
||||
// preferring the ISO code and falling back to the localised name when a
|
||||
// provider only supplied that.
|
||||
func geoCountries(gs []GeoInfo) []string {
|
||||
seen := make(map[string]struct{}, len(gs))
|
||||
var out []string
|
||||
for _, g := range gs {
|
||||
name := strings.TrimSpace(g.Country)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(g.CountryName)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// geoGetJSON fetches url and decodes the body into v. The client uses the
|
||||
// unforced network and honours the environment proxy: unlike the egress
|
||||
// probes, we do not care which path the query takes, only that it succeeds.
|
||||
func geoGetJSON(ctx context.Context, target string, v any) error {
|
||||
client := newDiagClient("", true, geoTimeout)
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
hdr := http.Header{}
|
||||
hdr.Set("Accept", "application/json")
|
||||
|
||||
code, body, _, err := diagGet(ctx, client, target, geoMaxBody, hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code < 200 || code > 299 {
|
||||
return fmt.Errorf("unexpected status %d", code)
|
||||
}
|
||||
if err := json.Unmarshal(body, v); err != nil {
|
||||
return fmt.Errorf("bad json: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// geoIPInfoResp is the subset of ipinfo.io's answer we use.
|
||||
type geoIPInfoResp struct {
|
||||
IP string `json:"ip"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
CountryName string `json:"country_name"` // only on paid plans
|
||||
Loc string `json:"loc"`
|
||||
Org string `json:"org"`
|
||||
Timezone string `json:"timezone"`
|
||||
Bogon bool `json:"bogon"`
|
||||
}
|
||||
|
||||
// geoFetchIPInfo queries ipinfo.io. The token, when non-empty, only raises the
|
||||
// rate limit; it is appended as a query parameter and never logged.
|
||||
func geoFetchIPInfo(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error) {
|
||||
target := "https://ipinfo.io/" + url.PathEscape(ip.String()) + "/json"
|
||||
if token != "" {
|
||||
target += "?token=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
var r geoIPInfoResp
|
||||
if err := geoGetJSON(ctx, target, &r); err != nil {
|
||||
return GeoInfo{}, err
|
||||
}
|
||||
if r.Bogon {
|
||||
return GeoInfo{}, fmt.Errorf("provider reports bogon address")
|
||||
}
|
||||
if r.Country == "" && r.Org == "" && r.City == "" {
|
||||
return GeoInfo{}, fmt.Errorf("empty answer")
|
||||
}
|
||||
|
||||
asn, org := geoSplitOrg(r.Org)
|
||||
return GeoInfo{
|
||||
Country: strings.TrimSpace(r.Country),
|
||||
CountryName: strings.TrimSpace(r.CountryName),
|
||||
Region: strings.TrimSpace(r.Region),
|
||||
City: strings.TrimSpace(r.City),
|
||||
Org: org,
|
||||
ASN: asn,
|
||||
Loc: strings.TrimSpace(r.Loc),
|
||||
Timezone: strings.TrimSpace(r.Timezone),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// geoIPAPIResp is ip-api.com's answer for the field set we request.
|
||||
type geoIPAPIResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
RegionName string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
ISP string `json:"isp"`
|
||||
Org string `json:"org"`
|
||||
AS string `json:"as"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// geoFetchIPAPI queries ip-api.com. The free tier is HTTP-only, which is also
|
||||
// why it keeps working from mainland China where the HTTPS providers often do
|
||||
// not. Answers are requested in Chinese to match the rest of the UI.
|
||||
func geoFetchIPAPI(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
|
||||
target := "http://ip-api.com/json/" + url.PathEscape(ip.String()) +
|
||||
"?lang=zh-CN&fields=status,message,country,countryCode,regionName,city,isp,org,as,timezone"
|
||||
|
||||
var r geoIPAPIResp
|
||||
if err := geoGetJSON(ctx, target, &r); err != nil {
|
||||
return GeoInfo{}, err
|
||||
}
|
||||
if !strings.EqualFold(r.Status, "success") {
|
||||
msg := strings.TrimSpace(r.Message)
|
||||
if msg == "" {
|
||||
msg = r.Status
|
||||
}
|
||||
return GeoInfo{}, fmt.Errorf("query failed: %s", msg)
|
||||
}
|
||||
|
||||
asn, asOrg := geoSplitOrg(r.AS)
|
||||
org := strings.TrimSpace(r.Org)
|
||||
if org == "" {
|
||||
org = strings.TrimSpace(r.ISP)
|
||||
}
|
||||
if org == "" {
|
||||
org = asOrg
|
||||
}
|
||||
return GeoInfo{
|
||||
Country: strings.TrimSpace(r.CountryCode),
|
||||
CountryName: strings.TrimSpace(r.Country),
|
||||
Region: strings.TrimSpace(r.RegionName),
|
||||
City: strings.TrimSpace(r.City),
|
||||
Org: org,
|
||||
ASN: asn,
|
||||
Timezone: strings.TrimSpace(r.Timezone),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// geoIPSBResp is api.ip.sb's answer. ASN comes back as a bare number, so it is
|
||||
// decoded loosely and normalised by [geoASNText].
|
||||
type geoIPSBResp struct {
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
ISP string `json:"isp"`
|
||||
ASN any `json:"asn"`
|
||||
ASNOrg string `json:"asn_organization"`
|
||||
Timezone string `json:"timezone"`
|
||||
Latitude any `json:"latitude"`
|
||||
Longitude any `json:"longitude"`
|
||||
}
|
||||
|
||||
// geoFetchIPSB queries api.ip.sb, the last-resort provider.
|
||||
func geoFetchIPSB(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
|
||||
target := "https://api.ip.sb/geoip/" + url.PathEscape(ip.String())
|
||||
|
||||
var r geoIPSBResp
|
||||
if err := geoGetJSON(ctx, target, &r); err != nil {
|
||||
return GeoInfo{}, err
|
||||
}
|
||||
if r.CountryCode == "" && r.Country == "" && r.ISP == "" {
|
||||
return GeoInfo{}, fmt.Errorf("empty answer")
|
||||
}
|
||||
|
||||
org := strings.TrimSpace(r.ISP)
|
||||
if org == "" {
|
||||
org = strings.TrimSpace(r.ASNOrg)
|
||||
}
|
||||
return GeoInfo{
|
||||
Country: strings.TrimSpace(r.CountryCode),
|
||||
CountryName: strings.TrimSpace(r.Country),
|
||||
Region: strings.TrimSpace(r.Region),
|
||||
City: strings.TrimSpace(r.City),
|
||||
Org: org,
|
||||
ASN: geoASNText(r.ASN),
|
||||
Loc: geoLocText(r.Latitude, r.Longitude),
|
||||
Timezone: strings.TrimSpace(r.Timezone),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// geoSplitOrg splits an "AS4134 Chinanet" style string into the ASN and the
|
||||
// operator name. Either half may be missing, in which case the whole string is
|
||||
// treated as the operator name.
|
||||
func geoSplitOrg(s string) (asn, org string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", ""
|
||||
}
|
||||
head, rest, _ := strings.Cut(s, " ")
|
||||
if geoLooksLikeASN(head) {
|
||||
return head, strings.TrimSpace(rest)
|
||||
}
|
||||
return "", s
|
||||
}
|
||||
|
||||
// geoLooksLikeASN reports whether s is an "AS####" token.
|
||||
func geoLooksLikeASN(s string) bool {
|
||||
if len(s) < 3 || !strings.EqualFold(s[:2], "AS") {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.ParseUint(s[2:], 10, 32)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// geoASNText normalises a JSON asn field (number or string) to "AS####".
|
||||
func geoASNText(v any) string {
|
||||
var s string
|
||||
switch n := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case float64:
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
s = strconv.FormatFloat(n, 'f', -1, 64)
|
||||
case string:
|
||||
s = strings.TrimSpace(n)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if s == "" || s == "0" {
|
||||
return ""
|
||||
}
|
||||
if geoLooksLikeASN(s) {
|
||||
return strings.ToUpper(s[:2]) + s[2:]
|
||||
}
|
||||
if _, err := strconv.ParseUint(s, 10, 32); err != nil {
|
||||
return ""
|
||||
}
|
||||
return "AS" + s
|
||||
}
|
||||
|
||||
// geoLocText renders a latitude/longitude pair in ipinfo's "lat,lon" form so
|
||||
// the Loc field means the same thing whichever provider answered.
|
||||
func geoLocText(lat, lon any) string {
|
||||
f := func(v any) (string, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return strconv.FormatFloat(n, 'f', -1, 64), true
|
||||
case string:
|
||||
s := strings.TrimSpace(n)
|
||||
return s, s != ""
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
a, okA := f(lat)
|
||||
b, okB := f(lon)
|
||||
if !okA || !okB {
|
||||
return ""
|
||||
}
|
||||
return a + "," + b
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package netdiag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ifDialTimeout bounds each source-address discovery dial. The dial is to a
|
||||
// UDP address, so no packet leaves the machine and the kernel answers from its
|
||||
// routing table immediately; the timeout only guards against a pathological
|
||||
// resolver or a wedged network stack.
|
||||
const ifDialTimeout = 2 * time.Second
|
||||
|
||||
// ifMaxInflight bounds how many interfaces are inspected concurrently.
|
||||
const ifMaxInflight = 8
|
||||
|
||||
// ifDefaultV4Target and ifDefaultV6Target are well-known anycast resolvers used
|
||||
// purely as "somewhere on the default route" destinations.
|
||||
const (
|
||||
ifDefaultV4Target = "8.8.8.8:80"
|
||||
ifDefaultV6Target = "[2001:4860:4860::8888]:80"
|
||||
)
|
||||
|
||||
// tailscaleV6Prefix is the ULA range Tailscale assigns to every node.
|
||||
var tailscaleV6Prefix = netip.MustParsePrefix("fd7a:115c:a1e0::/48")
|
||||
|
||||
// cgnatPrefix is RFC 6598 shared address space. Tailscale allocates its IPv4
|
||||
// node addresses out of 100.64.0.0/10 as well, which is why an address here
|
||||
// needs the interface name to be classified precisely; see [classifyOnIface].
|
||||
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
var (
|
||||
ulaPrefix = netip.MustParsePrefix("fc00::/7")
|
||||
linkLocalV4Pfx = netip.MustParsePrefix("169.254.0.0/16")
|
||||
rfc1918Prefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
}
|
||||
)
|
||||
|
||||
// ifTailscaleIfaceNames are the interface-name prefixes Tailscale (and the
|
||||
// wireguard/utun devices it rides on) uses across platforms.
|
||||
var ifTailscaleIfaceNames = []string{"tailscale", "ts", "utun", "wg"}
|
||||
|
||||
func ifLog(logger *slog.Logger) *slog.Logger {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return logger.With(slog.String("from", "netdiag/iface"))
|
||||
}
|
||||
|
||||
// ClassifyAddr buckets an address by reachable scope.
|
||||
//
|
||||
// Addresses in 100.64.0.0/10 are reported as [AddrCGNAT] because the range
|
||||
// alone cannot distinguish a carrier-grade NAT lease from a Tailscale node
|
||||
// address. [EnumerateInterfaces] refines that verdict using the interface name.
|
||||
func ClassifyAddr(a netip.Addr) AddrKind {
|
||||
a = a.Unmap()
|
||||
switch {
|
||||
case !a.IsValid():
|
||||
return AddrLinkLocal // degenerate input; never reachable
|
||||
case a.IsLoopback():
|
||||
return AddrLoopback
|
||||
case a.Is4() && cgnatPrefix.Contains(a):
|
||||
return AddrCGNAT
|
||||
case a.Is6() && tailscaleV6Prefix.Contains(a):
|
||||
return AddrTailscale
|
||||
case a.IsLinkLocalUnicast() || a.IsLinkLocalMulticast() || (a.Is4() && linkLocalV4Pfx.Contains(a)):
|
||||
return AddrLinkLocal
|
||||
case a.Is4() && isRFC1918(a):
|
||||
return AddrPrivateV4
|
||||
case a.Is6() && ulaPrefix.Contains(a):
|
||||
return AddrULA
|
||||
case a.Is4():
|
||||
return AddrGlobalV4
|
||||
default:
|
||||
return AddrGlobalV6
|
||||
}
|
||||
}
|
||||
|
||||
func isRFC1918(a netip.Addr) bool {
|
||||
for _, p := range rfc1918Prefixes {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyOnIface applies [ClassifyAddr] and then corrects the one case the
|
||||
// address alone cannot decide: Tailscale hands out IPv4 addresses from the
|
||||
// CGNAT range 100.64.0.0/10, so a 100.x address sitting on an interface named
|
||||
// tailscale*/ts*/utun*/wg* is a tailnet address rather than a carrier NAT
|
||||
// lease. The heuristic is name-based because the alternative (asking tailscaled)
|
||||
// would make this package depend on tailscale.com.
|
||||
func classifyOnIface(a netip.Addr, iface string) AddrKind {
|
||||
kind := ClassifyAddr(a)
|
||||
if kind == AddrCGNAT && isTailscaleIfaceName(iface) {
|
||||
return AddrTailscale
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
func isTailscaleIfaceName(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
for _, p := range ifTailscaleIfaceNames {
|
||||
if strings.HasPrefix(n, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnumerateInterfaces lists every address bound to every local interface and
|
||||
// determines which source addresses the kernel would use for default routes.
|
||||
func EnumerateInterfaces(ctx context.Context, logger *slog.Logger) InterfaceReport {
|
||||
log := ifLog(logger)
|
||||
var rep InterfaceReport
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
log.With(slog.String("error", err.Error())).Error("failed to enumerate interfaces")
|
||||
rep.Err = err.Error()
|
||||
rep.Status = StatusFail
|
||||
rep.Summary = "无法枚举本机网络接口"
|
||||
return rep
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
addrs []LocalAddr
|
||||
nIface int
|
||||
)
|
||||
|
||||
sem := make(chan struct{}, ifMaxInflight)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Source discovery is independent of enumeration, so run both in parallel.
|
||||
var v4Src, v6Src netip.Addr
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v4Src = defaultSource(ctx, "udp4", ifDefaultV4Target, log)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v6Src = defaultSource(ctx, "udp6", ifDefaultV6Target, log)
|
||||
}()
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(iface net.Interface) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
got := ifaceAddrs(iface, log)
|
||||
mu.Lock()
|
||||
if len(got) > 0 {
|
||||
nIface++
|
||||
}
|
||||
addrs = append(addrs, got...)
|
||||
mu.Unlock()
|
||||
}(iface)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rep.Addrs = addrs
|
||||
rep.DefaultV4Src = v4Src
|
||||
rep.DefaultV6Src = v6Src
|
||||
|
||||
sortLocalAddrs(rep.Addrs)
|
||||
|
||||
hasGlobalV6Addr := false
|
||||
for i := range rep.Addrs {
|
||||
a := &rep.Addrs[i]
|
||||
if a.Kind == AddrGlobalV6 {
|
||||
hasGlobalV6Addr = true
|
||||
}
|
||||
if (v4Src.IsValid() && a.Addr == v4Src) || (v6Src.IsValid() && a.Addr == v6Src) {
|
||||
a.IsDefaultSrc = true
|
||||
}
|
||||
}
|
||||
rep.HasGlobalV6 = hasGlobalV6Addr && v6Src.IsValid() && ClassifyAddr(v6Src) == AddrGlobalV6
|
||||
|
||||
finishInterfaceReport(&rep, nIface)
|
||||
|
||||
log.With(
|
||||
slog.Int("interfaces", nIface),
|
||||
slog.Int("addrs", len(rep.Addrs)),
|
||||
slog.String("v4_src", addrText(rep.DefaultV4Src)),
|
||||
slog.String("v6_src", addrText(rep.DefaultV6Src)),
|
||||
slog.String("status", rep.Status.String()),
|
||||
).Debug("enumerated local interfaces")
|
||||
|
||||
return rep
|
||||
}
|
||||
|
||||
// ifaceAddrs converts one interface's bound addresses into [LocalAddr] entries.
|
||||
// Errors are logged and swallowed: one unreadable interface must not blank the
|
||||
// whole panel.
|
||||
func ifaceAddrs(iface net.Interface, log *slog.Logger) []LocalAddr {
|
||||
raw, err := iface.Addrs()
|
||||
if err != nil {
|
||||
log.With(
|
||||
slog.String("iface", iface.Name),
|
||||
slog.String("error", err.Error()),
|
||||
).Debug("failed to read interface addresses")
|
||||
return nil
|
||||
}
|
||||
|
||||
hw := ""
|
||||
if len(iface.HardwareAddr) > 0 {
|
||||
hw = strings.ToLower(iface.HardwareAddr.String())
|
||||
}
|
||||
up := iface.Flags&net.FlagUp != 0
|
||||
|
||||
out := make([]LocalAddr, 0, len(raw))
|
||||
for _, a := range raw {
|
||||
pfx, ok := toPrefix(a)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
addr := pfx.Addr().Unmap()
|
||||
// Keep the zone off the reported address so equality against the
|
||||
// default-source lookup and the sort order stay stable.
|
||||
addr = addr.WithZone("")
|
||||
out = append(out, LocalAddr{
|
||||
Iface: iface.Name,
|
||||
Addr: addr,
|
||||
Prefix: netip.PrefixFrom(addr, pfx.Bits()),
|
||||
Kind: classifyOnIface(addr, iface.Name),
|
||||
Up: up,
|
||||
MTU: iface.MTU,
|
||||
Hardware: hw,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toPrefix normalises the net.Addr values iface.Addrs returns (*net.IPNet on
|
||||
// every supported platform, *net.IPAddr on a few).
|
||||
func toPrefix(a net.Addr) (netip.Prefix, bool) {
|
||||
switch v := a.(type) {
|
||||
case *net.IPNet:
|
||||
addr, ok := netip.AddrFromSlice(v.IP)
|
||||
if !ok {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
ones, _ := v.Mask.Size()
|
||||
if ones <= 0 || ones > addr.BitLen() {
|
||||
ones = addr.BitLen()
|
||||
}
|
||||
return netip.PrefixFrom(addr, ones), true
|
||||
case *net.IPAddr:
|
||||
addr, ok := netip.AddrFromSlice(v.IP)
|
||||
if !ok {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
return netip.PrefixFrom(addr, addr.BitLen()), true
|
||||
default:
|
||||
addr, err := netip.ParsePrefix(a.String())
|
||||
if err != nil {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
return addr, true
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSource asks the kernel which local address it would use to reach a
|
||||
// destination on the default route. Dialling a UDP address only installs a
|
||||
// route lookup on the socket; nothing is transmitted. Failure is expected and
|
||||
// normal (notably for udp6 on IPv4-only hosts) and never populates Err.
|
||||
func defaultSource(ctx context.Context, network, target string, log *slog.Logger) netip.Addr {
|
||||
dctx, cancel := context.WithTimeout(ctx, ifDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(dctx, network, target)
|
||||
if err != nil {
|
||||
log.With(
|
||||
slog.String("network", network),
|
||||
slog.String("error", err.Error()),
|
||||
).Debug("no default source address")
|
||||
return netip.Addr{}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ua, ok := conn.LocalAddr().(*net.UDPAddr)
|
||||
if !ok {
|
||||
return netip.Addr{}
|
||||
}
|
||||
addr, ok := netip.AddrFromSlice(ua.IP)
|
||||
if !ok {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return addr.Unmap().WithZone("")
|
||||
}
|
||||
|
||||
// sortLocalAddrs orders entries by interface name, then IPv4 before IPv6, then
|
||||
// by address, so repeated refreshes render identically.
|
||||
func sortLocalAddrs(as []LocalAddr) {
|
||||
sort.Slice(as, func(i, j int) bool {
|
||||
x, y := as[i], as[j]
|
||||
if x.Iface != y.Iface {
|
||||
return x.Iface < y.Iface
|
||||
}
|
||||
if x.Addr.Is4() != y.Addr.Is4() {
|
||||
return x.Addr.Is4()
|
||||
}
|
||||
return x.Addr.Compare(y.Addr) < 0
|
||||
})
|
||||
}
|
||||
|
||||
// finishInterfaceReport derives Status and Summary from the collected data.
|
||||
func finishInterfaceReport(rep *InterfaceReport, nIface int) {
|
||||
if len(rep.Addrs) == 0 {
|
||||
rep.Status = StatusFail
|
||||
if rep.Err == "" {
|
||||
rep.Err = "no local addresses found"
|
||||
}
|
||||
rep.Summary = "未发现任何本机地址"
|
||||
return
|
||||
}
|
||||
|
||||
// A private v4 address still routes out through NAT, but only if the kernel
|
||||
// actually picked a default source for it.
|
||||
var globalCapable bool
|
||||
for _, a := range rep.Addrs {
|
||||
switch a.Kind {
|
||||
case AddrGlobalV4, AddrGlobalV6, AddrCGNAT, AddrTailscale:
|
||||
globalCapable = true
|
||||
case AddrPrivateV4:
|
||||
globalCapable = globalCapable || rep.DefaultV4Src.IsValid()
|
||||
}
|
||||
}
|
||||
if globalCapable {
|
||||
rep.Status = StatusOK
|
||||
} else {
|
||||
rep.Status = StatusWarn
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d 个接口 / %d 个地址", nIface, len(rep.Addrs))
|
||||
if rep.DefaultV4Src.IsValid() {
|
||||
fmt.Fprintf(&b, ",IPv4 出口 %s", rep.DefaultV4Src)
|
||||
} else {
|
||||
b.WriteString(",无 IPv4 出口")
|
||||
}
|
||||
if rep.DefaultV6Src.IsValid() {
|
||||
fmt.Fprintf(&b, ",IPv6 出口 %s", rep.DefaultV6Src)
|
||||
} else {
|
||||
b.WriteString(",无 IPv6 出口")
|
||||
}
|
||||
rep.Summary = b.String()
|
||||
}
|
||||
|
||||
// addrText renders an address for logging, using "-" for the invalid zero
|
||||
// value so log lines stay readable.
|
||||
func addrText(a netip.Addr) string {
|
||||
if !a.IsValid() {
|
||||
return "-"
|
||||
}
|
||||
return a.String()
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package netdiag
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pasteUserAgent identifies tslink to the paste services. 0x0.st rejects the
|
||||
// Go default user agent with 403, so this is not merely cosmetic.
|
||||
const pasteUserAgent = "tslink/1.0 (+diagnostics)"
|
||||
|
||||
// pasteTimeout bounds a single upload attempt, including connect, write and
|
||||
// the read of the response body.
|
||||
const pasteTimeout = 20 * time.Second
|
||||
|
||||
// MaxPasteBytes is the largest payload accepted by [Upload]. Bigger dumps are
|
||||
// rejected rather than truncated: the tail of a log is usually the part the
|
||||
// helper needs, and silently dropping it wastes everyone's time.
|
||||
const MaxPasteBytes = 1 << 20
|
||||
|
||||
// pasteReadLimit caps how much of a response body is read back. A URL is a
|
||||
// couple hundred bytes; anything larger is an error page.
|
||||
const pasteReadLimit = 64 << 10
|
||||
|
||||
// PasteTarget is one supported paste service.
|
||||
type PasteTarget struct {
|
||||
Key string // stable id used by the UI
|
||||
Name string // human label
|
||||
Note string // short caveat: retention, region reachability
|
||||
}
|
||||
|
||||
// pasteService is a target plus the code that performs the upload.
|
||||
type pasteService struct {
|
||||
PasteTarget
|
||||
upload func(ctx context.Context, text string) (string, error)
|
||||
}
|
||||
|
||||
// pasteServices is the internal registry, in preference order.
|
||||
var pasteServices = []pasteService{
|
||||
{
|
||||
PasteTarget: PasteTarget{
|
||||
Key: "0x0",
|
||||
Name: "0x0.st",
|
||||
Note: "保留 30 天以上(按大小递减),境内访问可能较慢",
|
||||
},
|
||||
upload: uploadNullPointer,
|
||||
},
|
||||
{
|
||||
PasteTarget: PasteTarget{
|
||||
Key: "paste_rs",
|
||||
Name: "paste.rs",
|
||||
Note: "无固定保留期,容量满后自动淘汰旧内容",
|
||||
},
|
||||
upload: uploadPasteRS,
|
||||
},
|
||||
{
|
||||
PasteTarget: PasteTarget{
|
||||
Key: "dpaste",
|
||||
Name: "dpaste.org",
|
||||
Note: "保留 7 天后自动删除",
|
||||
},
|
||||
upload: uploadDpaste,
|
||||
},
|
||||
{
|
||||
PasteTarget: PasteTarget{
|
||||
Key: "termbin",
|
||||
Name: "termbin.com",
|
||||
Note: "纯 TCP (9999),HTTPS 被墙时仍可用;保留约 1 个月",
|
||||
},
|
||||
upload: uploadTermbin,
|
||||
},
|
||||
}
|
||||
|
||||
// PasteTargets lists the supported services in preference order.
|
||||
func PasteTargets() []PasteTarget {
|
||||
out := make([]PasteTarget, 0, len(pasteServices))
|
||||
for _, s := range pasteServices {
|
||||
out = append(out, s.PasteTarget)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PasteResult is a successful upload.
|
||||
type PasteResult struct {
|
||||
URL string
|
||||
Target string
|
||||
Bytes int
|
||||
Uploaded time.Time
|
||||
}
|
||||
|
||||
// ErrPasteEmpty is returned when there is nothing to upload.
|
||||
var ErrPasteEmpty = errors.New("netdiag: refusing to upload empty text")
|
||||
|
||||
// ErrPasteTooLarge is returned when the payload exceeds [MaxPasteBytes].
|
||||
var ErrPasteTooLarge = fmt.Errorf("netdiag: text exceeds the %d byte paste limit", MaxPasteBytes)
|
||||
|
||||
// Upload sends text to the named target and returns the resulting public URL.
|
||||
// An empty targetKey tries every target in [PasteTargets] order and returns the
|
||||
// first success; when all of them fail the returned error names each failure.
|
||||
//
|
||||
// The caller MUST redact the text before calling: uploading is an outbound
|
||||
// publication of user data to a third party. core.LogBuffer.ExportText performs
|
||||
// that redaction (leave ExportOptions.NoRedact false). The resulting paste is
|
||||
// PUBLIC — anyone holding the URL can read it, and most of these services offer
|
||||
// no way to delete it afterwards.
|
||||
//
|
||||
// Every attempt is bounded by a ~20s timeout and honours ctx.
|
||||
func Upload(ctx context.Context, targetKey, text string, logger *slog.Logger) (*PasteResult, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
logger = logger.With(slog.String("from", "paste"))
|
||||
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, ErrPasteEmpty
|
||||
}
|
||||
if len(text) > MaxPasteBytes {
|
||||
return nil, fmt.Errorf("%w (got %d bytes); filter the log before sharing", ErrPasteTooLarge, len(text))
|
||||
}
|
||||
|
||||
candidates := pasteServices
|
||||
if targetKey != "" {
|
||||
svc, ok := lookupPasteService(targetKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("netdiag: unknown paste target %q", targetKey)
|
||||
}
|
||||
candidates = []pasteService{svc}
|
||||
}
|
||||
|
||||
var failures []string
|
||||
for _, svc := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := attemptPaste(ctx, svc, text)
|
||||
if err != nil {
|
||||
logger.With(
|
||||
slog.String("target", svc.Key),
|
||||
slog.String("error", err.Error()),
|
||||
).Debug("paste upload failed")
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", svc.Key, err))
|
||||
continue
|
||||
}
|
||||
logger.With(
|
||||
slog.String("target", res.Target),
|
||||
slog.String("url", res.URL),
|
||||
slog.Int("bytes", res.Bytes),
|
||||
).Info("uploaded diagnostic paste")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if len(candidates) == 1 {
|
||||
return nil, fmt.Errorf("netdiag: upload to %s failed: %s", candidates[0].Key, strings.TrimPrefix(failures[0], candidates[0].Key+": "))
|
||||
}
|
||||
return nil, fmt.Errorf("netdiag: every paste target failed: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
|
||||
// attemptPaste runs one upload under its own timeout and validates the URL the
|
||||
// service handed back.
|
||||
func attemptPaste(ctx context.Context, svc pasteService, text string) (*PasteResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, pasteTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := svc.upload(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clean, err := normalisePasteURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PasteResult{
|
||||
URL: clean,
|
||||
Target: svc.Key,
|
||||
Bytes: len(text),
|
||||
Uploaded: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// lookupPasteService finds a service by its stable key.
|
||||
func lookupPasteService(key string) (pasteService, bool) {
|
||||
for _, s := range pasteServices {
|
||||
if s.Key == key {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return pasteService{}, false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// normalisePasteURL trims a service response down to a single http(s) URL.
|
||||
// termbin answers with a bare host such as "termbin.com/abcd", so a missing
|
||||
// scheme is tolerated and upgraded to https. Anything that smells like an HTML
|
||||
// error page is rejected outright.
|
||||
func normalisePasteURL(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
// termbin pads its reply with NULs and terminal escapes.
|
||||
s = strings.Trim(s, "\x00\r\n\t ")
|
||||
if s == "" {
|
||||
return "", errors.New("empty response")
|
||||
}
|
||||
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
if looksLikeHTML(s) {
|
||||
return "", fmt.Errorf("service returned an error page: %s", snippet(s))
|
||||
}
|
||||
if len(s) > 512 {
|
||||
return "", fmt.Errorf("response is not a URL: %s", snippet(s))
|
||||
}
|
||||
if !strings.Contains(s, "://") {
|
||||
s = "https://" + s
|
||||
}
|
||||
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("response is not a URL: %s", snippet(raw))
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("response has unexpected scheme %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" || !strings.Contains(u.Host, ".") {
|
||||
return "", fmt.Errorf("response has no usable host: %s", snippet(s))
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// looksLikeHTML reports whether s is the beginning of an HTML document rather
|
||||
// than a URL.
|
||||
func looksLikeHTML(s string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(s))
|
||||
return strings.HasPrefix(lower, "<") ||
|
||||
strings.Contains(lower, "<html") ||
|
||||
strings.Contains(lower, "<!doctype")
|
||||
}
|
||||
|
||||
// snippet shortens an untrusted response for inclusion in an error message.
|
||||
func snippet(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
if len(s) > 120 {
|
||||
return s[:120] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// pasteHTTPClient is shared by the HTTP-based targets. The per-attempt context
|
||||
// timeout is the real deadline; the client timeout is a backstop.
|
||||
var pasteHTTPClient = &http.Client{
|
||||
Timeout: pasteTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// doPaste issues one request and returns the (size-limited) response body.
|
||||
func doPaste(ctx context.Context, method, endpoint, contentType string, body []byte) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", pasteUserAgent)
|
||||
req.Header.Set("Accept", "text/plain, */*")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
resp, err := pasteHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, pasteReadLimit))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("http %d: %s", resp.StatusCode, snippet(string(data)))
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// uploadNullPointer posts to 0x0.st as multipart/form-data.
|
||||
func uploadNullPointer(ctx context.Context, text string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile("file", "tslink-log.txt")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.WriteString(part, text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return doPaste(ctx, http.MethodPost, "https://0x0.st/", mw.FormDataContentType(), buf.Bytes())
|
||||
}
|
||||
|
||||
// uploadPasteRS posts the raw text to paste.rs.
|
||||
func uploadPasteRS(ctx context.Context, text string) (string, error) {
|
||||
return doPaste(ctx, http.MethodPost, "https://paste.rs/", "text/plain; charset=utf-8", []byte(text))
|
||||
}
|
||||
|
||||
// uploadDpaste posts a urlencoded form to dpaste.org and asks for a bare URL
|
||||
// back rather than the JSON representation.
|
||||
func uploadDpaste(ctx context.Context, text string) (string, error) {
|
||||
form := url.Values{
|
||||
"content": {text},
|
||||
"lexer": {"text"},
|
||||
"format": {"url"},
|
||||
"expires": {"604800"},
|
||||
}
|
||||
return doPaste(ctx, http.MethodPost, "https://dpaste.org/api/",
|
||||
"application/x-www-form-urlencoded", []byte(form.Encode()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// termbin (raw TCP)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// termbinAddr is the netcat-style endpoint termbin.com exposes.
|
||||
const termbinAddr = "termbin.com:9999"
|
||||
|
||||
// uploadTermbin writes the text over a plain TCP connection, half-closes the
|
||||
// write side so the server knows the paste is complete, then reads the URL it
|
||||
// replies with. No TLS is involved, which is exactly why this target survives
|
||||
// environments where the HTTPS paste sites are unreachable.
|
||||
func uploadTermbin(ctx context.Context, text string) (string, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", termbinAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Bound the whole exchange, and make sure a hung read is interrupted when
|
||||
// the caller cancels: a blocked socket must never freeze the GUI.
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(pasteTimeout))
|
||||
}
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
|
||||
tcp, ok := conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return "", errors.New("termbin: connection is not tcp")
|
||||
}
|
||||
if _, err := io.WriteString(tcp, text); err != nil {
|
||||
return "", fmt.Errorf("termbin: write: %w", err)
|
||||
}
|
||||
// Half-close: termbin only answers once it sees EOF on its read side.
|
||||
if err := tcp.CloseWrite(); err != nil {
|
||||
return "", fmt.Errorf("termbin: close write: %w", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(tcp, pasteReadLimit))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("termbin: read: %w", err)
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return "", ctxErr
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
+1294
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,457 @@
|
||||
package netdiag
|
||||
|
||||
// This file answers one question: can traffic from this machine reach the
|
||||
// wider internet, and does the answer change depending on how it leaves?
|
||||
//
|
||||
// Every probe is run twice-ish over deliberately different paths — forced
|
||||
// IPv4, forced IPv6, and through whatever HTTP proxy the environment
|
||||
// advertises. The divergence between those paths is the signal: a user running
|
||||
// a proxy tool wants to see that the direct path is dead and the proxied one
|
||||
// works (or the reverse), not have the two averaged into one green tick.
|
||||
//
|
||||
// The mainland-China targets are baselines. They separate "this machine has no
|
||||
// internet at all" from "this machine has internet but cannot leave the
|
||||
// country", which are two completely different things to fix.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// diagUserAgent identifies our probes to the servers we poke. Some captive
|
||||
// portals and CDNs behave differently for an empty UA, and an honest one makes
|
||||
// the traffic recognisable in a packet capture.
|
||||
const diagUserAgent = "tslink-netdiag/1.0"
|
||||
|
||||
// diagMaxRedirects is the hard cap on redirects followed by any diagnostic
|
||||
// client. A redirect chain is usually a portal bouncing us around; three hops
|
||||
// is enough to land on it and few enough to stay inside the probe timeout.
|
||||
const diagMaxRedirects = 3
|
||||
|
||||
const (
|
||||
// rchTimeout bounds a single reachability probe end to end.
|
||||
rchTimeout = 5 * time.Second
|
||||
// rchMaxInflight bounds concurrent reachability probes.
|
||||
rchMaxInflight = 6
|
||||
// rchMaxBody caps how much of a response body we read. The targets answer
|
||||
// 204 with no body at all; the cap only exists so a hijacking portal
|
||||
// serving a huge page cannot stall the probe.
|
||||
rchMaxBody = 64 << 10
|
||||
)
|
||||
|
||||
func rchLog(logger *slog.Logger) *slog.Logger {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return logger.With(slog.String("from", "netdiag/reach"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared HTTP plumbing (used by reach.go, egress.go and geo.go)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// uaTransport stamps [diagUserAgent] onto every request that does not already
|
||||
// carry one. RoundTrippers must not mutate the request they are handed, so the
|
||||
// request is cloned first.
|
||||
type uaTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("User-Agent") != "" {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
clone := req.Clone(req.Context())
|
||||
clone.Header.Set("User-Agent", diagUserAgent)
|
||||
return t.base.RoundTrip(clone)
|
||||
}
|
||||
|
||||
// newDiagClient builds a single-use HTTP client for one diagnostic probe.
|
||||
//
|
||||
// network forces the dial family: "tcp4", "tcp6", or "" to let the resolver
|
||||
// and the kernel pick. Forcing the family is what makes an IPv4-only failure
|
||||
// distinguishable from an IPv6-only one.
|
||||
//
|
||||
// useProxy selects [http.ProxyFromEnvironment] when true and no proxy at all
|
||||
// when false. The false case is an explicit bypass, not a default: running the
|
||||
// same target both ways is how proxy interference becomes visible.
|
||||
//
|
||||
// timeout bounds the whole request, including dial, TLS handshake and body
|
||||
// read. Redirects are capped at [diagMaxRedirects] and the User-Agent is set
|
||||
// to [diagUserAgent].
|
||||
//
|
||||
// The client keeps no idle connections; callers may still call
|
||||
// CloseIdleConnections when they are done with it.
|
||||
func newDiagClient(network string, useProxy bool, timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
|
||||
var proxy func(*http.Request) (*url.URL, error)
|
||||
if useProxy {
|
||||
proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
Proxy: proxy,
|
||||
DialContext: func(ctx context.Context, defaultNetwork, addr string) (net.Conn, error) {
|
||||
if network != "" {
|
||||
defaultNetwork = network
|
||||
}
|
||||
return dialer.DialContext(ctx, defaultNetwork, addr)
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: uaTransport{base: tr},
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, via []*http.Request) error {
|
||||
if len(via) >= diagMaxRedirects {
|
||||
return fmt.Errorf("stopped after %d redirects", diagMaxRedirects)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// diagGet performs one GET and returns the status code, at most maxBody bytes
|
||||
// of the body, and the time to a complete response. ctx must already carry the
|
||||
// caller's deadline; nothing here blocks past it.
|
||||
func diagGet(ctx context.Context, client *http.Client, target string, maxBody int64, header http.Header) (int, []byte, time.Duration, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
for k, vs := range header {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, time.Since(start), err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
rtt := time.Since(start)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return resp.StatusCode, body, rtt, readErr
|
||||
}
|
||||
return resp.StatusCode, body, rtt, nil
|
||||
}
|
||||
|
||||
// diagProxyConfigured reports whether the environment advertises a proxy for
|
||||
// target. Only the boolean is ever surfaced: a proxy URL may embed credentials
|
||||
// and must never reach a log line or a report field.
|
||||
func diagProxyConfigured(target string) bool {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req := &http.Request{URL: u, Header: http.Header{}}
|
||||
p, err := http.ProxyFromEnvironment(req)
|
||||
return err == nil && p != nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overseas reachability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// rchTarget is one reachability probe definition.
|
||||
type rchTarget struct {
|
||||
name string
|
||||
url string
|
||||
region Region
|
||||
network string // "tcp4", "tcp6" or "" for unforced
|
||||
viaProxy bool
|
||||
want int // expected status code
|
||||
}
|
||||
|
||||
// rchTargets is the probe list. Cloudflare's generate_204 is hit four ways
|
||||
// because the paths fail independently.
|
||||
//
|
||||
// The direct and proxied Cloudflare probes deliberately use the same scheme:
|
||||
// the point of running one target both ways is that the proxy setting is the
|
||||
// only variable. Comparing plaintext-direct against TLS-proxied would let a
|
||||
// middlebox that hijacks HTTP but passes HTTPS masquerade as "only the proxy
|
||||
// works". The plaintext probe is kept separately, because that is exactly the
|
||||
// signal a captive portal produces.
|
||||
//
|
||||
// The last two entries are mainland-China baselines.
|
||||
func rchTargets() []rchTarget {
|
||||
return []rchTarget{
|
||||
{name: "Cloudflare 204 (IPv4)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent},
|
||||
{name: "Cloudflare 204 (IPv6)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp6", want: http.StatusNoContent},
|
||||
{name: "Cloudflare 204 (代理)", url: "https://cp.cloudflare.com/generate_204", region: RegionIntl, viaProxy: true, want: http.StatusNoContent},
|
||||
{name: "Cloudflare 204 (明文/门户检测)", url: "http://cp.cloudflare.com/generate_204", region: RegionIntl, network: "tcp4", want: http.StatusNoContent},
|
||||
{name: "Gstatic 204", url: "http://www.gstatic.com/generate_204", region: RegionIntl, want: http.StatusNoContent},
|
||||
{name: "Google 204", url: "https://www.google.com/generate_204", region: RegionIntl, want: http.StatusNoContent},
|
||||
{name: "小米 204(国内基准)", url: "http://connect.rom.miui.com/generate_204", region: RegionCN, want: http.StatusNoContent},
|
||||
{name: "百度(国内基准)", url: "https://www.baidu.com", region: RegionCN, want: http.StatusOK},
|
||||
}
|
||||
}
|
||||
|
||||
// ProbeOverseas checks whether traffic can leave for the wider internet.
|
||||
//
|
||||
// Every target is probed concurrently with its own ~5s budget, so the whole
|
||||
// section finishes in about that time no matter how many probes hang. Probes
|
||||
// against mainland-China targets act as a baseline: when they succeed and the
|
||||
// international ones do not, the line is up but egress is filtered, which is a
|
||||
// warning rather than a failure.
|
||||
//
|
||||
// A response that arrives with an unexpected status is recorded, not
|
||||
// discarded — a captive portal or an injected block page is precisely what the
|
||||
// user needs to see.
|
||||
func ProbeOverseas(ctx context.Context, logger *slog.Logger) OverseasReport {
|
||||
log := rchLog(logger)
|
||||
targets := rchTargets()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
probes = make([]ReachProbe, 0, len(targets))
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, rchMaxInflight)
|
||||
)
|
||||
|
||||
for _, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(t rchTarget) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
mu.Lock()
|
||||
probes = append(probes, rchCancelled(t, ctx.Err()))
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
p := rchProbeOne(ctx, t, log)
|
||||
mu.Lock()
|
||||
probes = append(probes, p)
|
||||
mu.Unlock()
|
||||
}(t)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rchSortProbes(probes)
|
||||
rep := OverseasReport{Probes: probes}
|
||||
rchSummarize(&rep)
|
||||
|
||||
log.With(
|
||||
slog.Int("probes", len(rep.Probes)),
|
||||
slog.String("status", rep.Status.String()),
|
||||
).Debug("finished overseas reachability probes")
|
||||
|
||||
return rep
|
||||
}
|
||||
|
||||
// rchCancelled builds the placeholder entry for a probe that never started
|
||||
// because the run was cancelled. The row still renders, which is better than a
|
||||
// silently shorter table.
|
||||
func rchCancelled(t rchTarget, err error) ReachProbe {
|
||||
msg := "cancelled"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
return ReachProbe{
|
||||
Name: t.name,
|
||||
URL: t.url,
|
||||
Region: t.region,
|
||||
ViaProxy: t.viaProxy,
|
||||
Network: t.network,
|
||||
Err: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// rchProbeOne runs a single probe. It never returns an error: a failure is a
|
||||
// datapoint, recorded in the probe's Err field.
|
||||
func rchProbeOne(ctx context.Context, t rchTarget, log *slog.Logger) ReachProbe {
|
||||
// ViaProxy must record what happened, not what was intended. A client
|
||||
// built with http.ProxyFromEnvironment sends the request direct when no
|
||||
// proxy is configured, and counting that as proof the proxy path works is
|
||||
// how the summary ends up asserting "only the proxy link is usable" on a
|
||||
// machine with no proxy at all.
|
||||
usedProxy := t.viaProxy && diagProxyConfigured(t.url)
|
||||
|
||||
p := ReachProbe{
|
||||
Name: t.name,
|
||||
URL: t.url,
|
||||
Region: t.region,
|
||||
ViaProxy: usedProxy,
|
||||
Network: t.network,
|
||||
}
|
||||
if t.viaProxy && !usedProxy {
|
||||
p.Name = t.name + "(环境未配置代理,实际直连)"
|
||||
}
|
||||
|
||||
pctx, cancel := context.WithTimeout(ctx, rchTimeout)
|
||||
defer cancel()
|
||||
|
||||
client := newDiagClient(t.network, usedProxy, rchTimeout)
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
code, _, rtt, err := diagGet(pctx, client, t.url, rchMaxBody, nil)
|
||||
p.RTT = rtt
|
||||
p.StatusCode = code
|
||||
switch {
|
||||
case err != nil:
|
||||
p.Err = rchErrText(err)
|
||||
case code == t.want:
|
||||
p.OK = true
|
||||
default:
|
||||
// Reachable, but something answered on the target's behalf.
|
||||
p.Err = fmt.Sprintf("unexpected status %d (want %d), 可能存在门户劫持或内容注入", code, t.want)
|
||||
}
|
||||
|
||||
log.With(
|
||||
slog.String("name", t.name),
|
||||
slog.String("network", rchNetworkText(t.network)),
|
||||
slog.Bool("via_proxy", t.viaProxy),
|
||||
slog.Int("status", p.StatusCode),
|
||||
slog.Duration("rtt", p.RTT),
|
||||
slog.Bool("ok", p.OK),
|
||||
).Debug("reachability probe done")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// rchErrText flattens a transport error into a short message. The URL is
|
||||
// stripped because url.Error embeds the full target (and, for a proxied
|
||||
// request, potentially proxy credentials) into its Error string.
|
||||
func rchErrText(err error) string {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) && ue.Err != nil {
|
||||
err = ue.Err
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "cancelled"
|
||||
}
|
||||
if i := strings.IndexByte(msg, '\n'); i >= 0 {
|
||||
msg = msg[:i]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func rchNetworkText(n string) string {
|
||||
if n == "" {
|
||||
return "auto"
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// rchSortProbes orders international probes before the CN baselines and is
|
||||
// otherwise stable on URL/network/proxy, so consecutive refreshes render in
|
||||
// exactly the same order.
|
||||
func rchSortProbes(ps []ReachProbe) {
|
||||
rank := func(r Region) int {
|
||||
if r == RegionIntl {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
sort.Slice(ps, func(i, j int) bool {
|
||||
x, y := ps[i], ps[j]
|
||||
if rank(x.Region) != rank(y.Region) {
|
||||
return rank(x.Region) < rank(y.Region)
|
||||
}
|
||||
if x.URL != y.URL {
|
||||
return x.URL < y.URL
|
||||
}
|
||||
if x.Network != y.Network {
|
||||
return x.Network < y.Network
|
||||
}
|
||||
if x.ViaProxy != y.ViaProxy {
|
||||
return !x.ViaProxy
|
||||
}
|
||||
return x.Name < y.Name
|
||||
})
|
||||
}
|
||||
|
||||
// rchSummarize derives Status and a one-line Chinese Summary.
|
||||
//
|
||||
// Any single successful international probe is enough for [StatusOK]: hosts
|
||||
// without IPv6 are the norm, so a failed v6 probe alongside a working v4 one
|
||||
// must not drag the verdict down. Only the CN baselines succeeding means the
|
||||
// local network is fine but the wider internet is not reachable
|
||||
// ([StatusWarn]); nothing succeeding at all is [StatusFail].
|
||||
func rchSummarize(rep *OverseasReport) {
|
||||
var (
|
||||
intlOK, intlTotal int
|
||||
cnOK, cnTotal int
|
||||
proxyOK bool
|
||||
directIntlOK bool
|
||||
v6OK bool
|
||||
hijacked int
|
||||
)
|
||||
for _, p := range rep.Probes {
|
||||
if p.Region == RegionIntl {
|
||||
intlTotal++
|
||||
if p.OK {
|
||||
intlOK++
|
||||
if p.ViaProxy {
|
||||
proxyOK = true
|
||||
} else {
|
||||
directIntlOK = true
|
||||
}
|
||||
if p.Network == "tcp6" {
|
||||
v6OK = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cnTotal++
|
||||
if p.OK {
|
||||
cnOK++
|
||||
}
|
||||
}
|
||||
if !p.OK && p.StatusCode > 0 {
|
||||
hijacked++
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
switch {
|
||||
case intlOK > 0:
|
||||
rep.Status = StatusOK
|
||||
fmt.Fprintf(&b, "境外可达(%d/%d 个境外目标成功)", intlOK, intlTotal)
|
||||
switch {
|
||||
case proxyOK && !directIntlOK:
|
||||
b.WriteString(",仅代理链路可用,直连被阻断")
|
||||
case directIntlOK && !proxyOK && diagProxyConfigured("https://cp.cloudflare.com/generate_204"):
|
||||
b.WriteString(",直连可用但代理链路失败")
|
||||
}
|
||||
if !v6OK {
|
||||
b.WriteString(",IPv6 不可用(不影响判定)")
|
||||
}
|
||||
case cnOK > 0:
|
||||
rep.Status = StatusWarn
|
||||
fmt.Fprintf(&b, "境外不可达(0/%d),但本地网络正常:国内基准 %d/%d 通过,问题在跨境链路而非本机网络", intlTotal, cnOK, cnTotal)
|
||||
default:
|
||||
rep.Status = StatusFail
|
||||
fmt.Fprintf(&b, "境内外目标均无法访问(0/%d),本机可能完全没有网络", intlTotal+cnTotal)
|
||||
}
|
||||
if hijacked > 0 {
|
||||
fmt.Fprintf(&b, ";%d 个目标返回了非预期状态码,疑似门户或注入", hijacked)
|
||||
}
|
||||
rep.Summary = b.String()
|
||||
}
|
||||
+505
@@ -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 "否"
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
package netdiag
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rfc5769TxID is the transaction ID from the RFC 5769 sample messages; the
|
||||
// hand-computed XOR-MAPPED-ADDRESS vectors below are derived from it.
|
||||
var rfc5769TxID = [12]byte{0xb7, 0xe7, 0xa7, 0x01, 0xbc, 0x34, 0xd6, 0x86, 0xfa, 0x87, 0xdf, 0xae}
|
||||
|
||||
// stunTestTLV encodes one attribute with its 4-byte alignment padding.
|
||||
func stunTestTLV(typ uint16, val []byte) []byte {
|
||||
out := make([]byte, 4, 4+len(val)+3)
|
||||
binary.BigEndian.PutUint16(out[0:2], typ)
|
||||
binary.BigEndian.PutUint16(out[2:4], uint16(len(val)))
|
||||
out = append(out, val...)
|
||||
if pad := (4 - len(val)%4) % 4; pad > 0 {
|
||||
out = append(out, make([]byte, pad)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stunTestRaw frames body as a STUN message with a correct length field.
|
||||
func stunTestRaw(typ uint16, txid [12]byte, body []byte) []byte {
|
||||
out := make([]byte, stunHeaderSize, stunHeaderSize+len(body))
|
||||
binary.BigEndian.PutUint16(out[0:2], typ)
|
||||
binary.BigEndian.PutUint16(out[2:4], uint16(len(body)))
|
||||
binary.BigEndian.PutUint32(out[4:8], stunMagicCookie)
|
||||
copy(out[8:20], txid[:])
|
||||
return append(out, body...)
|
||||
}
|
||||
|
||||
func mustHex(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("bad hex %q: %v", s, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestSTUNEncodeParseRoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg stunMessage
|
||||
attrs int
|
||||
}{
|
||||
{
|
||||
name: "bare request",
|
||||
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID},
|
||||
attrs: 0,
|
||||
},
|
||||
{
|
||||
name: "change request",
|
||||
msg: stunMessage{Type: stunBindingRequest, TxID: rfc5769TxID, Attrs: []stunAttr{
|
||||
{Type: stunAttrChangeRequest, Value: []byte{0, 0, 0, stunChangeIP | stunChangePort}},
|
||||
}},
|
||||
attrs: 1,
|
||||
},
|
||||
{
|
||||
name: "response with odd-length software",
|
||||
msg: stunMessage{Type: stunBindingSuccess, TxID: rfc5769TxID, Attrs: []stunAttr{
|
||||
{Type: stunAttrSoftware, Value: []byte("tslink/1")},
|
||||
{Type: stunAttrXORMappedAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("192.0.2.1:32853"), true, rfc5769TxID)},
|
||||
{Type: stunAttrOtherAddress, Value: stunEncodeAddr(netip.MustParseAddrPort("198.51.100.7:3479"), false, rfc5769TxID)},
|
||||
{Type: 0x7fff, Value: []byte{1, 2, 3, 4, 5}}, // unknown, needs padding
|
||||
}},
|
||||
attrs: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := tc.msg.encode()
|
||||
if len(raw)%4 != 0 {
|
||||
t.Fatalf("encoded message is not 4-byte aligned: %d", len(raw))
|
||||
}
|
||||
if got := binary.BigEndian.Uint32(raw[4:8]); got != stunMagicCookie {
|
||||
t.Fatalf("magic cookie = %#x", got)
|
||||
}
|
||||
got, err := parseSTUNMessage(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.Type != tc.msg.Type || got.TxID != tc.msg.TxID {
|
||||
t.Fatalf("header mismatch: got %#x/%x", got.Type, got.TxID)
|
||||
}
|
||||
if len(got.Attrs) != tc.attrs {
|
||||
t.Fatalf("attrs = %d, want %d", len(got.Attrs), tc.attrs)
|
||||
}
|
||||
for i, a := range tc.msg.Attrs {
|
||||
if got.Attrs[i].Type != a.Type {
|
||||
t.Errorf("attr %d type = %#x, want %#x", i, got.Attrs[i].Type, a.Type)
|
||||
}
|
||||
if !bytes.Equal(got.Attrs[i].Value, a.Value) {
|
||||
t.Errorf("attr %d value = %x, want %x", i, got.Attrs[i].Value, a.Value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNDecodeXORMappedAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attr uint16
|
||||
// hand-computed payload: reserved, family, xor-port, xor-address
|
||||
payload string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// 192.0.2.1 ^ 2112a442 = e112a643, port 32853 ^ 0x2112 = 0xa147
|
||||
name: "v4",
|
||||
attr: stunAttrXORMappedAddress,
|
||||
payload: "0001a147e112a643",
|
||||
want: "192.0.2.1:32853",
|
||||
},
|
||||
{
|
||||
// same, delivered under the legacy 0x8020 attribute type
|
||||
name: "v4 legacy attr",
|
||||
attr: stunAttrXORMappedAddrAlt,
|
||||
payload: "0001a147e112a643",
|
||||
want: "192.0.2.1:32853",
|
||||
},
|
||||
{
|
||||
// 2001:db8:1234:5678:11:2233:4455:6677 ^ (cookie || txid)
|
||||
name: "v6",
|
||||
attr: stunAttrXORMappedAddress,
|
||||
payload: "0002a1470113a9faa5d3f179bc25f4b5bed2b9d9",
|
||||
want: "[2001:db8:1234:5678:11:2233:4455:6677]:32853",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := mustHex(t, tc.payload)
|
||||
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(tc.attr, payload))
|
||||
msg, err := parseSTUNMessage(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
got, ok := msg.mappedAddr()
|
||||
if !ok {
|
||||
t.Fatal("no mapped address decoded")
|
||||
}
|
||||
if got.String() != tc.want {
|
||||
t.Fatalf("mapped = %s, want %s", got, tc.want)
|
||||
}
|
||||
// encoding it again must reproduce the same bytes
|
||||
if back := stunEncodeAddr(got, true, rfc5769TxID); !bytes.Equal(back, payload) {
|
||||
t.Fatalf("re-encoded = %x, want %x", back, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNDecodePlainMappedAddress(t *testing.T) {
|
||||
payload := mustHex(t, "00010d96c0000201") // 192.0.2.1:3478, no XOR
|
||||
raw := stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrMappedAddress, payload))
|
||||
msg, err := parseSTUNMessage(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
got, ok := msg.mappedAddr()
|
||||
if !ok || got.String() != "192.0.2.1:3478" {
|
||||
t.Fatalf("mapped = %v (ok=%v), want 192.0.2.1:3478", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNParseTolerance(t *testing.T) {
|
||||
good := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw []byte
|
||||
wantErr bool
|
||||
wantAttrs int
|
||||
wantMap string
|
||||
}{
|
||||
{
|
||||
name: "unknown attributes are skipped",
|
||||
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(stunTestTLV(0x7f01, []byte{9}), good, stunTestTLV(0xfffe, []byte("xyz")))),
|
||||
wantAttrs: 3,
|
||||
wantMap: "192.0.2.1:32853",
|
||||
},
|
||||
{
|
||||
name: "missing trailing padding tolerated",
|
||||
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, []byte{0x80, 0x22, 0x00, 0x03, 'a', 'b', 'c'})),
|
||||
wantAttrs: 2,
|
||||
wantMap: "192.0.2.1:32853",
|
||||
},
|
||||
{
|
||||
name: "fingerprint after mapped address",
|
||||
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, concat(good, stunTestTLV(stunAttrFingerprint, []byte{1, 2, 3, 4}))),
|
||||
wantAttrs: 2,
|
||||
wantMap: "192.0.2.1:32853",
|
||||
},
|
||||
{
|
||||
name: "header shorter than 20 bytes",
|
||||
raw: []byte{0x01, 0x01, 0x00, 0x00},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "trailing bytes beyond declared length ignored",
|
||||
raw: append(stunTestRaw(stunBindingSuccess, rfc5769TxID, nil), 0x00),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "truncated attribute value",
|
||||
raw: func() []byte {
|
||||
b := stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00, 0x10, 0x00, 0x01})
|
||||
return b
|
||||
}(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "truncated attribute header",
|
||||
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, []byte{0x00, 0x20, 0x00}),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "address attribute shorter than its family requires",
|
||||
raw: stunTestRaw(stunBindingSuccess, rfc5769TxID, stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0002a1470113a9fa"))),
|
||||
wantAttrs: 1,
|
||||
wantMap: "", // v6 payload truncated: reported as absent, not fatal
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
msg, err := parseSTUNMessage(tc.raw)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if tc.wantAttrs != 0 && len(msg.Attrs) != tc.wantAttrs {
|
||||
t.Fatalf("attrs = %d, want %d", len(msg.Attrs), tc.wantAttrs)
|
||||
}
|
||||
got, ok := msg.mappedAddr()
|
||||
if tc.wantMap == "" {
|
||||
if ok {
|
||||
t.Fatalf("expected no mapped address, got %s", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok || got.String() != tc.wantMap {
|
||||
t.Fatalf("mapped = %v (ok=%v), want %s", got, ok, tc.wantMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNResponseFor(t *testing.T) {
|
||||
other := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
body := stunTestTLV(stunAttrXORMappedAddress, mustHex(t, "0001a147e112a643"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw []byte
|
||||
txid [12]byte
|
||||
want bool
|
||||
}{
|
||||
{"matching success", stunTestRaw(stunBindingSuccess, rfc5769TxID, body), rfc5769TxID, true},
|
||||
{"matching error response", stunTestRaw(stunBindingError, rfc5769TxID, nil), rfc5769TxID, true},
|
||||
{"txid mismatch", stunTestRaw(stunBindingSuccess, other, body), rfc5769TxID, false},
|
||||
{"request is not a response", stunTestRaw(stunBindingRequest, rfc5769TxID, nil), rfc5769TxID, false},
|
||||
{"garbage", []byte("not a stun packet"), rfc5769TxID, false},
|
||||
{"empty", nil, rfc5769TxID, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
msg, ok := stunResponseFor(tc.raw, tc.txid)
|
||||
if ok != tc.want {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.want)
|
||||
}
|
||||
if ok && msg == nil {
|
||||
t.Fatal("accepted response but returned nil message")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNBindingRequestMsg(t *testing.T) {
|
||||
plain := stunBindingRequestMsg(0)
|
||||
if len(plain.Attrs) != 0 {
|
||||
t.Fatalf("plain request carries %d attributes", len(plain.Attrs))
|
||||
}
|
||||
if plain.TxID == ([12]byte{}) {
|
||||
t.Fatal("transaction id was not randomised")
|
||||
}
|
||||
if other := stunBindingRequestMsg(0); other.TxID == plain.TxID {
|
||||
t.Fatal("two requests share a transaction id")
|
||||
}
|
||||
cr := stunBindingRequestMsg(stunChangeIP | stunChangePort)
|
||||
v, ok := cr.attr(stunAttrChangeRequest)
|
||||
if !ok || len(v) != 4 || v[3] != 0x06 {
|
||||
t.Fatalf("change-request attribute = %x (ok=%v)", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNServerLists(t *testing.T) {
|
||||
var cn, intl int
|
||||
hosts := map[string]bool{}
|
||||
for _, s := range DefaultSTUNServers() {
|
||||
if hosts[s.Host] {
|
||||
t.Errorf("duplicate host %s", s.Host)
|
||||
}
|
||||
hosts[s.Host] = true
|
||||
if s.Name == "" {
|
||||
t.Errorf("%s has no name", s.Host)
|
||||
}
|
||||
switch s.Region {
|
||||
case RegionCN:
|
||||
cn++
|
||||
case RegionIntl:
|
||||
intl++
|
||||
default:
|
||||
t.Errorf("%s has unknown region %q", s.Host, s.Region)
|
||||
}
|
||||
}
|
||||
if cn == 0 || intl == 0 {
|
||||
t.Fatalf("default list must span both regions, got cn=%d intl=%d", cn, intl)
|
||||
}
|
||||
for _, s := range RFC5780Servers() {
|
||||
if !hosts[s.Host] {
|
||||
t.Errorf("rfc5780 server %s missing from the default list", s.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func concat(parts ...[]byte) []byte {
|
||||
var out []byte
|
||||
for _, p := range parts {
|
||||
out = append(out, p...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,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 }
|
||||
Reference in New Issue
Block a user