diff --git a/docs/superpowers/plans/2026-05-01-custom-best-exit-probe-target.md b/docs/superpowers/plans/2026-05-01-custom-best-exit-probe-target.md
new file mode 100644
index 0000000..50a75a1
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-01-custom-best-exit-probe-target.md
@@ -0,0 +1,1083 @@
+# Custom Best-Exit Probe Target Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let each tunnel define the TCP host/port used for exit-side quality probing and `best` exit scoring, defaulting to `www.bing.com:443` when unset.
+
+**Architecture:** Add small backend probe-target normalization helpers, persist configured target fields on `tunnel`, and thread the effective target through tunnel quality probing and best-exit scoring. Frontend adds compact tunnel-form inputs and replaces Bing-specific monitoring labels with target-aware text while keeping existing API fields compatible.
+
+**Tech Stack:** Go `net/http` handlers + GORM models/repository, SQLite/PostgreSQL auto-migration, Vite/React/TypeScript frontend with shadcn bridge components.
+
+---
+
+## File Structure
+
+- Create `go-backend/internal/http/handler/tunnel_probe_target.go`: target constants, value type, validation, request parsing, formatting, and effective default helpers.
+- Create `go-backend/internal/http/handler/tunnel_probe_target_test.go`: TDD coverage for normalization, defaulting, invalid inputs, and formatting.
+- Modify `go-backend/internal/store/model/model.go`: add `probe_target_host` and `probe_target_port` columns to `model.Tunnel`.
+- Modify `go-backend/internal/store/repo/repository.go`: include configured target fields in `ListTunnels()`.
+- Modify `go-backend/internal/store/repo/repository_mutations.go`: persist target fields in tunnel create/update repository helpers.
+- Modify `go-backend/internal/http/handler/mutations.go`: validate request target and save it during `tunnelCreate` / `tunnelUpdate`.
+- Create `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`: handler-level persistence/list/get tests.
+- Modify `go-backend/internal/http/handler/tunnel_best_exit.go` and `tunnel_best_exit_test.go`: make exit-to-public scoring use the configured target.
+- Modify `go-backend/internal/http/handler/tunnel_quality_prober.go` and add tests in `tunnel_quality_prober_test.go`: make quality snapshots use and expose the effective target.
+- Modify `go-backend/internal/http/handler/monitoring.go`: include effective target metadata in DB fallback quality responses.
+- Modify `vite-frontend/src/pages/tunnel.tsx`: tunnel types, edit/create payload, form inputs, list display for target.
+- Modify `vite-frontend/src/pages/tunnel/form.ts`: frontend validation/defaults for target fields.
+- Modify `vite-frontend/src/api/types.ts`: tunnel quality target metadata.
+- Modify `vite-frontend/src/pages/node/tunnel-monitor-view.tsx`: target-aware monitoring labels and chart names.
+
+## Task 1: Backend Probe Target Helpers
+
+**Files:**
+- Create: `go-backend/internal/http/handler/tunnel_probe_target.go`
+- Create: `go-backend/internal/http/handler/tunnel_probe_target_test.go`
+
+- [ ] **Step 1: Write failing helper tests**
+
+Create `go-backend/internal/http/handler/tunnel_probe_target_test.go`:
+
+```go
+package handler
+
+import "testing"
+
+func TestNormalizeTunnelProbeTargetDefaultsWhenEmpty(t *testing.T) {
+ target, configured, err := normalizeTunnelProbeTarget("", 0)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if configured {
+ t.Fatalf("expected empty input to be default, not configured")
+ }
+ if target.Host != defaultTunnelProbeTargetHost || target.Port != defaultTunnelProbeTargetPort {
+ t.Fatalf("unexpected default target: %+v", target)
+ }
+}
+
+func TestNormalizeTunnelProbeTargetAcceptsHostPortAndIPv6(t *testing.T) {
+ target, configured, err := normalizeTunnelProbeTarget(" [2001:db8::1] ", 8443)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !configured {
+ t.Fatalf("expected explicit target")
+ }
+ if target.Host != "2001:db8::1" || target.Port != 8443 {
+ t.Fatalf("unexpected normalized target: %+v", target)
+ }
+ if got := formatTunnelProbeTarget(target); got != "[2001:db8::1]:8443" {
+ t.Fatalf("unexpected formatted target: %s", got)
+ }
+}
+
+func TestNormalizeTunnelProbeTargetRejectsPartialAndInvalidInputs(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ port int
+ }{
+ {name: "missing host", host: "", port: 443},
+ {name: "missing port", host: "example.com", port: 0},
+ {name: "port too high", host: "example.com", port: 70000},
+ {name: "scheme", host: "https://example.com", port: 443},
+ {name: "path", host: "example.com/ping", port: 443},
+ {name: "space", host: "example .com", port: 443},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, _, err := normalizeTunnelProbeTarget(tt.host, tt.port); err == nil {
+ t.Fatalf("expected validation error")
+ }
+ })
+ }
+}
+
+func TestParseTunnelProbeTargetFromRequest(t *testing.T) {
+ req := map[string]interface{}{
+ "probeTargetHost": "speed.example.com",
+ "probeTargetPort": float64(1443),
+ }
+ target, configured, err := parseTunnelProbeTargetFromRequest(req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !configured || target.Host != "speed.example.com" || target.Port != 1443 {
+ t.Fatalf("unexpected request target: %+v configured=%v", target, configured)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify RED**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run 'TestNormalizeTunnelProbeTarget|TestParseTunnelProbeTarget' -count=1
+```
+
+Expected: FAIL with undefined symbols such as `normalizeTunnelProbeTarget`, `defaultTunnelProbeTargetHost`, and `parseTunnelProbeTargetFromRequest`.
+
+- [ ] **Step 3: Implement helper**
+
+Create `go-backend/internal/http/handler/tunnel_probe_target.go`:
+
+```go
+package handler
+
+import (
+ "errors"
+ "fmt"
+ "net/netip"
+ "strings"
+
+ "go-backend/internal/store/model"
+)
+
+const (
+ defaultTunnelProbeTargetHost = "www.bing.com"
+ defaultTunnelProbeTargetPort = 443
+)
+
+type tunnelProbeTarget struct {
+ Host string
+ Port int
+}
+
+func defaultTunnelProbeTarget() tunnelProbeTarget {
+ return tunnelProbeTarget{Host: defaultTunnelProbeTargetHost, Port: defaultTunnelProbeTargetPort}
+}
+
+func normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, error) {
+ host = strings.TrimSpace(host)
+ if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
+ host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
+ }
+
+ if host == "" && port == 0 {
+ return defaultTunnelProbeTarget(), false, nil
+ }
+ if host == "" {
+ return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能为空")
+ }
+ if port <= 0 || port > 65535 {
+ return tunnelProbeTarget{}, false, errors.New("测试目标端口必须是 1-65535")
+ }
+ if strings.Contains(host, "://") || strings.ContainsAny(host, "/?#") || strings.ContainsAny(host, " \t\r\n") {
+ return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能包含协议或路径")
+ }
+
+ return tunnelProbeTarget{Host: host, Port: port}, true, nil
+}
+
+func parseTunnelProbeTargetFromRequest(req map[string]interface{}) (tunnelProbeTarget, bool, error) {
+ if req == nil {
+ return defaultTunnelProbeTarget(), false, nil
+ }
+ return normalizeTunnelProbeTarget(asString(req["probeTargetHost"]), asInt(req["probeTargetPort"], 0))
+}
+
+func effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget {
+ if tunnel == nil {
+ return defaultTunnelProbeTarget()
+ }
+ return effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort)
+}
+
+func effectiveTunnelProbeTargetValues(host string, port int) tunnelProbeTarget {
+ target, configured, err := normalizeTunnelProbeTarget(host, port)
+ if err != nil || !configured {
+ return defaultTunnelProbeTarget()
+ }
+ return target
+}
+
+func formatTunnelProbeTarget(target tunnelProbeTarget) string {
+ if addr, err := netip.ParseAddr(target.Host); err == nil && addr.Is6() {
+ return fmt.Sprintf("[%s]:%d", target.Host, target.Port)
+ }
+ return fmt.Sprintf("%s:%d", target.Host, target.Port)
+}
+```
+
+- [ ] **Step 4: Run helper tests to verify GREEN**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run 'TestNormalizeTunnelProbeTarget|TestParseTunnelProbeTarget' -count=1
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Format and commit**
+
+```bash
+gofmt -w internal/http/handler/tunnel_probe_target.go internal/http/handler/tunnel_probe_target_test.go
+git add internal/http/handler/tunnel_probe_target.go internal/http/handler/tunnel_probe_target_test.go
+git commit -m "feat: add tunnel probe target normalization"
+```
+
+## Task 2: Persist Probe Target On Tunnels
+
+**Files:**
+- Modify: `go-backend/internal/store/model/model.go`
+- Modify: `go-backend/internal/store/repo/repository.go`
+- Modify: `go-backend/internal/store/repo/repository_mutations.go`
+- Modify: `go-backend/internal/http/handler/mutations.go`
+- Create: `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`
+
+- [ ] **Step 1: Write failing API persistence tests**
+
+Create `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`:
+
+```go
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "go-backend/internal/store/repo"
+)
+
+func TestTunnelCreatePersistsProbeTargetAndListReturnsConfiguredValue(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ body := bytes.NewReader([]byte(`{
+ "name":"custom-target",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}],
+ "probeTargetHost":"speed.example.com",
+ "probeTargetPort":8443
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body))
+ assertProbeTargetSuccess(t, res)
+
+ listRes := httptest.NewRecorder()
+ h.tunnelList(listRes, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil))
+ var payload struct {
+ Code int `json:"code"`
+ Data []map[string]any `json:"data"`
+ }
+ decodeProbeTargetResponse(t, listRes, &payload)
+ if payload.Code != 0 {
+ t.Fatalf("expected success, got code %d", payload.Code)
+ }
+ item := payload.Data[0]
+ if item["probeTargetHost"] != "speed.example.com" || item["probeTargetPort"] != float64(8443) {
+ t.Fatalf("unexpected probe target in list response: %+v", item)
+ }
+}
+
+func TestTunnelUpdatePersistsDefaultProbeTargetAsEmpty(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 77, "existing", "old.example.com", 9443)
+ body := bytes.NewReader([]byte(`{
+ "id":77,
+ "name":"existing",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}],
+ "probeTargetHost":"",
+ "probeTargetPort":0
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
+ assertProbeTargetSuccess(t, res)
+
+ items, err := h.repo.ListTunnels()
+ if err != nil {
+ t.Fatalf("list tunnels: %v", err)
+ }
+ item := findProbeTargetTunnelItem(t, items, 77)
+ if item["probeTargetHost"] != "" || item["probeTargetPort"] != 0 {
+ t.Fatalf("expected default target to round-trip as empty/0, got %+v", item)
+ }
+}
+
+func TestTunnelCreateRejectsInvalidProbeTarget(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ body := bytes.NewReader([]byte(`{
+ "name":"bad-target",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}],
+ "probeTargetHost":"https://example.com",
+ "probeTargetPort":443
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body))
+ var payload struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ }
+ decodeProbeTargetResponse(t, res, &payload)
+ if payload.Code == 0 || payload.Msg == "" {
+ t.Fatalf("expected validation failure, got %+v", payload)
+ }
+}
+
+func setupProbeTargetTunnelHandler(t *testing.T) *Handler {
+ t.Helper()
+ r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = r.Close() })
+ h := New(r, "secret")
+ now := time.Now().UnixMilli()
+ if err := r.DB().Exec(`
+ INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
+ VALUES(10, 'entry-a', 'entry-secret', '10.0.0.1', '10.0.0.1', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
+ `, now, now).Error; err != nil {
+ t.Fatalf("insert node: %v", err)
+ }
+ return h
+}
+
+func seedProbeTargetTunnel(t *testing.T, h *Handler, id int64, name string, host string, port int) {
+ t.Helper()
+ now := time.Now().UnixMilli()
+ if err := h.repo.DB().Exec(`
+ INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference, probe_target_host, probe_target_port)
+ VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '', ?, ?)
+ `, id, name, now, now, id, host, port).Error; err != nil {
+ t.Fatalf("insert tunnel: %v", err)
+ }
+ if err := h.repo.DB().Exec(`
+ INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
+ VALUES(?, '1', 10, 30001, 'round', 1, 'tls')
+ `, id).Error; err != nil {
+ t.Fatalf("insert chain: %v", err)
+ }
+}
+
+func assertProbeTargetSuccess(t *testing.T, res *httptest.ResponseRecorder) {
+ t.Helper()
+ var payload struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ }
+ decodeProbeTargetResponse(t, res, &payload)
+ if payload.Code != 0 {
+ t.Fatalf("expected success, got %+v", payload)
+ }
+}
+
+func decodeProbeTargetResponse(t *testing.T, res *httptest.ResponseRecorder, v any) {
+ t.Helper()
+ if res.Code != http.StatusOK {
+ t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code)
+ }
+ if err := json.NewDecoder(res.Body).Decode(v); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+}
+
+func findProbeTargetTunnelItem(t *testing.T, items []map[string]interface{}, id int64) map[string]interface{} {
+ t.Helper()
+ for _, item := range items {
+ if asInt64(item["id"], 0) == id {
+ return item
+ }
+ }
+ t.Fatalf("tunnel %d not found: %+v", id, items)
+ return nil
+}
+```
+
+- [ ] **Step 2: Run tests to verify RED**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget' -count=1
+```
+
+Expected: FAIL because `probe_target_host` / `probe_target_port` columns and response fields do not exist yet.
+
+- [ ] **Step 3: Add model and repository fields**
+
+Modify `go-backend/internal/store/model/model.go` in `type Tunnel`:
+
+```go
+ IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"`
+ ProbeTargetHost string `gorm:"column:probe_target_host;type:text;not null;default:''"`
+ ProbeTargetPort int `gorm:"column:probe_target_port;not null;default:0"`
+```
+
+Modify `go-backend/internal/store/repo/repository.go` in `ListTunnels()` tunnel map:
+
+```go
+ "ipPreference": t.IPPreference,
+ "probeTargetHost": t.ProbeTargetHost,
+ "probeTargetPort": t.ProbeTargetPort,
+```
+
+Modify `go-backend/internal/store/repo/repository_mutations.go` signatures and fields:
+
+```go
+func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, probeTargetHost string, probeTargetPort int, now int64) error {
+```
+
+Add to the `Updates` map:
+
+```go
+ "probe_target_host": probeTargetHost,
+ "probe_target_port": probeTargetPort,
+```
+
+Update `CreateTunnelTx` for consistency:
+
+```go
+func (r *Repository) CreateTunnelTx(tx *gorm.DB, name string, trafficRatio float64, typeVal int, flow int64, now int64, status int, inIP interface{}, inx int, ipPreference string, probeTargetHost string, probeTargetPort int) (int64, error) {
+```
+
+Set fields in `model.Tunnel`:
+
+```go
+ ProbeTargetHost: probeTargetHost,
+ ProbeTargetPort: probeTargetPort,
+```
+
+- [ ] **Step 4: Validate and save fields in handlers**
+
+In `tunnelCreate`, after `ipPreference := asString(req["ipPreference"])`, add:
+
+```go
+ probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req)
+ if err != nil {
+ response.WriteJSON(w, response.ErrDefault(err.Error()))
+ return
+ }
+ probeTargetHost := ""
+ probeTargetPort := 0
+ if probeTargetConfigured {
+ probeTargetHost = probeTarget.Host
+ probeTargetPort = probeTarget.Port
+ }
+```
+
+Set fields on the `model.Tunnel` literal:
+
+```go
+ ProbeTargetHost: probeTargetHost,
+ ProbeTargetPort: probeTargetPort,
+```
+
+In `tunnelUpdate`, after `ipPreference := asString(req["ipPreference"])`, add the same validation block and pass `probeTargetHost`, `probeTargetPort` into `h.repo.UpdateTunnelTx(...)` before `now`.
+
+- [ ] **Step 5: Run API persistence tests to verify GREEN**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget' -count=1
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Run broader backend compile check and commit**
+
+```bash
+gofmt -w internal/store/model/model.go internal/store/repo/repository.go internal/store/repo/repository_mutations.go internal/http/handler/mutations.go internal/http/handler/tunnel_probe_target_api_test.go
+go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget|TestNormalizeTunnelProbeTarget' -count=1
+git add internal/store/model/model.go internal/store/repo/repository.go internal/store/repo/repository_mutations.go internal/http/handler/mutations.go internal/http/handler/tunnel_probe_target_api_test.go
+git commit -m "feat: persist tunnel probe targets"
+```
+
+## Task 3: Use Probe Target In Best-Exit Scoring
+
+**Files:**
+- Modify: `go-backend/internal/http/handler/tunnel_best_exit.go`
+- Modify: `go-backend/internal/http/handler/tunnel_best_exit_test.go`
+- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go`
+
+- [ ] **Step 1: Write failing best-exit scoring test**
+
+Append to `go-backend/internal/http/handler/tunnel_best_exit_test.go`:
+
+```go
+func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) {
+ owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"}
+ exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}}
+ nodes := map[int64]*nodeRecord{
+ 10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
+ 30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
+ }
+ target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443}
+ var calls []string
+ ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
+ calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
+ return 10, 0, nil
+ }
+
+ scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, target, ping)
+ if len(scores) != 1 || !scores[0].Success {
+ t.Fatalf("expected successful score, got %+v", scores)
+ }
+ if !slices.Contains(calls, "30|speed.example.com|8443") {
+ t.Fatalf("expected exit public probe to use configured target, calls=%+v", calls)
+ }
+ for _, call := range calls {
+ if strings.Contains(call, defaultTunnelProbeTargetHost) {
+ t.Fatalf("did not expect default target call when custom target configured: %+v", calls)
+ }
+ }
+}
+```
+
+Ensure the `tunnel_best_exit_test.go` import block contains these imports after adding the test:
+
+```go
+import (
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+```
+
+- [ ] **Step 2: Run test to verify RED**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget -count=1
+```
+
+Expected: FAIL because `evaluateBestExitOwner` does not accept a target and still uses the hardcoded default.
+
+- [ ] **Step 3: Thread target into best-exit scoring**
+
+Modify `evaluateBestExitOwner` signature in `tunnel_best_exit.go`:
+
+```go
+func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, target tunnelProbeTarget, ping bestExitProbeFunc) []bestExitCandidateScore {
+```
+
+Replace the hardcoded public probe call:
+
+```go
+ publicLatency, publicLoss, publicErr := ping(exit.NodeID, target.Host, target.Port, options)
+```
+
+Update existing tests and callers to pass `defaultTunnelProbeTarget()` unless they are testing custom targets.
+
+In `tunnel_quality_prober.go`, compute and pass the target from `probeTunnel`:
+
+```go
+ probeTarget := effectiveTunnelProbeTarget(tunnel)
+ p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget)
+```
+
+Change `probeBestExitOwners` signature:
+
+```go
+func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) {
+```
+
+Pass it into scoring:
+
+```go
+ scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, probeTarget, roundPinger)
+```
+
+- [ ] **Step 4: Make round pinger cache target-safe**
+
+In `tunnel_best_exit.go`, replace `newBestExitRoundPinger` cache key with node+host+port:
+
+```go
+type bestExitProbeCacheKey struct {
+ NodeID int64
+ Host string
+ Port int
+}
+
+func newBestExitRoundPinger(base bestExitProbeFunc) bestExitProbeFunc {
+ cache := make(map[bestExitProbeCacheKey]bestExitProbeResult)
+ return func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
+ key := bestExitProbeCacheKey{NodeID: nodeID, Host: ip, Port: port}
+ if cached, ok := cache[key]; ok {
+ return cached.latency, cached.loss, cached.err
+ }
+ lat, loss, err := base(nodeID, ip, port, options)
+ cache[key] = bestExitProbeResult{latency: lat, loss: loss, err: err}
+ return lat, loss, err
+ }
+}
+```
+
+- [ ] **Step 5: Run best-exit tests to verify GREEN**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run 'TestEvaluateBestExitOwner|TestBestExit|TestBuildTunnelChainConfigUsesBestRuntimeStrategy' -count=1
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Format and commit**
+
+```bash
+gofmt -w internal/http/handler/tunnel_best_exit.go internal/http/handler/tunnel_best_exit_test.go internal/http/handler/tunnel_quality_prober.go
+git add internal/http/handler/tunnel_best_exit.go internal/http/handler/tunnel_best_exit_test.go internal/http/handler/tunnel_quality_prober.go
+git commit -m "feat: use probe target for best exit scoring"
+```
+
+## Task 4: Use Probe Target In Tunnel Quality Monitoring
+
+**Files:**
+- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go`
+- Modify: `go-backend/internal/http/handler/monitoring.go`
+- Create: `go-backend/internal/http/handler/tunnel_quality_prober_test.go`
+
+- [ ] **Step 1: Write failing quality prober test**
+
+Create `go-backend/internal/http/handler/tunnel_quality_prober_test.go`:
+
+```go
+func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 77, "quality-target", "speed.example.com", 8443)
+ if err := h.repo.DB().Exec(`
+ INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
+ VALUES(30, 'exit-a', 'exit-secret', '10.0.0.30', '10.0.0.30', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
+ `, time.Now().UnixMilli(), time.Now().UnixMilli()).Error; err != nil {
+ t.Fatalf("insert exit node: %v", err)
+ }
+ if err := h.repo.DB().Exec(`
+ INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
+ VALUES(77, '3', 30, 30001, 'round', 1, 'tls')
+ `).Error; err != nil {
+ t.Fatalf("insert exit chain: %v", err)
+ }
+
+ p := newTunnelQualityProber(h)
+ var calls []string
+ p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
+ calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
+ return 10, 0, nil
+ }
+ p.probeTunnel(77)
+
+ if !slices.Contains(calls, "30|speed.example.com|8443") {
+ t.Fatalf("expected exit probe to configured target, calls=%+v", calls)
+ }
+ snaps := p.GetAll()
+ if len(snaps) != 1 {
+ t.Fatalf("expected one quality snapshot, got %+v", snaps)
+ }
+ if snaps[0].ProbeTargetHost != "speed.example.com" || snaps[0].ProbeTargetPort != 8443 {
+ t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
+ }
+}
+```
+
+Use this import block in the new `tunnel_quality_prober_test.go` file:
+
+```go
+import (
+ "fmt"
+ "slices"
+ "testing"
+ "time"
+)
+```
+
+- [ ] **Step 2: Run test to verify RED**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run TestTunnelQualityProberUsesConfiguredProbeTarget -count=1
+```
+
+Expected: FAIL because `tunnelQualityProber` has no injectable `probeNode` and snapshots do not include target metadata.
+
+- [ ] **Step 3: Add injectable probe function and target metadata**
+
+Modify `tunnelQualitySnapshot` in `tunnel_quality_prober.go`:
+
+```go
+ ProbeTargetHost string `json:"probeTargetHost,omitempty"`
+ ProbeTargetPort int `json:"probeTargetPort,omitempty"`
+```
+
+Modify `tunnelQualityProber` struct:
+
+```go
+ probeNode bestExitProbeFunc
+```
+
+Add method:
+
+```go
+func (p *tunnelQualityProber) pingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
+ if p != nil && p.probeNode != nil {
+ return p.probeNode(nodeID, ip, port, options)
+ }
+ return p.tcpPingNode(nodeID, ip, port, options)
+}
+```
+
+Replace existing `p.tcpPingNode(...)` calls in `probeTunnel` and `probeBestExitOwners` setup with `p.pingNode(...)` / `newBestExitRoundPinger(p.pingNode)`.
+
+- [ ] **Step 4: Use effective target in quality probes**
+
+In `probeTunnel`, after loading `tunnel` and before the switch:
+
+```go
+ probeTarget := effectiveTunnelProbeTarget(tunnel)
+ snap.ProbeTargetHost = probeTarget.Host
+ snap.ProbeTargetPort = probeTarget.Port
+```
+
+Replace hardcoded Bing probes:
+
+```go
+ lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
+```
+
+and:
+
+```go
+ lat, loss, err := p.pingNode(outNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
+```
+
+Use the same replacement in the default case. Keep persisted DB columns unchanged.
+
+- [ ] **Step 5: Include target metadata in DB fallback monitoring response**
+
+In `monitoring.go`, when converting DB `TunnelQuality` rows to `tunnelQualitySnapshot`, fetch tunnel list once and build an effective-target map:
+
+```go
+ targetsByTunnelID := map[int64]tunnelProbeTarget{}
+ if tunnels, listErr := h.repo.ListTunnels(); listErr == nil {
+ for _, item := range tunnels {
+ id := asInt64(item["id"], 0)
+ if id > 0 {
+ targetsByTunnelID[id] = effectiveTunnelProbeTargetValues(asString(item["probeTargetHost"]), asInt(item["probeTargetPort"], 0))
+ }
+ }
+ }
+```
+
+Set snapshot metadata:
+
+```go
+ target := targetsByTunnelID[q.TunnelID]
+ if target.Host == "" {
+ target = defaultTunnelProbeTarget()
+ }
+```
+
+Then include:
+
+```go
+ ProbeTargetHost: target.Host,
+ ProbeTargetPort: target.Port,
+```
+
+- [ ] **Step 6: Run quality prober tests to verify GREEN**
+
+Run from `go-backend`:
+
+```bash
+go test ./internal/http/handler -run 'TestTunnelQualityProberUsesConfiguredProbeTarget|TestTunnelCreatePersistsProbeTarget' -count=1
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Format and commit**
+
+```bash
+gofmt -w internal/http/handler/tunnel_quality_prober.go internal/http/handler/tunnel_quality_prober_test.go internal/http/handler/monitoring.go
+git add internal/http/handler/tunnel_quality_prober.go internal/http/handler/tunnel_quality_prober_test.go internal/http/handler/monitoring.go
+git commit -m "feat: use probe target for tunnel quality checks"
+```
+
+## Task 5: Frontend Form And Monitoring Display
+
+**Files:**
+- Modify: `vite-frontend/src/pages/tunnel.tsx`
+- Modify: `vite-frontend/src/pages/tunnel/form.ts`
+- Modify: `vite-frontend/src/api/types.ts`
+- Modify: `vite-frontend/src/pages/node/tunnel-monitor-view.tsx`
+
+- [ ] **Step 1: Extend frontend tunnel and quality types**
+
+In `vite-frontend/src/pages/tunnel.tsx`, add fields to `Tunnel` and `TunnelForm`:
+
+```ts
+probeTargetHost?: string;
+probeTargetPort?: number;
+```
+
+In `vite-frontend/src/api/types.ts`, extend `TunnelQualityApiItem`:
+
+```ts
+probeTargetHost?: string;
+probeTargetPort?: number;
+```
+
+In `vite-frontend/src/pages/tunnel/form.ts`, extend `TunnelFormInput`:
+
+```ts
+probeTargetHost?: string;
+probeTargetPort?: number;
+```
+
+- [ ] **Step 2: Add frontend defaults and validation**
+
+In `createTunnelFormDefaults()` add:
+
+```ts
+probeTargetHost: "",
+probeTargetPort: 0,
+```
+
+In `validateTunnelForm`, add after traffic ratio validation:
+
+```ts
+const probeHost = (form.probeTargetHost || "").trim();
+const probePort = Number(form.probeTargetPort || 0);
+
+if (probeHost || probePort > 0) {
+ if (!probeHost) {
+ errors.probeTargetHost = "请输入测试目标 Host";
+ } else if (
+ probeHost.includes("://") ||
+ /[\s/?#]/.test(probeHost)
+ ) {
+ errors.probeTargetHost = "Host 不能包含协议、空格或路径";
+ }
+
+ if (!Number.isInteger(probePort) || probePort < 1 || probePort > 65535) {
+ errors.probeTargetPort = "端口必须是 1-65535";
+ }
+}
+```
+
+- [ ] **Step 3: Round-trip form values**
+
+In `handleEdit`, add:
+
+```ts
+probeTargetHost: tunnel.probeTargetHost || "",
+probeTargetPort: tunnel.probeTargetPort || 0,
+```
+
+Before building submit `data`, normalize:
+
+```ts
+const probeTargetHost = (form.probeTargetHost || "").trim();
+const probeTargetPort = probeTargetHost ? Number(form.probeTargetPort || 0) : 0;
+```
+
+Set in payload:
+
+```ts
+probeTargetHost,
+probeTargetPort,
+```
+
+- [ ] **Step 4: Add form inputs**
+
+In the tunnel modal body near `ipPreference` and before `入口配置`, add:
+
+```tsx
+
+
+
质量检测目标
+
+ 用于实时隧道质量检测和 best 最优出口评分,留空使用 www.bing.com:443
+
+
+
+
+ setForm((prev) => ({ ...prev, probeTargetHost: e.target.value }))
+ }
+ />
+
+ setForm((prev) => ({
+ ...prev,
+ probeTargetPort: e.target.value ? Number(e.target.value) : 0,
+ }))
+ }
+ />
+
+
+```
+
+- [ ] **Step 5: Add target-aware labels in monitoring view**
+
+In `vite-frontend/src/pages/node/tunnel-monitor-view.tsx`, add helper near constants:
+
+```ts
+const DEFAULT_PROBE_TARGET_LABEL = "www.bing.com:443";
+
+const probeTargetLabel = (quality?: TunnelQualityApiItem | null) => {
+ if (!quality?.probeTargetHost || !quality.probeTargetPort) {
+ return DEFAULT_PROBE_TARGET_LABEL;
+ }
+ const host = quality.probeTargetHost.includes(":")
+ ? `[${quality.probeTargetHost}]`
+ : quality.probeTargetHost;
+ return `${host}:${quality.probeTargetPort}`;
+};
+```
+
+Replace visible labels `出口 → Bing 延迟`, `出口 → Bing 丢包`, `出口→Bing`, and table header `出口→Bing` with target-neutral labels:
+
+```tsx
+出口 → 测试目标 延迟
+```
+
+and add `title={probeTargetLabel(quality)}` to compact labels where space is constrained.
+
+Where the detail view has auto-probe status, append:
+
+```tsx
+· 测试目标: {probeTargetLabel(quality)}
+```
+
+- [ ] **Step 6: Run frontend build**
+
+Run from `vite-frontend`:
+
+```bash
+pnpm run build
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit frontend changes**
+
+```bash
+git add src/pages/tunnel.tsx src/pages/tunnel/form.ts src/api/types.ts src/pages/node/tunnel-monitor-view.tsx
+git commit -m "feat: add tunnel probe target UI"
+```
+
+## Task 6: Full Verification And Final Review
+
+**Files:**
+- No planned code changes. Fix only issues found by verification or review.
+
+- [ ] **Step 1: Run backend tests**
+
+Run from `go-backend`:
+
+```bash
+go test ./...
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run frontend build**
+
+Run from `vite-frontend`:
+
+```bash
+pnpm run build
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Check diff hygiene**
+
+Run from repo root:
+
+```bash
+git diff --check origin/main...HEAD
+git status --short
+```
+
+Expected: no whitespace errors; only intended branch changes.
+
+- [ ] **Step 4: Manual smoke checklist**
+
+Use local backend/frontend if available:
+
+```bash
+# terminal 1
+cd go-backend && go run ./cmd/paneld
+
+# terminal 2
+cd vite-frontend && pnpm run dev
+```
+
+Check:
+
+- Create tunnel with blank quality target; list/get shows empty target fields and monitoring uses `www.bing.com:443` effectively.
+- Edit tunnel with `speed.example.com` and `8443`; reopen edit modal and confirm values round-trip.
+- Enable tunnel quality detection; monitoring labels show `测试目标`, not hardcoded Bing.
+- A `best` multi-exit tunnel eventually leaves `等待探测` when probes succeed against the configured target.
+
+- [ ] **Step 5: Final code review**
+
+Dispatch a final reviewer with this context:
+
+```text
+Review custom per-tunnel probe target implementation. Verify configured host/port persists, defaults to www.bing.com:443 when unset, is used by best-exit scoring and tunnel quality prober, preserves existing API fields, and does not change routing/switching thresholds or add frontend dependencies.
+```
+
+Fix any Critical or Important findings, then rerun Steps 1-3.
+
+- [ ] **Step 6: Commit review fixes if any**
+
+If review fixes were needed, stage the files touched by those fixes and commit them. Use the exact changed paths from `git status --short`; do not stage unrelated files.
+
+```bash
+git status --short
+git add go-backend/internal/http/handler/tunnel_probe_target.go go-backend/internal/http/handler/tunnel_probe_target_test.go go-backend/internal/http/handler/tunnel_probe_target_api_test.go go-backend/internal/http/handler/tunnel_best_exit.go go-backend/internal/http/handler/tunnel_best_exit_test.go go-backend/internal/http/handler/tunnel_quality_prober.go go-backend/internal/http/handler/tunnel_quality_prober_test.go go-backend/internal/http/handler/monitoring.go go-backend/internal/http/handler/mutations.go go-backend/internal/store/model/model.go go-backend/internal/store/repo/repository.go go-backend/internal/store/repo/repository_mutations.go vite-frontend/src/pages/tunnel.tsx vite-frontend/src/pages/tunnel/form.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/node/tunnel-monitor-view.tsx
+git commit -m "fix: stabilize custom probe target handling"
+```
+
+If `git status --short` shows no review-fix changes, do not create an empty commit.
+
+---
+
+## Implementation Notes
+
+- Keep the existing `exitToBingLatency` / `exitToBingLoss` JSON and DB fields for compatibility; change labels, not keys.
+- Do not add frontend tests. This repository has no frontend test framework and `AGENTS.md` explicitly says not to add one.
+- Backend handlers should continue using repository methods for new persistence paths. Do not introduce new `repo.DB()` usage in production handlers.
+- SQLite and PostgreSQL compatibility: use plain string/int GORM tags, no `jsonb` or `serial`.
+- Do not edit `install.sh` or `panel_install.sh`.
diff --git a/docs/superpowers/specs/2026-05-01-custom-best-exit-probe-target-design.md b/docs/superpowers/specs/2026-05-01-custom-best-exit-probe-target-design.md
new file mode 100644
index 0000000..4e723fe
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-01-custom-best-exit-probe-target-design.md
@@ -0,0 +1,180 @@
+# Custom Best-Exit Probe Target Design
+
+Date: 2026-05-01
+Status: Approved design
+
+## Goal
+
+Allow each tunnel to define the TCP target used for exit-side quality probing instead of always probing `www.bing.com:443`.
+
+The custom target must be used consistently by:
+
+- `best` exit scoring: each exit probes the configured target to measure exit-to-public quality.
+- Tunnel quality monitoring: the existing exit-side quality check probes the same configured target.
+
+If a tunnel does not configure a target, behavior remains compatible with today: `www.bing.com:443`.
+
+## Non-Goals
+
+- Do not add HTTP/HTTPS request probing in this phase. The probe remains TCP host/port measurement.
+- Do not add a global default target setting in this phase.
+- Do not require existing tunnels to be edited or migrated manually.
+- Do not change the `best` switching thresholds, confirmation rounds, cooldowns, or runtime chain ordering semantics.
+- Do not add frontend test infrastructure.
+
+## User-Facing Behavior
+
+Each tunnel form gets a compact quality target section:
+
+- Host input, placeholder `www.bing.com`.
+- Port input, placeholder `443`.
+- Helper text: this target is used for tunnel quality detection and `best` optimal-exit scoring; leaving it empty uses `www.bing.com:443`.
+
+Tunnel list/get responses include the configured target so edit forms can round-trip it. The UI displays the effective target near quality/best-exit information as `测试目标:host:port`.
+
+## Data Model
+
+Add nullable/default-compatible fields to `model.Tunnel`:
+
+- `ProbeTargetHost string` mapped to `probe_target_host`, `type:text`, default `''`.
+- `ProbeTargetPort int` mapped to `probe_target_port`, default `0`.
+
+Effective target resolution:
+
+- If `ProbeTargetHost` is non-empty and `ProbeTargetPort` is valid, use it.
+- Otherwise use `www.bing.com:443`.
+
+The existing `TunnelQuality` persisted fields `exit_to_bing_latency` and `exit_to_bing_loss` remain unchanged for compatibility. They will semantically mean exit-to-configured-test-target after this change. API/UI labels should avoid saying `Bing` for new displays.
+
+## Validation
+
+On create/update:
+
+- Empty host and empty/zero port are allowed and mean default target.
+- If either host or port is set, validate both as a pair.
+- Host is trimmed and must not contain URL scheme, path, query, or whitespace.
+- Host can be a domain, IPv4, or IPv6 literal. Bracketed IPv6 input should be normalized by removing surrounding brackets.
+- Port must be an integer from `1` to `65535`.
+- Do not perform network probing during save; external network failures must not block configuration changes.
+
+Errors should be specific, for example:
+
+- `测试目标 Host 不能为空`
+- `测试目标端口必须是 1-65535`
+- `测试目标 Host 不能包含协议或路径`
+
+## Backend Flow
+
+Introduce a small value/helper near the tunnel quality and best-exit code:
+
+```go
+type tunnelProbeTarget struct {
+ Host string
+ Port int
+}
+```
+
+Helpers:
+
+- `defaultTunnelProbeTarget() tunnelProbeTarget` returns `www.bing.com:443`.
+- `normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, error)` validates user input; the boolean indicates whether the user explicitly configured a target.
+- `effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget` returns configured target or default.
+
+Use the effective target in `tunnelQualityProber.probeTunnel`:
+
+- Type 1 and unknown tunnel fallback probes entry node to effective target instead of hardcoded Bing.
+- Type 2 probes the selected/current exit node to effective target instead of hardcoded Bing.
+- `probeBestExitOwners` receives the effective target and passes it into best-exit owner scoring.
+
+Use the effective target in `evaluateBestExitOwner`:
+
+- Owner-to-exit measurement stays unchanged.
+- Exit-to-public measurement probes `target.Host:target.Port` instead of `bestExitPublicTargetHost:bestExitPublicTargetPort`.
+- The per-round public probe cache key must include node ID plus target host and port so future extensions cannot reuse measurements across different targets.
+
+## API Shape
+
+Tunnel list/get data includes:
+
+```json
+{
+ "probeTargetHost": "example.com",
+ "probeTargetPort": 443
+}
+```
+
+For old/default tunnels, return empty host and `0` to represent `use default`. The edit form must preserve default-as-empty unless the user explicitly saves a custom target.
+
+Quality monitoring response includes effective target display metadata:
+
+```json
+{
+ "probeTargetHost": "www.bing.com",
+ "probeTargetPort": 443
+}
+```
+
+Existing `exitToBingLatency` and `exitToBingLoss` keys stay to avoid breaking frontend and external consumers.
+
+## Frontend Flow
+
+Extend `ChainTunnel` only if needed for node-level data; the target belongs to the tunnel, so `Tunnel` and `TunnelForm` get:
+
+- `probeTargetHost?: string`
+- `probeTargetPort?: number`
+
+On edit:
+
+- Populate form fields from tunnel response.
+- Empty or zero means default target.
+
+On submit:
+
+- Trim host.
+- Convert blank port to `0`.
+- Send `probeTargetHost` and `probeTargetPort` with create/update payload.
+
+Display:
+
+- In the form helper, show default target behavior.
+- In quality/best-exit display areas, avoid `Bing` wording; prefer `测试目标` or the concrete `host:port`.
+
+## Error Handling
+
+- Invalid target input returns a normal API error envelope with a specific message.
+- Probe failures use existing quality error paths and best-exit scoring failure entries.
+- If all exit-to-target probes fail, best-exit behavior remains the same as today when all Bing probes fail: no valid best decision is applied from that round.
+
+## Testing
+
+Backend tests:
+
+- Normalize default target when host/port are empty.
+- Reject partial host/port configuration and invalid port ranges.
+- Reject host values with URL scheme/path/whitespace.
+- Create/update tunnel persists `probeTargetHost` and `probeTargetPort`.
+- `ListTunnels` returns target fields.
+- `tunnelQualityProber` uses configured target instead of `www.bing.com:443`.
+- `best` scoring uses configured target for exit-to-target probes.
+- Empty target preserves old default `www.bing.com:443` behavior.
+
+Frontend verification:
+
+- `pnpm run build` passes.
+- Manual UI check: create/edit tunnel with blank target and custom target, confirm payload and round-trip display.
+
+## Rollout And Compatibility
+
+- Existing tunnels continue using `www.bing.com:443` because empty target resolves to default.
+- SQLite/PostgreSQL schema changes are handled by existing auto-migration.
+- Historical `TunnelQuality` rows keep existing columns and are not rewritten.
+- No runtime agent change is required; the panel already performs these quality probes through existing node ping APIs.
+
+## Open Decisions
+
+None. User-approved decisions:
+
+- Per-tunnel fields are `host + port`.
+- The target applies to both `best` scoring and tunnel quality monitoring.
+- Probe type remains TCP host/port.
+- Empty target defaults to `www.bing.com:443`.
diff --git a/go-backend/internal/http/handler/monitoring.go b/go-backend/internal/http/handler/monitoring.go
index 96c0687..17f6541 100644
--- a/go-backend/internal/http/handler/monitoring.go
+++ b/go-backend/internal/http/handler/monitoring.go
@@ -234,8 +234,22 @@ func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Req
return
}
+ targetsByTunnelID := map[int64]tunnelProbeTarget{}
+ if tunnels, listErr := h.repo.ListTunnels(); listErr == nil {
+ for _, item := range tunnels {
+ id := asInt64(item["id"], 0)
+ if id > 0 {
+ targetsByTunnelID[id] = effectiveTunnelProbeTargetValues(asString(item["probeTargetHost"]), asInt(item["probeTargetPort"], 0))
+ }
+ }
+ }
+
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
for _, q := range qualities {
+ target := targetsByTunnelID[q.TunnelID]
+ if target.Host == "" {
+ target = defaultTunnelProbeTarget()
+ }
snapshots = append(snapshots, tunnelQualitySnapshot{
TunnelID: q.TunnelID,
EntryToExitLatency: q.EntryToExitLatency,
@@ -246,6 +260,8 @@ func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Req
ErrorMessage: q.ErrorMessage,
Timestamp: q.Timestamp,
ChainDetails: q.ChainDetails,
+ ProbeTargetHost: target.Host,
+ ProbeTargetPort: target.Port,
})
}
response.WriteJSON(w, response.OK(snapshots))
diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go
index 463d6b8..5ac689e 100644
--- a/go-backend/internal/http/handler/mutations.go
+++ b/go-backend/internal/http/handler/mutations.go
@@ -607,6 +607,17 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
trafficRatio := asFloat(req["trafficRatio"], 1.0)
inIP := asString(req["inIp"])
ipPreference := asString(req["ipPreference"])
+ probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req)
+ if err != nil {
+ response.WriteJSON(w, response.ErrDefault(err.Error()))
+ return
+ }
+ probeTargetHost := ""
+ probeTargetPort := 0
+ if probeTargetConfigured {
+ probeTargetHost = probeTarget.Host
+ probeTargetPort = probeTarget.Port
+ }
now := time.Now().UnixMilli()
inx := h.repo.NextIndex("tunnel")
localDomain := h.federationLocalDomain()
@@ -685,17 +696,19 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
tunnel := model.Tunnel{
- Name: name,
- TrafficRatio: trafficRatio,
- Type: typeVal,
- Protocol: tunnelProtocol,
- Flow: flow,
- CreatedTime: now,
- UpdatedTime: now,
- Status: status,
- InIP: tunnelInIP,
- Inx: inx,
- IPPreference: ipPreference,
+ Name: name,
+ TrafficRatio: trafficRatio,
+ Type: typeVal,
+ Protocol: tunnelProtocol,
+ Flow: flow,
+ CreatedTime: now,
+ UpdatedTime: now,
+ Status: status,
+ InIP: tunnelInIP,
+ Inx: inx,
+ IPPreference: ipPreference,
+ ProbeTargetHost: probeTargetHost,
+ ProbeTargetPort: probeTargetPort,
}
if err := tx.Create(&tunnel).Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -871,9 +884,30 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
- oldEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id)
typeVal := asInt(req["type"], 1)
+ ipPreference := asString(req["ipPreference"])
+ _, hasProbeTargetHost := req["probeTargetHost"]
+ _, hasProbeTargetPort := req["probeTargetPort"]
+ probeTargetFieldsPresent := hasProbeTargetHost || hasProbeTargetPort
+ probeTargetHost := ""
+ probeTargetPort := 0
+ if probeTargetFieldsPresent {
+ probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req)
+ if err != nil {
+ response.WriteJSON(w, response.ErrDefault(err.Error()))
+ return
+ }
+ if probeTargetConfigured {
+ probeTargetHost = probeTarget.Host
+ probeTargetPort = probeTarget.Port
+ }
+ }
+ oldEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id)
oldTunnel, _ := h.getTunnelRecord(id)
+ if !probeTargetFieldsPresent && oldTunnel != nil {
+ probeTargetHost = oldTunnel.ProbeTargetHost
+ probeTargetPort = oldTunnel.ProbeTargetPort
+ }
oldChainRows, _ := h.listChainNodesForTunnel(id)
if oldTunnel != nil && oldTunnel.Type == 2 && typeVal != 2 {
h.cleanupTunnelRuntime(id)
@@ -881,7 +915,6 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
h.cleanupFederationRuntime(id)
now := time.Now().UnixMilli()
- ipPreference := asString(req["ipPreference"])
localDomain := h.federationLocalDomain()
runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id)
@@ -928,6 +961,8 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
inIp,
ipPreference,
updateProtocol,
+ probeTargetHost,
+ probeTargetPort,
now,
); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
diff --git a/go-backend/internal/http/handler/tunnel_best_exit.go b/go-backend/internal/http/handler/tunnel_best_exit.go
index 6afda57..4c2b2bc 100644
--- a/go-backend/internal/http/handler/tunnel_best_exit.go
+++ b/go-backend/internal/http/handler/tunnel_best_exit.go
@@ -57,6 +57,12 @@ type bestExitProbeResult struct {
err error
}
+type bestExitProbeCacheKey struct {
+ NodeID int64
+ Host string
+ Port int
+}
+
type bestExitDecision struct {
AppliedExitNodeID int64
PendingExitNodeID int64
@@ -138,7 +144,7 @@ func sortBestExitScores(scores []bestExitCandidateScore) {
})
}
-func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, ping bestExitProbeFunc) []bestExitCandidateScore {
+func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, target tunnelProbeTarget, ping bestExitProbeFunc) []bestExitCandidateScore {
scores := make([]bestExitCandidateScore, 0, len(exits))
if owner.NodeID <= 0 || len(exits) == 0 || ping == nil {
return scores
@@ -160,7 +166,7 @@ func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, ownerErr.Error()))
continue
}
- publicLatency, publicLoss, publicErr := ping(exit.NodeID, bestExitPublicTargetHost, bestExitPublicTargetPort, options)
+ publicLatency, publicLoss, publicErr := ping(exit.NodeID, target.Host, target.Port, options)
if publicErr != nil {
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, publicErr.Error()))
continue
@@ -193,17 +199,15 @@ func resolveBestExitProbeTarget(fromNode, targetNode *nodeRecord, preferredPort
}
func newBestExitRoundPinger(base bestExitProbeFunc) bestExitProbeFunc {
- cache := make(map[int64]bestExitProbeResult)
+ cache := make(map[bestExitProbeCacheKey]bestExitProbeResult)
return func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
- if ip == bestExitPublicTargetHost && port == bestExitPublicTargetPort {
- if cached, ok := cache[nodeID]; ok {
- return cached.latency, cached.loss, cached.err
- }
- lat, loss, err := base(nodeID, ip, port, options)
- cache[nodeID] = bestExitProbeResult{latency: lat, loss: loss, err: err}
- return lat, loss, err
+ key := bestExitProbeCacheKey{NodeID: nodeID, Host: ip, Port: port}
+ if cached, ok := cache[key]; ok {
+ return cached.latency, cached.loss, cached.err
}
- return base(nodeID, ip, port, options)
+ lat, loss, err := base(nodeID, ip, port, options)
+ cache[key] = bestExitProbeResult{latency: lat, loss: loss, err: err}
+ return lat, loss, err
}
}
diff --git a/go-backend/internal/http/handler/tunnel_best_exit_test.go b/go-backend/internal/http/handler/tunnel_best_exit_test.go
index e0eb40e..ca0f677 100644
--- a/go-backend/internal/http/handler/tunnel_best_exit_test.go
+++ b/go-backend/internal/http/handler/tunnel_best_exit_test.go
@@ -2,6 +2,9 @@ package handler
import (
"errors"
+ "fmt"
+ "slices"
+ "strings"
"testing"
"time"
)
@@ -189,7 +192,7 @@ func TestBestExitEnsureAppliedDoesNotOverrideExistingAppliedExit(t *testing.T) {
}
}
-func TestBestExitRoundPingerCachesPublicProbeOnly(t *testing.T) {
+func TestBestExitRoundPingerCachesByNodeHostAndPort(t *testing.T) {
publicCalls := 0
ownerCalls := 0
pinger := newBestExitRoundPinger(func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
@@ -220,8 +223,8 @@ func TestBestExitRoundPingerCachesPublicProbeOnly(t *testing.T) {
if _, _, err := pinger(10, "10.0.0.30", 30030, diagnosisExecOptions{}); err != nil {
t.Fatalf("unexpected repeated owner ping err=%v", err)
}
- if ownerCalls != 2 {
- t.Fatalf("expected owner-to-exit probes not cached, got %d calls", ownerCalls)
+ if ownerCalls != 1 {
+ t.Fatalf("expected owner-to-exit probes cached by target, got %d calls", ownerCalls)
}
}
@@ -375,7 +378,7 @@ func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
}
}
- scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger)
+ scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
if len(scores) != 2 {
t.Fatalf("expected two scores, got %+v", scores)
}
@@ -384,6 +387,34 @@ func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
}
}
+func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) {
+ owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"}
+ exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}}
+ nodes := map[int64]*nodeRecord{
+ 10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
+ 30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
+ }
+ target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443}
+ var calls []string
+ ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
+ calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
+ return 10, 0, nil
+ }
+
+ scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, target, ping)
+ if len(scores) != 1 || !scores[0].Success {
+ t.Fatalf("expected successful score, got %+v", scores)
+ }
+ if !slices.Contains(calls, "30|speed.example.com|8443") {
+ t.Fatalf("expected exit public probe to use configured target, calls=%+v", calls)
+ }
+ for _, call := range calls {
+ if strings.Contains(call, defaultTunnelProbeTargetHost) {
+ t.Fatalf("did not expect default target call when custom target configured: %+v", calls)
+ }
+ }
+}
+
func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testing.T) {
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}}
@@ -395,7 +426,7 @@ func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testin
return 0, 100, errBestExitProbeForTest
}
- scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger)
+ scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
if len(scores) != 1 || scores[0].Success {
t.Fatalf("expected failed candidate, got %+v", scores)
}
@@ -413,7 +444,7 @@ func TestEvaluateBestExitOwnerMarksCandidateFailedWhenTargetResolutionFails(t *t
return 0, 100, nil
}
- scores := evaluateBestExitOwner(owner, exits, nodes, "v4", diagnosisExecOptions{}, pinger)
+ scores := evaluateBestExitOwner(owner, exits, nodes, "v4", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
if len(scores) != 1 || scores[0].Success {
t.Fatalf("expected failed candidate, got %+v", scores)
}
diff --git a/go-backend/internal/http/handler/tunnel_probe_target.go b/go-backend/internal/http/handler/tunnel_probe_target.go
new file mode 100644
index 0000000..d21200f
--- /dev/null
+++ b/go-backend/internal/http/handler/tunnel_probe_target.go
@@ -0,0 +1,222 @@
+package handler
+
+import (
+ "errors"
+ "fmt"
+ "net/netip"
+ "strconv"
+ "strings"
+
+ "go-backend/internal/store/model"
+)
+
+const (
+ defaultTunnelProbeTargetHost = "www.bing.com"
+ defaultTunnelProbeTargetPort = 443
+)
+
+type tunnelProbeTarget struct {
+ Host string
+ Port int
+}
+
+func defaultTunnelProbeTarget() tunnelProbeTarget {
+ return tunnelProbeTarget{Host: defaultTunnelProbeTargetHost, Port: defaultTunnelProbeTargetPort}
+}
+
+func normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, error) {
+ host = strings.TrimSpace(host)
+ if host == "" && port == 0 {
+ return defaultTunnelProbeTarget(), false, nil
+ }
+ if host == "" {
+ return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能为空")
+ }
+ if port <= 0 || port > 65535 {
+ return tunnelProbeTarget{}, false, errors.New("测试目标端口必须是 1-65535")
+ }
+ if strings.Contains(host, "://") || strings.ContainsAny(host, "/?#") || strings.ContainsAny(host, " \t\r\n") || isTunnelProbeTargetSchemeLikeHost(host) {
+ return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能包含协议或路径")
+ }
+ if normalized, ok := normalizeTunnelProbeTargetHost(host); ok {
+ host = normalized
+ } else {
+ return tunnelProbeTarget{}, false, errors.New("测试目标 Host 格式无效")
+ }
+
+ return tunnelProbeTarget{Host: host, Port: port}, true, nil
+}
+
+func normalizeTunnelProbeTargetHost(host string) (string, bool) {
+ if strings.HasPrefix(host, "[") || strings.HasSuffix(host, "]") {
+ if !strings.HasPrefix(host, "[") || !strings.HasSuffix(host, "]") {
+ return "", false
+ }
+ inner := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
+ addr, err := netip.ParseAddr(inner)
+ if err != nil || !addr.Is6() {
+ return "", false
+ }
+ return inner, true
+ }
+
+ if addr, err := netip.ParseAddr(host); err == nil {
+ return addr.String(), true
+ }
+ if strings.Contains(host, ":") || isTunnelProbeTargetIPv4Like(host) {
+ return "", false
+ }
+ if !isValidTunnelProbeTargetHost(host) {
+ return "", false
+ }
+ return host, true
+}
+
+func isValidTunnelProbeTargetHost(host string) bool {
+ if host == "" || len(host) > 253 {
+ return false
+ }
+ for _, label := range strings.Split(host, ".") {
+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+ return false
+ }
+ for _, r := range label {
+ if !isASCIILetter(r) && !isASCIIDigit(r) && r != '-' {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func isTunnelProbeTargetIPv4Like(host string) bool {
+ if host == "" {
+ return false
+ }
+ for _, r := range host {
+ if !isASCIIDigit(r) && r != '.' {
+ return false
+ }
+ }
+ return strings.Contains(host, ".")
+}
+
+func isTunnelProbeTargetSchemeLikeHost(host string) bool {
+ if _, err := netip.ParseAddr(host); err == nil {
+ return false
+ }
+
+ colon := strings.IndexByte(host, ':')
+ if colon <= 0 {
+ return false
+ }
+ for i, r := range host[:colon] {
+ if i == 0 {
+ if !isASCIILetter(r) {
+ return false
+ }
+ continue
+ }
+ if !isASCIILetter(r) && !isASCIIDigit(r) && r != '+' && r != '-' && r != '.' {
+ return false
+ }
+ }
+ return true
+}
+
+func isASCIILetter(r rune) bool {
+ return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
+}
+
+func isASCIIDigit(r rune) bool {
+ return r >= '0' && r <= '9'
+}
+
+func parseTunnelProbeTargetFromRequest(req map[string]interface{}) (tunnelProbeTarget, bool, error) {
+ if req == nil {
+ return defaultTunnelProbeTarget(), false, nil
+ }
+ rawHost, hasHost := req["probeTargetHost"]
+ rawPort, hasPort := req["probeTargetPort"]
+ if !hasHost && !hasPort {
+ return defaultTunnelProbeTarget(), false, nil
+ }
+ host, err := parseTunnelProbeTargetHostValue(rawHost)
+ if err != nil {
+ return tunnelProbeTarget{}, false, err
+ }
+ port, err := parseTunnelProbeTargetPortValue(rawPort)
+ if err != nil {
+ return tunnelProbeTarget{}, false, err
+ }
+ return normalizeTunnelProbeTarget(host, port)
+}
+
+func parseTunnelProbeTargetHostValue(raw interface{}) (string, error) {
+ if raw == nil {
+ return "", nil
+ }
+ host, ok := raw.(string)
+ if !ok {
+ return "", errors.New("测试目标 Host 格式无效")
+ }
+ if host != strings.TrimSpace(host) {
+ return "", errors.New("测试目标 Host 不能包含协议或路径")
+ }
+ return host, nil
+}
+
+func parseTunnelProbeTargetPortValue(raw interface{}) (int, error) {
+ if raw == nil {
+ return 0, nil
+ }
+ switch v := raw.(type) {
+ case float64:
+ if v != float64(int64(v)) {
+ return 0, errors.New("测试目标端口必须是整数")
+ }
+ return int(v), nil
+ case string:
+ if v == "" {
+ return 0, nil
+ }
+ if v != strings.TrimSpace(v) {
+ return 0, errors.New("测试目标端口必须是整数")
+ }
+ port, err := strconv.Atoi(v)
+ if err != nil {
+ return 0, errors.New("测试目标端口必须是整数")
+ }
+ return port, nil
+ case int:
+ return v, nil
+ case int32:
+ return int(v), nil
+ case int64:
+ return int(v), nil
+ default:
+ return 0, errors.New("测试目标端口必须是整数")
+ }
+}
+
+func effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget {
+ if tunnel == nil {
+ return defaultTunnelProbeTarget()
+ }
+ return effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort)
+}
+
+func effectiveTunnelProbeTargetValues(host string, port int) tunnelProbeTarget {
+ target, configured, err := normalizeTunnelProbeTarget(host, port)
+ if err != nil || !configured {
+ return defaultTunnelProbeTarget()
+ }
+ return target
+}
+
+func formatTunnelProbeTarget(target tunnelProbeTarget) string {
+ if addr, err := netip.ParseAddr(target.Host); err == nil && addr.Is6() {
+ return fmt.Sprintf("[%s]:%d", target.Host, target.Port)
+ }
+ return fmt.Sprintf("%s:%d", target.Host, target.Port)
+}
diff --git a/go-backend/internal/http/handler/tunnel_probe_target_api_test.go b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go
new file mode 100644
index 0000000..159076f
--- /dev/null
+++ b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go
@@ -0,0 +1,289 @@
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "go-backend/internal/store/repo"
+)
+
+func TestTunnelCreatePersistsProbeTargetAndListReturnsConfiguredValue(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ body := bytes.NewReader([]byte(`{
+ "name":"custom-target",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}],
+ "probeTargetHost":"speed.example.com",
+ "probeTargetPort":8443
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body))
+ assertProbeTargetSuccess(t, res)
+
+ listRes := httptest.NewRecorder()
+ h.tunnelList(listRes, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil))
+ var payload struct {
+ Code int `json:"code"`
+ Data []map[string]any `json:"data"`
+ }
+ decodeProbeTargetResponse(t, listRes, &payload)
+ if payload.Code != 0 {
+ t.Fatalf("expected success, got code %d", payload.Code)
+ }
+ item := payload.Data[0]
+ if item["probeTargetHost"] != "speed.example.com" || item["probeTargetPort"] != float64(8443) {
+ t.Fatalf("unexpected probe target in list response: %+v", item)
+ }
+}
+
+func TestTunnelUpdatePersistsDefaultProbeTargetAsEmpty(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 77, "existing", "old.example.com", 9443)
+ body := bytes.NewReader([]byte(`{
+ "id":77,
+ "name":"existing",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}],
+ "probeTargetHost":"",
+ "probeTargetPort":0
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
+ assertProbeTargetSuccess(t, res)
+
+ items, err := h.repo.ListTunnels()
+ if err != nil {
+ t.Fatalf("list tunnels: %v", err)
+ }
+ item := findProbeTargetTunnelItem(t, items, 77)
+ if item["probeTargetHost"] != "" || item["probeTargetPort"] != 0 {
+ t.Fatalf("expected default target to round-trip as empty/0, got %+v", item)
+ }
+}
+
+func TestTunnelUpdateWithoutProbeTargetFieldsPreservesExistingTarget(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 79, "existing", "old.example.com", 9443)
+ body := bytes.NewReader([]byte(`{
+ "id":79,
+ "name":"existing",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}]
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
+ assertProbeTargetSuccess(t, res)
+
+ items, err := h.repo.ListTunnels()
+ if err != nil {
+ t.Fatalf("list tunnels: %v", err)
+ }
+ item := findProbeTargetTunnelItem(t, items, 79)
+ if item["probeTargetHost"] != "old.example.com" || item["probeTargetPort"] != 9443 {
+ t.Fatalf("expected omitted probe target fields to preserve existing target, got %+v", item)
+ }
+}
+
+func TestTunnelUpdateRejectsInvalidProbeTargetWithoutClearingExistingTarget(t *testing.T) {
+ tests := []struct {
+ name string
+ probeFields string
+ }{
+ {name: "non numeric port", probeFields: `,"probeTargetPort":"abc"`},
+ {name: "fractional port", probeFields: `,"probeTargetPort":443.5`},
+ {name: "whitespace host", probeFields: `,"probeTargetHost":" "`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 80, "existing", "old.example.com", 9443)
+ body := bytes.NewReader([]byte(`{
+ "id":80,
+ "name":"existing",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}]
+ ` + tt.probeFields + `}`))
+
+ res := httptest.NewRecorder()
+ h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
+ var payload struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ }
+ decodeProbeTargetResponse(t, res, &payload)
+ if payload.Code == 0 || payload.Msg == "" {
+ t.Fatalf("expected validation failure, got %+v", payload)
+ }
+
+ items, err := h.repo.ListTunnels()
+ if err != nil {
+ t.Fatalf("list tunnels: %v", err)
+ }
+ item := findProbeTargetTunnelItem(t, items, 80)
+ if item["probeTargetHost"] != "old.example.com" || item["probeTargetPort"] != 9443 {
+ t.Fatalf("expected invalid probe target to preserve existing target, got %+v", item)
+ }
+ })
+ }
+}
+
+func TestTunnelCreateRejectsInvalidProbeTarget(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ body := bytes.NewReader([]byte(`{
+ "name":"bad-target",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}],
+ "probeTargetHost":"https://example.com",
+ "probeTargetPort":443
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body))
+ var payload struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ }
+ decodeProbeTargetResponse(t, res, &payload)
+ if payload.Code == 0 || payload.Msg == "" {
+ t.Fatalf("expected validation failure, got %+v", payload)
+ }
+}
+
+func TestTunnelUpdateInvalidProbeTargetDoesNotCleanFederationBindings(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 88, "existing", "old.example.com", 9443)
+ seedProbeTargetFederationBinding(t, h, 88)
+ body := bytes.NewReader([]byte(`{
+ "id":88,
+ "name":"existing",
+ "type":1,
+ "flow":1,
+ "trafficRatio":1,
+ "status":1,
+ "inNodeId":[{"nodeId":10,"protocol":"tls"}],
+ "probeTargetHost":"https://example.com",
+ "probeTargetPort":443
+ }`))
+
+ res := httptest.NewRecorder()
+ h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body))
+ var payload struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ }
+ decodeProbeTargetResponse(t, res, &payload)
+ if payload.Code == 0 || payload.Msg == "" {
+ t.Fatalf("expected validation failure, got %+v", payload)
+ }
+
+ bindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(88)
+ if err != nil {
+ t.Fatalf("list federation bindings: %v", err)
+ }
+ if len(bindings) != 1 {
+ t.Fatalf("expected federation binding to remain after invalid update, got %d", len(bindings))
+ }
+}
+
+func setupProbeTargetTunnelHandler(t *testing.T) *Handler {
+ t.Helper()
+ r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = r.Close() })
+ h := New(r, "secret")
+ now := time.Now().UnixMilli()
+ if err := r.DB().Exec(`
+ INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
+ VALUES(10, 'entry-a', 'entry-secret', '10.0.0.1', '10.0.0.1', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
+ `, now, now).Error; err != nil {
+ t.Fatalf("insert node: %v", err)
+ }
+ return h
+}
+
+func seedProbeTargetTunnel(t *testing.T, h *Handler, id int64, name string, host string, port int) {
+ t.Helper()
+ now := time.Now().UnixMilli()
+ if err := h.repo.DB().Exec(`
+ INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference, probe_target_host, probe_target_port)
+ VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '', ?, ?)
+ `, id, name, now, now, id, host, port).Error; err != nil {
+ t.Fatalf("insert tunnel: %v", err)
+ }
+ if err := h.repo.DB().Exec(`
+ INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
+ VALUES(?, '1', 10, 30001, 'round', 1, 'tls')
+ `, id).Error; err != nil {
+ t.Fatalf("insert chain: %v", err)
+ }
+}
+
+func seedProbeTargetFederationBinding(t *testing.T, h *Handler, tunnelID int64) {
+ t.Helper()
+ now := time.Now().UnixMilli()
+ if err := h.repo.DB().Exec(`
+ INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
+ VALUES(?, 10, 1, 0, 'http://peer.example', ?, 'remote-binding', 30001, 1, ?, ?)
+ `, tunnelID, "probe-target-test-binding", now, now).Error; err != nil {
+ t.Fatalf("insert federation binding: %v", err)
+ }
+}
+
+func assertProbeTargetSuccess(t *testing.T, res *httptest.ResponseRecorder) {
+ t.Helper()
+ var payload struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ }
+ decodeProbeTargetResponse(t, res, &payload)
+ if payload.Code != 0 {
+ t.Fatalf("expected success, got %+v", payload)
+ }
+}
+
+func decodeProbeTargetResponse(t *testing.T, res *httptest.ResponseRecorder, v any) {
+ t.Helper()
+ if res.Code != http.StatusOK {
+ t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code)
+ }
+ if err := json.NewDecoder(res.Body).Decode(v); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+}
+
+func findProbeTargetTunnelItem(t *testing.T, items []map[string]interface{}, id int64) map[string]interface{} {
+ t.Helper()
+ for _, item := range items {
+ if asInt64(item["id"], 0) == id {
+ return item
+ }
+ }
+ t.Fatalf("tunnel %d not found: %+v", id, items)
+ return nil
+}
diff --git a/go-backend/internal/http/handler/tunnel_probe_target_test.go b/go-backend/internal/http/handler/tunnel_probe_target_test.go
new file mode 100644
index 0000000..ded971a
--- /dev/null
+++ b/go-backend/internal/http/handler/tunnel_probe_target_test.go
@@ -0,0 +1,120 @@
+package handler
+
+import "testing"
+
+func TestNormalizeTunnelProbeTargetDefaultsWhenEmpty(t *testing.T) {
+ target, configured, err := normalizeTunnelProbeTarget("", 0)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if configured {
+ t.Fatalf("expected empty input to be default, not configured")
+ }
+ if target.Host != defaultTunnelProbeTargetHost || target.Port != defaultTunnelProbeTargetPort {
+ t.Fatalf("unexpected default target: %+v", target)
+ }
+}
+
+func TestNormalizeTunnelProbeTargetAcceptsHostPortAndIPv6(t *testing.T) {
+ target, configured, err := normalizeTunnelProbeTarget(" [2001:db8::1] ", 8443)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !configured {
+ t.Fatalf("expected explicit target")
+ }
+ if target.Host != "2001:db8::1" || target.Port != 8443 {
+ t.Fatalf("unexpected normalized target: %+v", target)
+ }
+ if got := formatTunnelProbeTarget(target); got != "[2001:db8::1]:8443" {
+ t.Fatalf("unexpected formatted target: %s", got)
+ }
+}
+
+func TestNormalizeTunnelProbeTargetRejectsPartialAndInvalidInputs(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ port int
+ }{
+ {name: "missing host", host: "", port: 443},
+ {name: "missing port", host: "example.com", port: 0},
+ {name: "port too high", host: "example.com", port: 70000},
+ {name: "scheme", host: "https://example.com", port: 443},
+ {name: "path", host: "example.com/ping", port: 443},
+ {name: "space", host: "example .com", port: 443},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, _, err := normalizeTunnelProbeTarget(tt.host, tt.port); err == nil {
+ t.Fatalf("expected validation error")
+ }
+ })
+ }
+}
+
+func TestNormalizeTunnelProbeTargetRejectsSchemePrefixButAllowsIPv6(t *testing.T) {
+ for _, host := range []string{"https:example.com", "mailto:ops@example.com"} {
+ if _, _, err := normalizeTunnelProbeTarget(host, 443); err == nil {
+ t.Fatalf("expected scheme-like host %q to be rejected", host)
+ }
+ }
+
+ for _, host := range []string{"2001:db8::1", "[2001:db8::1]"} {
+ target, configured, err := normalizeTunnelProbeTarget(host, 443)
+ if err != nil {
+ t.Fatalf("expected IPv6 host %q to be accepted: %v", host, err)
+ }
+ if !configured || target.Host != "2001:db8::1" {
+ t.Fatalf("unexpected IPv6 normalization for %q: %+v configured=%v", host, target, configured)
+ }
+ }
+}
+
+func TestNormalizeTunnelProbeTargetValidatesHostShape(t *testing.T) {
+ validHosts := []string{
+ "example.com",
+ "localhost",
+ "api-1.example.co.uk",
+ "192.0.2.10",
+ "2001:db8::1",
+ "[2001:db8::1]",
+ }
+ for _, host := range validHosts {
+ if _, _, err := normalizeTunnelProbeTarget(host, 443); err != nil {
+ t.Fatalf("expected valid host %q: %v", host, err)
+ }
+ }
+
+ invalidHosts := []string{
+ "1:2:3",
+ "[2001:db8::1",
+ "2001:db8::1]",
+ "[example.com]",
+ "example..com",
+ "-example.com",
+ "example-.com",
+ "exa_mple.com",
+ "999.1.1.1",
+ }
+ for _, host := range invalidHosts {
+ if _, _, err := normalizeTunnelProbeTarget(host, 443); err == nil {
+ t.Fatalf("expected invalid host %q to be rejected", host)
+ }
+ }
+}
+
+func TestParseTunnelProbeTargetFromRequest(t *testing.T) {
+ req := map[string]interface{}{
+ "probeTargetHost": "speed.example.com",
+ "probeTargetPort": float64(1443),
+ }
+ target, configured, err := parseTunnelProbeTargetFromRequest(req)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !configured || target.Host != "speed.example.com" || target.Port != 1443 {
+ t.Fatalf("unexpected request target: %+v configured=%v", target, configured)
+ }
+}
diff --git a/go-backend/internal/http/handler/tunnel_quality_prober.go b/go-backend/internal/http/handler/tunnel_quality_prober.go
index 8a9fba9..0e2188f 100644
--- a/go-backend/internal/http/handler/tunnel_quality_prober.go
+++ b/go-backend/internal/http/handler/tunnel_quality_prober.go
@@ -42,6 +42,8 @@ type tunnelQualitySnapshot struct {
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
ChainDetails string `json:"chainDetails,omitempty"`
+ ProbeTargetHost string `json:"probeTargetHost,omitempty"`
+ ProbeTargetPort int `json:"probeTargetPort,omitempty"`
// internal fields for db reporting
lastDBWrite int64 `json:"-"`
@@ -57,6 +59,7 @@ type tunnelQualityProber struct {
interval time.Duration
lastPrune int64
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
+ probeNode bestExitProbeFunc
}
// newTunnelQualityProber creates a new prober (not yet running).
@@ -226,6 +229,9 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
p.storeResult(snap)
return
}
+ probeTarget := effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort)
+ snap.ProbeTargetHost = probeTarget.Host
+ snap.ProbeTargetPort = probeTarget.Port
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil || len(chainRows) == 0 {
@@ -242,13 +248,13 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
pingTimeoutMS: tunnelQualityPingTimeoutMs,
timeoutMessage: "探测超时",
}
- p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options)
+ p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget)
switch tunnel.Type {
case 1:
- // Port forwarding: entry → Bing only
+ // Port forwarding: entry → public probe target only.
if len(inNodes) > 0 {
- lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
+ lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
@@ -310,7 +316,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
hop.TargetIP = targetIP
hop.TargetPort = targetPort
- lat, loss, err := p.tcpPingNode(source.NodeID, targetIP, targetPort, options)
+ lat, loss, err := p.pingNode(source.NodeID, targetIP, targetPort, options)
if err == nil {
hop.Latency = lat
hop.Loss = loss
@@ -346,7 +352,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
// Exit → Bing
if len(outNodes) > 0 {
- lat, loss, err := p.tcpPingNode(outNodes[0].NodeID, "www.bing.com", 443, options)
+ lat, loss, err := p.pingNode(outNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
@@ -360,9 +366,9 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
snap.Success = probeOK
default:
- // Unknown type: entry → Bing
+ // Unknown type: entry → public probe target.
if len(inNodes) > 0 {
- lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
+ lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
@@ -376,7 +382,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
p.storeResult(snap)
}
-func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions) {
+func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) {
if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
return
}
@@ -400,14 +406,14 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai
}
// This best-exit decision cache is per decision round; the display-oriented
// tunnel quality snapshot may still collect its own first-exit public probe.
- roundPinger := newBestExitRoundPinger(p.tcpPingNode)
+ roundPinger := newBestExitRoundPinger(p.pingNode)
for _, owner := range owners {
if nodeMap[owner.NodeID] == nil {
continue
}
key := bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: owner.NodeID}
p.handler.bestExit.ensureApplied(key, outNodes[0].NodeID, time.Now())
- scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, roundPinger)
+ scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, probeTarget, roundPinger)
decision := p.handler.bestExit.observeScores(key, scores, time.Now())
if decision.Switch {
now := time.Now()
@@ -421,6 +427,13 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai
}
}
+func (p *tunnelQualityProber) pingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
+ if p != nil && p.probeNode != nil {
+ return p.probeNode(nodeID, ip, port, options)
+ }
+ return p.tcpPingNode(nodeID, ip, port, options)
+}
+
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
h := p.handler
if h == nil {
diff --git a/go-backend/internal/http/handler/tunnel_quality_prober_test.go b/go-backend/internal/http/handler/tunnel_quality_prober_test.go
new file mode 100644
index 0000000..0b93a29
--- /dev/null
+++ b/go-backend/internal/http/handler/tunnel_quality_prober_test.go
@@ -0,0 +1,69 @@
+package handler
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+ "time"
+)
+
+func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 77, "quality-target", "speed.example.com", 8443)
+ if err := h.repo.DB().Exec(`
+ INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
+ VALUES(30, 'exit-a', 'exit-secret', '10.0.0.30', '10.0.0.30', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
+ `, time.Now().UnixMilli(), time.Now().UnixMilli()).Error; err != nil {
+ t.Fatalf("insert exit node: %v", err)
+ }
+ if err := h.repo.DB().Exec(`
+ INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
+ VALUES(77, '3', 30, 30001, 'round', 1, 'tls')
+ `).Error; err != nil {
+ t.Fatalf("insert exit chain: %v", err)
+ }
+
+ p := newTunnelQualityProber(h)
+ var calls []string
+ p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
+ calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
+ return 10, 0, nil
+ }
+ p.probeTunnel(77)
+
+ if !slices.Contains(calls, "10|speed.example.com|8443") {
+ t.Fatalf("expected type 1 public probe from entry to configured target, calls=%+v", calls)
+ }
+ if slices.Contains(calls, "30|speed.example.com|8443") {
+ t.Fatalf("did not expect type 1 public probe from exit node, calls=%+v", calls)
+ }
+ snaps := p.GetAll()
+ if len(snaps) != 1 {
+ t.Fatalf("expected one quality snapshot, got %+v", snaps)
+ }
+ if snaps[0].ProbeTargetHost != "speed.example.com" || snaps[0].ProbeTargetPort != 8443 {
+ t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
+ }
+}
+
+func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
+ h := setupProbeTargetTunnelHandler(t)
+ seedProbeTargetTunnel(t, h, 78, "quality-target-incomplete", "speed.example.com", 8443)
+ if err := h.repo.DB().Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, 78).Error; err != nil {
+ t.Fatalf("delete chain rows: %v", err)
+ }
+
+ p := newTunnelQualityProber(h)
+ p.probeTunnel(78)
+
+ snaps := p.GetAll()
+ if len(snaps) != 1 {
+ t.Fatalf("expected one quality snapshot, got %+v", snaps)
+ }
+ if snaps[0].ErrorMessage == "" {
+ t.Fatalf("expected incomplete chain error, got %+v", snaps[0])
+ }
+ if snaps[0].ProbeTargetHost != "speed.example.com" || snaps[0].ProbeTargetPort != 8443 {
+ t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
+ }
+}
diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go
index 2bf5d41..965c6fe 100644
--- a/go-backend/internal/store/model/model.go
+++ b/go-backend/internal/store/model/model.go
@@ -119,18 +119,20 @@ type StatisticsFlow struct {
func (StatisticsFlow) TableName() string { return "statistics_flow" }
type Tunnel struct {
- ID int64 `gorm:"primaryKey;autoIncrement"`
- Name string `gorm:"type:varchar(100);not null"`
- TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
- Type int `gorm:"not null"`
- Protocol string `gorm:"type:varchar(10);not null;default:'tls'"`
- Flow int64 `gorm:"not null"`
- CreatedTime int64 `gorm:"column:created_time;not null"`
- UpdatedTime int64 `gorm:"column:updated_time;not null"`
- Status int `gorm:"not null"`
- InIP sql.NullString `gorm:"column:in_ip;type:text"`
- Inx int `gorm:"not null;default:0"`
- IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"`
+ ID int64 `gorm:"primaryKey;autoIncrement"`
+ Name string `gorm:"type:varchar(100);not null"`
+ TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
+ Type int `gorm:"not null"`
+ Protocol string `gorm:"type:varchar(10);not null;default:'tls'"`
+ Flow int64 `gorm:"not null"`
+ CreatedTime int64 `gorm:"column:created_time;not null"`
+ UpdatedTime int64 `gorm:"column:updated_time;not null"`
+ Status int `gorm:"not null"`
+ InIP sql.NullString `gorm:"column:in_ip;type:text"`
+ Inx int `gorm:"not null;default:0"`
+ IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"`
+ ProbeTargetHost string `gorm:"column:probe_target_host;type:text;not null;default:''"`
+ ProbeTargetPort int `gorm:"column:probe_target_port;not null;default:0"`
}
func (Tunnel) TableName() string { return "tunnel" }
@@ -403,19 +405,21 @@ type NodeBackup struct {
}
type TunnelBackup struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- TrafficRatio float64 `json:"trafficRatio"`
- Type int `json:"type"`
- Protocol string `json:"protocol"`
- Flow int64 `json:"flow"`
- CreatedTime int64 `json:"createdTime"`
- UpdatedTime int64 `json:"updatedTime"`
- Status int `json:"status"`
- InIP string `json:"inIp,omitempty"`
- Inx int `json:"inx"`
- IPPreference string `json:"ipPreference,omitempty"`
- ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ TrafficRatio float64 `json:"trafficRatio"`
+ Type int `json:"type"`
+ Protocol string `json:"protocol"`
+ Flow int64 `json:"flow"`
+ CreatedTime int64 `json:"createdTime"`
+ UpdatedTime int64 `json:"updatedTime"`
+ Status int `json:"status"`
+ InIP string `json:"inIp,omitempty"`
+ Inx int `json:"inx"`
+ IPPreference string `json:"ipPreference,omitempty"`
+ ProbeTargetHost string `json:"probeTargetHost,omitempty"`
+ ProbeTargetPort int `json:"probeTargetPort,omitempty"`
+ ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
}
type ChainTunnelBackup struct {
@@ -553,12 +557,14 @@ type ForwardRecord struct {
// TunnelRecord is a minimal tunnel view used by control plane.
type TunnelRecord struct {
- ID int64
- Type int
- Status int
- Flow int64
- TrafficRatio float64
- Protocol string
+ ID int64
+ Type int
+ Status int
+ Flow int64
+ TrafficRatio float64
+ Protocol string
+ ProbeTargetHost string
+ ProbeTargetPort int
}
type UserQuotaView struct {
diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go
index 22b3fff..4206ec8 100644
--- a/go-backend/internal/store/repo/repository.go
+++ b/go-backend/internal/store/repo/repository.go
@@ -385,7 +385,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
}
if m.HasTable(&model.Tunnel{}) {
- for _, field := range []string{"Inx", "IPPreference"} {
+ for _, field := range []string{"Inx", "IPPreference", "ProbeTargetHost", "ProbeTargetPort"} {
if m.HasColumn(&model.Tunnel{}, field) {
continue
}
@@ -1141,11 +1141,13 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
"id": t.ID, "inx": t.Inx, "name": t.Name,
"type": t.Type, "flow": t.Flow, "trafficRatio": t.TrafficRatio,
"status": t.Status, "createdTime": t.CreatedTime,
- "inIp": nullableString(t.InIP),
- "ipPreference": t.IPPreference,
- "inNodeId": make([]map[string]interface{}, 0),
- "outNodeId": make([]map[string]interface{}, 0),
- "chainNodes": make([][]map[string]interface{}, 0),
+ "inIp": nullableString(t.InIP),
+ "ipPreference": t.IPPreference,
+ "probeTargetHost": t.ProbeTargetHost,
+ "probeTargetPort": t.ProbeTargetPort,
+ "inNodeId": make([]map[string]interface{}, 0),
+ "outNodeId": make([]map[string]interface{}, 0),
+ "chainNodes": make([][]map[string]interface{}, 0),
}
orderedIDs = append(orderedIDs, t.ID)
}
@@ -2061,6 +2063,7 @@ func (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {
Type: t.Type, Protocol: t.Protocol, Flow: t.Flow,
CreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,
Status: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,
+ ProbeTargetHost: t.ProbeTargetHost, ProbeTargetPort: t.ProbeTargetPort,
}
if t.InIP.Valid {
b.InIP = t.InIP.String
@@ -2456,23 +2459,25 @@ func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, e
count := 0
for _, t := range tunnels {
item := model.Tunnel{
- ID: t.ID,
- Name: t.Name,
- TrafficRatio: t.TrafficRatio,
- Type: t.Type,
- Protocol: t.Protocol,
- Flow: t.Flow,
- CreatedTime: t.CreatedTime,
- UpdatedTime: now,
- Status: t.Status,
- InIP: sql.NullString{String: t.InIP, Valid: true},
- Inx: t.Inx,
- IPPreference: t.IPPreference,
+ ID: t.ID,
+ Name: t.Name,
+ TrafficRatio: t.TrafficRatio,
+ Type: t.Type,
+ Protocol: t.Protocol,
+ Flow: t.Flow,
+ CreatedTime: t.CreatedTime,
+ UpdatedTime: now,
+ Status: t.Status,
+ InIP: sql.NullString{String: t.InIP, Valid: true},
+ Inx: t.Inx,
+ IPPreference: t.IPPreference,
+ ProbeTargetHost: t.ProbeTargetHost,
+ ProbeTargetPort: t.ProbeTargetPort,
}
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
- "name", "traffic_ratio", "type", "protocol", "flow", "updated_time", "status", "in_ip", "inx", "ip_preference",
+ "name", "traffic_ratio", "type", "protocol", "flow", "updated_time", "status", "in_ip", "inx", "ip_preference", "probe_target_host", "probe_target_port",
}),
}).Create(&item).Error
if err != nil {
diff --git a/go-backend/internal/store/repo/repository_backup_test.go b/go-backend/internal/store/repo/repository_backup_test.go
new file mode 100644
index 0000000..e205459
--- /dev/null
+++ b/go-backend/internal/store/repo/repository_backup_test.go
@@ -0,0 +1,59 @@
+package repo
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) {
+ source, err := Open(filepath.Join(t.TempDir(), "source.db"))
+ if err != nil {
+ t.Fatalf("open source repo: %v", err)
+ }
+ defer source.Close()
+
+ now := time.Now().UnixMilli()
+ if err := source.DB().Exec(`
+ INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, probe_target_host, probe_target_port)
+ VALUES(20, 'backup-target', 1, 2, 'tls', 1, ?, ?, 1, '', 1, 'speed.example.com', 8443)
+ `, now, now).Error; err != nil {
+ t.Fatalf("insert source tunnel: %v", err)
+ }
+
+ backup, err := source.ExportAll()
+ if err != nil {
+ t.Fatalf("export backup: %v", err)
+ }
+ if len(backup.Tunnels) != 1 {
+ t.Fatalf("expected one exported tunnel, got %d", len(backup.Tunnels))
+ }
+ if backup.Tunnels[0].ProbeTargetHost != "speed.example.com" || backup.Tunnels[0].ProbeTargetPort != 8443 {
+ t.Fatalf("unexpected exported probe target: %+v", backup.Tunnels[0])
+ }
+
+ dest, err := Open(filepath.Join(t.TempDir(), "dest.db"))
+ if err != nil {
+ t.Fatalf("open dest repo: %v", err)
+ }
+ defer dest.Close()
+
+ result, err := dest.Import(backup, []string{"tunnels"})
+ if err != nil {
+ t.Fatalf("import backup: %v", err)
+ }
+ if result.TunnelsImported != 1 {
+ t.Fatalf("expected one imported tunnel, got %d", result.TunnelsImported)
+ }
+
+ items, err := dest.ListTunnels()
+ if err != nil {
+ t.Fatalf("list imported tunnels: %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("expected one imported tunnel item, got %d", len(items))
+ }
+ if items[0]["probeTargetHost"] != "speed.example.com" || items[0]["probeTargetPort"] != 8443 {
+ t.Fatalf("unexpected imported probe target: %+v", items[0])
+ }
+}
diff --git a/go-backend/internal/store/repo/repository_flow.go b/go-backend/internal/store/repo/repository_flow.go
index e67ca28..7393548 100644
--- a/go-backend/internal/store/repo/repository_flow.go
+++ b/go-backend/internal/store/repo/repository_flow.go
@@ -253,12 +253,14 @@ func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error
return nil, err
}
tr := model.TunnelRecord{
- ID: t.ID,
- Type: t.Type,
- Status: t.Status,
- Flow: t.Flow,
- TrafficRatio: t.TrafficRatio,
- Protocol: t.Protocol,
+ ID: t.ID,
+ Type: t.Type,
+ Status: t.Status,
+ Flow: t.Flow,
+ TrafficRatio: t.TrafficRatio,
+ Protocol: t.Protocol,
+ ProbeTargetHost: t.ProbeTargetHost,
+ ProbeTargetPort: t.ProbeTargetPort,
}
if tr.Flow <= 0 {
tr.Flow = 1
diff --git a/go-backend/internal/store/repo/repository_flow_batch_test.go b/go-backend/internal/store/repo/repository_flow_batch_test.go
index 81711ec..1f055ae 100644
--- a/go-backend/internal/store/repo/repository_flow_batch_test.go
+++ b/go-backend/internal/store/repo/repository_flow_batch_test.go
@@ -111,6 +111,33 @@ func TestGetFlowUploadForwardMetasKeepsForwardsWhenTunnelRowMissing(t *testing.T
}
}
+func TestGetTunnelRecordIncludesProbeTarget(t *testing.T) {
+ r, err := Open(filepath.Join(t.TempDir(), "tunnel-record-probe-target.db"))
+ if err != nil {
+ t.Fatalf("open repo: %v", err)
+ }
+ defer r.Close()
+
+ now := time.Now().UnixMilli()
+ if err := r.DB().Exec(`
+ INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, probe_target_host, probe_target_port)
+ VALUES(1, 't1', 1, 2, 'tls', 1, ?, ?, 1, NULL, 0, 'speed.example.com', 8443)
+ `, now, now).Error; err != nil {
+ t.Fatalf("insert tunnel: %v", err)
+ }
+
+ record, err := r.GetTunnelRecord(1)
+ if err != nil {
+ t.Fatalf("get tunnel record: %v", err)
+ }
+ if record == nil {
+ t.Fatalf("expected tunnel record")
+ }
+ if record.ProbeTargetHost != "speed.example.com" || record.ProbeTargetPort != 8443 {
+ t.Fatalf("unexpected probe target on record: %#v", record)
+ }
+}
+
func TestAddUserQuotaUsageBatchReturnsNormalizedViews(t *testing.T) {
r, err := Open(filepath.Join(t.TempDir(), "quota-batch.db"))
if err != nil {
diff --git a/go-backend/internal/store/repo/repository_migrate_test.go b/go-backend/internal/store/repo/repository_migrate_test.go
index f83fbd8..d1cea1f 100644
--- a/go-backend/internal/store/repo/repository_migrate_test.go
+++ b/go-backend/internal/store/repo/repository_migrate_test.go
@@ -3,6 +3,7 @@ package repo
import (
"database/sql"
"errors"
+ "path/filepath"
"strings"
"testing"
@@ -58,6 +59,64 @@ func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) {
}
}
+func TestOpenBackfillsSQLiteLegacyTunnelProbeTargetColumns(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "legacy.db")
+ db, err := gorm.Open(gsqlite.Open(dbPath), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ if err != nil {
+ t.Fatalf("open legacy sqlite: %v", err)
+ }
+
+ if err := db.Exec(`
+ CREATE TABLE tunnel (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name VARCHAR(100) NOT NULL,
+ traffic_ratio REAL NOT NULL DEFAULT 1.0,
+ type INTEGER NOT NULL,
+ protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
+ flow INTEGER NOT NULL,
+ created_time INTEGER NOT NULL,
+ updated_time INTEGER NOT NULL,
+ status INTEGER NOT NULL,
+ in_ip TEXT
+ )
+ `).Error; err != nil {
+ t.Fatalf("create legacy tunnel table: %v", err)
+ }
+ if err := db.Exec(`
+ INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip)
+ VALUES(1, 'legacy-tunnel', 1, 1, 'tls', 1, 1, 1, 1, '')
+ `).Error; err != nil {
+ t.Fatalf("insert legacy tunnel: %v", err)
+ }
+ if sqlDB, _ := db.DB(); sqlDB != nil {
+ _ = sqlDB.Close()
+ }
+
+ r, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open migrated sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = r.Close() })
+
+ m := r.DB().Migrator()
+ for _, field := range []string{"ProbeTargetHost", "ProbeTargetPort"} {
+ if !m.HasColumn(&model.Tunnel{}, field) {
+ t.Fatalf("expected tunnel.%s column to exist", field)
+ }
+ }
+
+ var host string
+ var port int
+ if err := r.DB().Raw(`SELECT probe_target_host, probe_target_port FROM tunnel WHERE id = 1`).Row().Scan(&host, &port); err != nil {
+ t.Fatalf("query probe target defaults: %v", err)
+ }
+ if host != "" || port != 0 {
+ t.Fatalf("expected default probe target empty/0, got %q/%d", host, port)
+ }
+}
+
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go
index 394ea86..54f2cdc 100644
--- a/go-backend/internal/store/repo/repository_mutations.go
+++ b/go-backend/internal/store/repo/repository_mutations.go
@@ -397,22 +397,24 @@ func (r *Repository) UpdateTunnelOrder(tunnelID int64, inx int, now int64) {
Updates(map[string]interface{}{"inx": inx, "updated_time": now}).Error
}
-func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, now int64) error {
+func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, probeTargetHost string, probeTargetPort int, now int64) error {
if tx == nil {
return errors.New("database unavailable")
}
return tx.Model(&model.Tunnel{}).
Where("id = ?", tunnelID).
Updates(map[string]interface{}{
- "name": name,
- "type": typeVal,
- "flow": flow,
- "traffic_ratio": trafficRatio,
- "status": status,
- "in_ip": nullStringFromInterface(inIP),
- "ip_preference": ipPreference,
- "protocol": protocol,
- "updated_time": now,
+ "name": name,
+ "type": typeVal,
+ "flow": flow,
+ "traffic_ratio": trafficRatio,
+ "status": status,
+ "in_ip": nullStringFromInterface(inIP),
+ "ip_preference": ipPreference,
+ "protocol": protocol,
+ "probe_target_host": probeTargetHost,
+ "probe_target_port": probeTargetPort,
+ "updated_time": now,
}).Error
}
@@ -1326,20 +1328,22 @@ func (r *Repository) BatchUpdateForwardStatus(ids []int64, status int) (int, int
return s, f
}
-func (r *Repository) CreateTunnelTx(tx *gorm.DB, name string, trafficRatio float64, typeVal int, flow int64, now int64, status int, inIP interface{}, inx int, ipPreference string) (int64, error) {
+func (r *Repository) CreateTunnelTx(tx *gorm.DB, name string, trafficRatio float64, typeVal int, flow int64, now int64, status int, inIP interface{}, inx int, ipPreference string, probeTargetHost string, probeTargetPort int) (int64, error) {
inIPVal := nullStringFromInterface(inIP)
tunnel := model.Tunnel{
- Name: name,
- TrafficRatio: trafficRatio,
- Type: typeVal,
- Protocol: "tls",
- Flow: flow,
- CreatedTime: now,
- UpdatedTime: now,
- Status: status,
- InIP: inIPVal,
- Inx: inx,
- IPPreference: ipPreference,
+ Name: name,
+ TrafficRatio: trafficRatio,
+ Type: typeVal,
+ Protocol: "tls",
+ Flow: flow,
+ CreatedTime: now,
+ UpdatedTime: now,
+ Status: status,
+ InIP: inIPVal,
+ Inx: inx,
+ IPPreference: ipPreference,
+ ProbeTargetHost: probeTargetHost,
+ ProbeTargetPort: probeTargetPort,
}
if err := tx.Create(&tunnel).Error; err != nil {
return 0, err
diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts
index 39cfcbd..cf9cddf 100644
--- a/vite-frontend/src/api/types.ts
+++ b/vite-frontend/src/api/types.ts
@@ -48,6 +48,8 @@ export interface TunnelApiItem {
trafficRatio?: number;
inIp?: string;
ipPreference?: string;
+ probeTargetHost?: string;
+ probeTargetPort?: number;
inNodeId?: TunnelChainNodePayload[];
outNodeId?: TunnelChainNodePayload[];
chainNodes?: TunnelChainNodePayload[][];
@@ -328,6 +330,8 @@ export interface TunnelMutationPayload {
trafficRatio?: number;
inIp?: string;
ipPreference?: string;
+ probeTargetHost?: string;
+ probeTargetPort?: number;
inNodeId?: TunnelChainNodePayload[];
outNodeId?: TunnelChainNodePayload[];
chainNodes?: TunnelChainNodePayload[][];
@@ -525,6 +529,8 @@ export interface TunnelQualityApiItem {
exitToBingLatency: number;
entryToExitLoss: number;
exitToBingLoss: number;
+ probeTargetHost?: string;
+ probeTargetPort?: number;
success: boolean;
errorMessage?: string;
timestamp: number;
diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx
index 59e9db3..b51aeeb 100644
--- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx
+++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx
@@ -63,6 +63,19 @@ const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY =
"monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
"monitorTunnelQualityEnabledChanged";
+const DEFAULT_PROBE_TARGET_LABEL = "www.bing.com:443";
+
+const probeTargetLabel = (quality?: TunnelQualityApiItem | null) => {
+ if (!quality?.probeTargetHost || !quality.probeTargetPort) {
+ return DEFAULT_PROBE_TARGET_LABEL;
+ }
+
+ const host = quality.probeTargetHost.includes(":")
+ ? `[${quality.probeTargetHost}]`
+ : quality.probeTargetHost;
+
+ return `${host}:${quality.probeTargetPort}`;
+};
const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts);
@@ -177,7 +190,7 @@ function UptimeHistoryBar({
}
const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-";
- const tooltip = `${timeStr} | ${displayLatency} | ${statusText}`;
+ const tooltip = `${timeStr} | ${displayLatency} | ${statusText} | 测试目标: ${probeTargetLabel(q)}`;
return (
-
+
- 出口 → Bing 延迟
+ 出口 → 测试目标 延迟
-
- 出口 → Bing 丢包
+
+ 出口 → 测试目标 丢包
0 ? "text-warning" : ""}`}
@@ -1054,6 +1073,9 @@ export function TunnelMonitorView({
实时隧道质量检测已关闭
>
)}
+
+ · 测试目标: {probeTargetLabel(quality)}
+
{quality?.timestamp && (
· 最近更新:{" "}
@@ -1148,6 +1170,7 @@ export function TunnelMonitorView({
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
+ const targetLabel = probeTargetLabel(quality);
return (
- 出口→Bing
+ 出口→测试目标
+
+
+ {targetLabel}
状态
名称
入口→出口
- 出口→Bing
+ 出口→测试目标
更新时间
{tunnels.map((tunnel) => {
const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1;
+ const targetLabel = probeTargetLabel(quality);
return (
-
+
+
+
+ {targetLabel}
+
+
{quality?.timestamp ? (
diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx
index c312dae..4a2550b 100644
--- a/vite-frontend/src/pages/tunnel.tsx
+++ b/vite-frontend/src/pages/tunnel.tsx
@@ -130,6 +130,8 @@ interface Tunnel {
flow: number; // 1: 单向, 2: 双向
trafficRatio: number;
ipPreference?: string;
+ probeTargetHost?: string;
+ probeTargetPort?: number;
bestExitState?: BestExitState;
status: number;
createdTime: string;
@@ -156,6 +158,8 @@ interface TunnelForm {
trafficRatio: number;
inIp: string; // 入口IP
ipPreference: string;
+ probeTargetHost?: string;
+ probeTargetPort?: number;
status: number;
}
@@ -584,6 +588,8 @@ export default function TunnelPage() {
.join("\n")
: "",
ipPreference: tunnel.ipPreference || "",
+ probeTargetHost: tunnel.probeTargetHost || "",
+ probeTargetPort: tunnel.probeTargetPort || 0,
status: tunnel.status,
});
setErrors({});
@@ -860,12 +866,18 @@ export default function TunnelPage() {
.map((ip) => ip.trim())
.filter((ip) => ip)
.join(",");
+ const probeTargetHost = (form.probeTargetHost || "").trim();
+ const probeTargetPort = probeTargetHost
+ ? Number(form.probeTargetPort || 0)
+ : 0;
const data = {
...form,
inIp: inIpString,
outNodeId: cleanedOutNodeId,
chainNodes: cleanedChainNodes,
+ probeTargetHost,
+ probeTargetPort,
};
const response = isEdit
@@ -2323,6 +2335,55 @@ export default function TunnelPage() {
)}
+
+
+
质量检测目标
+
+ 用于实时隧道质量检测和 best 最优出口评分,留空使用
+ www.bing.com:443
+
+
+
+
+ setForm((prev) => ({
+ ...prev,
+ probeTargetHost: e.target.value,
+ }))
+ }
+ />
+
+ setForm((prev) => ({
+ ...prev,
+ probeTargetPort: e.target.value
+ ? Number(e.target.value)
+ : 0,
+ }))
+ }
+ />
+
+
+
入口配置
diff --git a/vite-frontend/src/pages/tunnel/form.ts b/vite-frontend/src/pages/tunnel/form.ts
index c69ea75..18dc5ba 100644
--- a/vite-frontend/src/pages/tunnel/form.ts
+++ b/vite-frontend/src/pages/tunnel/form.ts
@@ -8,6 +8,8 @@ interface TunnelFormInput {
inNodeId: TunnelChainNode[];
outNodeId?: TunnelChainNode[];
trafficRatio: number;
+ probeTargetHost?: string;
+ probeTargetPort?: number;
}
interface TunnelNodeInput {
@@ -15,6 +17,96 @@ interface TunnelNodeInput {
status: number;
}
+const isValidProbeIPv4 = (host: string) => {
+ const parts = host.split(".");
+
+ return (
+ parts.length === 4 &&
+ parts.every((part) => {
+ if (!/^\d+$/.test(part)) {
+ return false;
+ }
+ if (part.length > 1 && part.startsWith("0")) {
+ return false;
+ }
+
+ const value = Number(part);
+
+ return value >= 0 && value <= 255;
+ })
+ );
+};
+
+const isIPv4LikeProbeHost = (host: string) =>
+ /^[0-9.]+$/.test(host) && host.includes(".");
+
+const isValidProbeIPv6 = (host: string) => {
+ let value = host;
+
+ if (host.startsWith("[") || host.endsWith("]")) {
+ if (!host.startsWith("[") || !host.endsWith("]")) {
+ return false;
+ }
+ value = host.slice(1, -1);
+ }
+
+ if (!value.includes(":") || value.includes("[") || value.includes("]")) {
+ return false;
+ }
+
+ try {
+ const url = new URL(`http://[${value}]`);
+
+ return url.hostname.length > 0;
+ } catch {
+ return false;
+ }
+};
+
+const isSchemeLikeProbeHost = (host: string) => {
+ if (isValidProbeIPv6(host)) {
+ return false;
+ }
+
+ const colonIndex = host.indexOf(":");
+
+ if (colonIndex <= 0) {
+ return false;
+ }
+
+ return /^[A-Za-z][A-Za-z0-9+.-]*$/.test(host.slice(0, colonIndex));
+};
+
+const isValidProbeDomain = (host: string) => {
+ if (!host || host.length > 253) {
+ return false;
+ }
+
+ return host.split(".").every((label) => {
+ if (
+ !label ||
+ label.length > 63 ||
+ label.startsWith("-") ||
+ label.endsWith("-")
+ ) {
+ return false;
+ }
+
+ return /^[A-Za-z0-9-]+$/.test(label);
+ });
+};
+
+const isValidProbeTargetHost = (host: string) => {
+ if (isValidProbeIPv6(host) || isValidProbeIPv4(host)) {
+ return true;
+ }
+ if (host.includes(":") || isIPv4LikeProbeHost(host)) {
+ return false;
+ }
+
+ return isValidProbeDomain(host);
+};
+
export const createTunnelFormDefaults = () => {
return {
name: "",
@@ -26,6 +118,8 @@ export const createTunnelFormDefaults = () => {
trafficRatio: 1.0,
inIp: "",
ipPreference: "",
+ probeTargetHost: "",
+ probeTargetPort: 0,
status: 1,
};
};
@@ -63,6 +157,31 @@ export const validateTunnelForm = (
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
}
+ const rawProbeHost = form.probeTargetHost || "";
+ const probeHost = rawProbeHost.trim();
+ const probePortInput = form.probeTargetPort;
+ const probePort = Number(probePortInput ?? 0);
+ const hasProbeHostInput = rawProbeHost.length > 0;
+ const hasProbePort = probePortInput != null && probePortInput !== 0;
+
+ if (hasProbeHostInput || hasProbePort) {
+ if (!probeHost) {
+ errors.probeTargetHost = "请输入测试目标 Host";
+ } else if (
+ probeHost.includes("://") ||
+ /[\s/?#]/.test(rawProbeHost) ||
+ isSchemeLikeProbeHost(probeHost)
+ ) {
+ errors.probeTargetHost = "Host 不能包含协议、端口、空格或路径";
+ } else if (!isValidProbeTargetHost(probeHost)) {
+ errors.probeTargetHost = "测试目标 Host 格式无效";
+ }
+
+ if (!Number.isInteger(probePort) || probePort < 1 || probePort > 65535) {
+ errors.probeTargetPort = "端口必须是 1-65535";
+ }
+ }
+
if (form.type === 2) {
if (!form.outNodeId || form.outNodeId.length === 0) {
errors.outNodeId = "请至少选择一个出口节点";