From ad4109594a200d894e2ff9dd826c43b6940a271a Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 22:14:02 +0800 Subject: [PATCH 01/21] docs: add custom best exit probe target design --- ...01-custom-best-exit-probe-target-design.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-custom-best-exit-probe-target-design.md 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`. From c2745dcd5682c2a1a2d8a23b1b9c4a78be9a4b7c Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 22:22:57 +0800 Subject: [PATCH 02/21] docs: add custom best exit probe target plan --- ...026-05-01-custom-best-exit-probe-target.md | 1083 +++++++++++++++++ 1 file changed, 1083 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-custom-best-exit-probe-target.md 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`. From cbed4eab235674133a0e7cdf6e818414af3b1fef Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 22:45:58 +0800 Subject: [PATCH 03/21] feat: add tunnel probe target normalization --- .../http/handler/tunnel_probe_target.go | 75 +++++++++++++++++++ .../http/handler/tunnel_probe_target_test.go | 69 +++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 go-backend/internal/http/handler/tunnel_probe_target.go create mode 100644 go-backend/internal/http/handler/tunnel_probe_target_test.go 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..80af291 --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_probe_target.go @@ -0,0 +1,75 @@ +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 defaultTunnelProbeTarget() +} + +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_test.go b/go-backend/internal/http/handler/tunnel_probe_target_test.go new file mode 100644 index 0000000..a39a2ed --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_probe_target_test.go @@ -0,0 +1,69 @@ +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) + } +} From a131b706137e88bc37d9940331a9e37dd53f7719 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 22:49:34 +0800 Subject: [PATCH 04/21] fix: reject probe target scheme prefixes --- .../http/handler/tunnel_probe_target.go | 33 ++++++++++++++++++- .../http/handler/tunnel_probe_target_test.go | 18 ++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/go-backend/internal/http/handler/tunnel_probe_target.go b/go-backend/internal/http/handler/tunnel_probe_target.go index 80af291..be8e234 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target.go +++ b/go-backend/internal/http/handler/tunnel_probe_target.go @@ -38,13 +38,44 @@ func normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, 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") { + if strings.Contains(host, "://") || isTunnelProbeTargetSchemeLikeHost(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 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 diff --git a/go-backend/internal/http/handler/tunnel_probe_target_test.go b/go-backend/internal/http/handler/tunnel_probe_target_test.go index a39a2ed..df0ecc9 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target_test.go +++ b/go-backend/internal/http/handler/tunnel_probe_target_test.go @@ -54,6 +54,24 @@ func TestNormalizeTunnelProbeTargetRejectsPartialAndInvalidInputs(t *testing.T) } } +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 TestParseTunnelProbeTargetFromRequest(t *testing.T) { req := map[string]interface{}{ "probeTargetHost": "speed.example.com", From 615d9e67ebe15a08b43a50cb8ff2c9cdd66abf78 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 22:53:37 +0800 Subject: [PATCH 05/21] fix: validate probe target host shape --- .../http/handler/tunnel_probe_target.go | 65 +++++++++++++++++-- .../http/handler/tunnel_probe_target_test.go | 33 ++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/go-backend/internal/http/handler/tunnel_probe_target.go b/go-backend/internal/http/handler/tunnel_probe_target.go index be8e234..6a03284 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target.go +++ b/go-backend/internal/http/handler/tunnel_probe_target.go @@ -25,10 +25,6 @@ func defaultTunnelProbeTarget() tunnelProbeTarget { 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 } @@ -38,13 +34,72 @@ func normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, if port <= 0 || port > 65535 { return tunnelProbeTarget{}, false, errors.New("测试目标端口必须是 1-65535") } - if strings.Contains(host, "://") || isTunnelProbeTargetSchemeLikeHost(host) || strings.ContainsAny(host, "/?#") || strings.ContainsAny(host, " \t\r\n") { + 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 diff --git a/go-backend/internal/http/handler/tunnel_probe_target_test.go b/go-backend/internal/http/handler/tunnel_probe_target_test.go index df0ecc9..ded971a 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target_test.go +++ b/go-backend/internal/http/handler/tunnel_probe_target_test.go @@ -72,6 +72,39 @@ func TestNormalizeTunnelProbeTargetRejectsSchemePrefixButAllowsIPv6(t *testing.T } } +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", From 90012725cca906e2e8aee4b387a04247bfbee214 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 22:58:47 +0800 Subject: [PATCH 06/21] feat: persist tunnel probe targets --- go-backend/internal/http/handler/mutations.go | 48 +++-- .../http/handler/tunnel_probe_target.go | 2 +- .../handler/tunnel_probe_target_api_test.go | 168 ++++++++++++++++++ go-backend/internal/store/model/model.go | 26 +-- go-backend/internal/store/repo/repository.go | 12 +- .../store/repo/repository_mutations.go | 48 ++--- 6 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 go-backend/internal/http/handler/tunnel_probe_target_api_test.go diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 463d6b8..21ed68d 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())) @@ -882,6 +895,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { now := time.Now().UnixMilli() 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 + } localDomain := h.federationLocalDomain() runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id) @@ -928,6 +952,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_probe_target.go b/go-backend/internal/http/handler/tunnel_probe_target.go index 6a03284..2110854 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target.go +++ b/go-backend/internal/http/handler/tunnel_probe_target.go @@ -142,7 +142,7 @@ func effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget { if tunnel == nil { return defaultTunnelProbeTarget() } - return defaultTunnelProbeTarget() + return effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort) } func effectiveTunnelProbeTargetValues(host string, port int) tunnelProbeTarget { 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..ef1cd56 --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go @@ -0,0 +1,168 @@ +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 +} diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 2bf5d41..31646b5 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" } diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 22b3fff..47a09e1 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -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) } 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 From 521fe79b15a7fe86f85ab20cb793cafbc4e84089 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:10:47 +0800 Subject: [PATCH 07/21] fix: validate tunnel probe target before cleanup --- go-backend/internal/http/handler/mutations.go | 18 +++---- .../handler/tunnel_probe_target_api_test.go | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 21ed68d..632d260 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -884,16 +884,7 @@ 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) - oldTunnel, _ := h.getTunnelRecord(id) - oldChainRows, _ := h.listChainNodesForTunnel(id) - if oldTunnel != nil && oldTunnel.Type == 2 && typeVal != 2 { - h.cleanupTunnelRuntime(id) - } - h.cleanupFederationRuntime(id) - - now := time.Now().UnixMilli() ipPreference := asString(req["ipPreference"]) probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req) if err != nil { @@ -906,6 +897,15 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { probeTargetHost = probeTarget.Host probeTargetPort = probeTarget.Port } + oldEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id) + oldTunnel, _ := h.getTunnelRecord(id) + oldChainRows, _ := h.listChainNodesForTunnel(id) + if oldTunnel != nil && oldTunnel.Type == 2 && typeVal != 2 { + h.cleanupTunnelRuntime(id) + } + h.cleanupFederationRuntime(id) + + now := time.Now().UnixMilli() localDomain := h.federationLocalDomain() runtimeState, err := h.prepareTunnelCreateState(h.repo.DB(), req, typeVal, id) 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 index ef1cd56..3072006 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target_api_test.go +++ b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go @@ -99,6 +99,42 @@ func TestTunnelCreateRejectsInvalidProbeTarget(t *testing.T) { } } +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")) @@ -134,6 +170,17 @@ func seedProbeTargetTunnel(t *testing.T, h *Handler, id int64, name string, host } } +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 { From 58ef260755b91b2fb4dd1adeb2a40ab43574d700 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:15:21 +0800 Subject: [PATCH 08/21] fix: migrate legacy tunnel probe target columns --- go-backend/internal/store/repo/repository.go | 2 +- .../store/repo/repository_migrate_test.go | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 47a09e1..dea9e02 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 } 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), From cc0b8de2e1adddac37d61b2c680eba92d0a713ec Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:20:50 +0800 Subject: [PATCH 09/21] feat: use probe target for best exit scoring --- .../internal/http/handler/tunnel_best_exit.go | 26 ++++++----- .../http/handler/tunnel_best_exit_test.go | 43 ++++++++++++++++--- .../http/handler/tunnel_quality_prober.go | 23 ++++++++-- 3 files changed, 72 insertions(+), 20 deletions(-) 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_quality_prober.go b/go-backend/internal/http/handler/tunnel_quality_prober.go index 8a9fba9..e1df91e 100644 --- a/go-backend/internal/http/handler/tunnel_quality_prober.go +++ b/go-backend/internal/http/handler/tunnel_quality_prober.go @@ -242,7 +242,8 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { pingTimeoutMS: tunnelQualityPingTimeoutMs, timeoutMessage: "探测超时", } - p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options) + probeTarget := p.probeTargetForTunnel(tunnelID) + p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget) switch tunnel.Type { case 1: @@ -376,7 +377,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 } @@ -407,7 +408,7 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai } 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 +422,22 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai } } +func (p *tunnelQualityProber) probeTargetForTunnel(tunnelID int64) tunnelProbeTarget { + if p == nil || p.handler == nil || p.handler.repo == nil { + return defaultTunnelProbeTarget() + } + tunnels, err := p.handler.repo.ListTunnels() + if err != nil { + return defaultTunnelProbeTarget() + } + for _, tunnel := range tunnels { + if asInt64(tunnel["id"], 0) == tunnelID { + return effectiveTunnelProbeTargetValues(asString(tunnel["probeTargetHost"]), asInt(tunnel["probeTargetPort"], 0)) + } + } + return defaultTunnelProbeTarget() +} + func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) { h := p.handler if h == nil { From a259dd83b2a0fc0a04e7b0d6ca106d65283d0639 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:24:48 +0800 Subject: [PATCH 10/21] fix: load probe target with tunnel record --- .../http/handler/tunnel_quality_prober.go | 18 +------------ go-backend/internal/store/model/model.go | 14 +++++----- .../internal/store/repo/repository_flow.go | 14 +++++----- .../store/repo/repository_flow_batch_test.go | 27 +++++++++++++++++++ 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/go-backend/internal/http/handler/tunnel_quality_prober.go b/go-backend/internal/http/handler/tunnel_quality_prober.go index e1df91e..f8adfe4 100644 --- a/go-backend/internal/http/handler/tunnel_quality_prober.go +++ b/go-backend/internal/http/handler/tunnel_quality_prober.go @@ -242,7 +242,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { pingTimeoutMS: tunnelQualityPingTimeoutMs, timeoutMessage: "探测超时", } - probeTarget := p.probeTargetForTunnel(tunnelID) + probeTarget := effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort) p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget) switch tunnel.Type { @@ -422,22 +422,6 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai } } -func (p *tunnelQualityProber) probeTargetForTunnel(tunnelID int64) tunnelProbeTarget { - if p == nil || p.handler == nil || p.handler.repo == nil { - return defaultTunnelProbeTarget() - } - tunnels, err := p.handler.repo.ListTunnels() - if err != nil { - return defaultTunnelProbeTarget() - } - for _, tunnel := range tunnels { - if asInt64(tunnel["id"], 0) == tunnelID { - return effectiveTunnelProbeTargetValues(asString(tunnel["probeTargetHost"]), asInt(tunnel["probeTargetPort"], 0)) - } - } - return defaultTunnelProbeTarget() -} - 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/store/model/model.go b/go-backend/internal/store/model/model.go index 31646b5..e5b0f59 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -555,12 +555,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_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 { From 55a931510b4258af568457a72d09aa59f42befaf Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:28:26 +0800 Subject: [PATCH 11/21] feat: use probe target for tunnel quality checks --- .../internal/http/handler/monitoring.go | 16 +++++++ .../http/handler/tunnel_quality_prober.go | 38 ++++++++++++---- .../handler/tunnel_quality_prober_test.go | 44 +++++++++++++++++++ 3 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 go-backend/internal/http/handler/tunnel_quality_prober_test.go 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/tunnel_quality_prober.go b/go-backend/internal/http/handler/tunnel_quality_prober.go index f8adfe4..bdc5683 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). @@ -243,13 +246,19 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { timeoutMessage: "探测超时", } probeTarget := effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort) + snap.ProbeTargetHost = probeTarget.Host + snap.ProbeTargetPort = probeTarget.Port p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget) switch tunnel.Type { case 1: - // Port forwarding: entry → Bing only - if len(inNodes) > 0 { - lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options) + // Port forwarding: exit/public edge → public probe target when available. + publicProbeNodes := inNodes + if len(outNodes) > 0 { + publicProbeNodes = outNodes + } + if len(publicProbeNodes) > 0 { + lat, loss, err := p.pingNode(publicProbeNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) if err == nil { snap.ExitToBingLatency = lat snap.ExitToBingLoss = loss @@ -311,7 +320,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 @@ -347,7 +356,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 @@ -361,9 +370,13 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { snap.Success = probeOK default: - // Unknown type: entry → Bing - if len(inNodes) > 0 { - lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options) + // Unknown type: use exit/public edge when available, otherwise entry. + publicProbeNodes := inNodes + if len(outNodes) > 0 { + publicProbeNodes = outNodes + } + if len(publicProbeNodes) > 0 { + lat, loss, err := p.pingNode(publicProbeNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) if err == nil { snap.ExitToBingLatency = lat snap.ExitToBingLoss = loss @@ -401,7 +414,7 @@ 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 @@ -422,6 +435,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..34f1e7b --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_quality_prober_test.go @@ -0,0 +1,44 @@ +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, "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]) + } +} From 869d726b7a3f05082508b2ecf82d850856830836 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:32:00 +0800 Subject: [PATCH 12/21] fix: preserve type one quality probe owner --- .../http/handler/tunnel_quality_prober.go | 26 ++++++----------- .../handler/tunnel_quality_prober_test.go | 29 +++++++++++++++++-- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/go-backend/internal/http/handler/tunnel_quality_prober.go b/go-backend/internal/http/handler/tunnel_quality_prober.go index bdc5683..0e2188f 100644 --- a/go-backend/internal/http/handler/tunnel_quality_prober.go +++ b/go-backend/internal/http/handler/tunnel_quality_prober.go @@ -229,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 { @@ -245,20 +248,13 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { pingTimeoutMS: tunnelQualityPingTimeoutMs, timeoutMessage: "探测超时", } - probeTarget := effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort) - snap.ProbeTargetHost = probeTarget.Host - snap.ProbeTargetPort = probeTarget.Port p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget) switch tunnel.Type { case 1: - // Port forwarding: exit/public edge → public probe target when available. - publicProbeNodes := inNodes - if len(outNodes) > 0 { - publicProbeNodes = outNodes - } - if len(publicProbeNodes) > 0 { - lat, loss, err := p.pingNode(publicProbeNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) + // Port forwarding: entry → public probe target only. + if len(inNodes) > 0 { + lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) if err == nil { snap.ExitToBingLatency = lat snap.ExitToBingLoss = loss @@ -370,13 +366,9 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) { snap.Success = probeOK default: - // Unknown type: use exit/public edge when available, otherwise entry. - publicProbeNodes := inNodes - if len(outNodes) > 0 { - publicProbeNodes = outNodes - } - if len(publicProbeNodes) > 0 { - lat, loss, err := p.pingNode(publicProbeNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) + // Unknown type: entry → public probe target. + if len(inNodes) > 0 { + lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) if err == nil { snap.ExitToBingLatency = lat snap.ExitToBingLoss = loss diff --git a/go-backend/internal/http/handler/tunnel_quality_prober_test.go b/go-backend/internal/http/handler/tunnel_quality_prober_test.go index 34f1e7b..0b93a29 100644 --- a/go-backend/internal/http/handler/tunnel_quality_prober_test.go +++ b/go-backend/internal/http/handler/tunnel_quality_prober_test.go @@ -31,8 +31,11 @@ func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) { } p.probeTunnel(77) - if !slices.Contains(calls, "30|speed.example.com|8443") { - t.Fatalf("expected exit probe to configured target, calls=%+v", calls) + 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 { @@ -42,3 +45,25 @@ func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) { 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]) + } +} From 42c5492c1dab8cc872aeebf34b659a89980baa38 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:36:14 +0800 Subject: [PATCH 13/21] feat: add tunnel probe target UI --- vite-frontend/src/api/types.ts | 2 + .../src/pages/node/tunnel-monitor-view.tsx | 45 +++++++++++--- vite-frontend/src/pages/tunnel.tsx | 61 +++++++++++++++++++ vite-frontend/src/pages/tunnel/form.ts | 19 ++++++ 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 39cfcbd..f69f7af 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -525,6 +525,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..3eec8a3 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 && ( · 最近更新:{" "} @@ -1201,9 +1223,12 @@ export function TunnelMonitorView({ />
-
+
- 出口→Bing + 出口→测试目标
状态 名称 入口→出口 - 出口→Bing + 出口→测试目标 更新时间 @@ -1294,7 +1319,7 @@ export function TunnelMonitorView({ type="entryToExit" /> - + 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..6c560fc 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 { @@ -26,6 +28,8 @@ export const createTunnelFormDefaults = () => { trafficRatio: 1.0, inIp: "", ipPreference: "", + probeTargetHost: "", + probeTargetPort: 0, status: 1, }; }; @@ -63,6 +67,21 @@ export const validateTunnelForm = ( errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)"; } + 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"; + } + } + if (form.type === 2) { if (!form.outNodeId || form.outNodeId.length === 0) { errors.outNodeId = "请至少选择一个出口节点"; From a923ec4785da73a6cc253d9cce44958b8a280b36 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:39:14 +0800 Subject: [PATCH 14/21] fix: validate probe target port input --- vite-frontend/src/pages/tunnel/form.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vite-frontend/src/pages/tunnel/form.ts b/vite-frontend/src/pages/tunnel/form.ts index 6c560fc..bb7b4f1 100644 --- a/vite-frontend/src/pages/tunnel/form.ts +++ b/vite-frontend/src/pages/tunnel/form.ts @@ -68,9 +68,11 @@ export const validateTunnelForm = ( } const probeHost = (form.probeTargetHost || "").trim(); - const probePort = Number(form.probeTargetPort || 0); + const probePortInput = form.probeTargetPort; + const probePort = Number(probePortInput ?? 0); + const hasProbePort = probePortInput != null && probePortInput !== 0; - if (probeHost || probePort > 0) { + if (probeHost || hasProbePort) { if (!probeHost) { errors.probeTargetHost = "请输入测试目标 Host"; } else if (probeHost.includes("://") || /[\s/?#]/.test(probeHost)) { From a40150b136fd78fe383dcd65ba845a5bfbab4da3 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:42:21 +0800 Subject: [PATCH 15/21] fix: type tunnel probe target payloads --- vite-frontend/src/api/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index f69f7af..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[][]; From 727ef56c672d570ade14436a4ff2dbc5f1c2f1bc Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:46:56 +0800 Subject: [PATCH 16/21] fix: improve probe target form feedback --- .../src/pages/node/tunnel-monitor-view.tsx | 33 +++++-- vite-frontend/src/pages/tunnel/form.ts | 97 ++++++++++++++++++- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx index 3eec8a3..b51aeeb 100644 --- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -1170,6 +1170,7 @@ export function TunnelMonitorView({ {tunnels.map((tunnel) => { const quality = qualityMap[tunnel.id]; const isEnabled = tunnel.status === 1; + const targetLabel = probeTargetLabel(quality); return (
-
+
出口→测试目标
+
+ {targetLabel} +
{ 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/form.ts b/vite-frontend/src/pages/tunnel/form.ts index bb7b4f1..e6b636c 100644 --- a/vite-frontend/src/pages/tunnel/form.ts +++ b/vite-frontend/src/pages/tunnel/form.ts @@ -17,6 +17,93 @@ 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; + } + + 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: "", @@ -75,8 +162,14 @@ export const validateTunnelForm = ( if (probeHost || hasProbePort) { if (!probeHost) { errors.probeTargetHost = "请输入测试目标 Host"; - } else if (probeHost.includes("://") || /[\s/?#]/.test(probeHost)) { - errors.probeTargetHost = "Host 不能包含协议、空格或路径"; + } else if ( + probeHost.includes("://") || + /[\s/?#]/.test(probeHost) || + isSchemeLikeProbeHost(probeHost) + ) { + errors.probeTargetHost = "Host 不能包含协议、端口、空格或路径"; + } else if (!isValidProbeTargetHost(probeHost)) { + errors.probeTargetHost = "测试目标 Host 格式无效"; } if (!Number.isInteger(probePort) || probePort < 1 || probePort > 65535) { From d97d91801d5e2c890cb9d3d7a189bbe327650969 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:50:41 +0800 Subject: [PATCH 17/21] fix: reject leading zero probe IPv4 --- vite-frontend/src/pages/tunnel/form.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vite-frontend/src/pages/tunnel/form.ts b/vite-frontend/src/pages/tunnel/form.ts index e6b636c..334527d 100644 --- a/vite-frontend/src/pages/tunnel/form.ts +++ b/vite-frontend/src/pages/tunnel/form.ts @@ -26,6 +26,9 @@ const isValidProbeIPv4 = (host: string) => { if (!/^\d+$/.test(part)) { return false; } + if (part.length > 1 && part.startsWith("0")) { + return false; + } const value = Number(part); From 0f3242bf1136dbea2a67a999037257d92cdd5b8d Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 23:54:31 +0800 Subject: [PATCH 18/21] fix: reject raw probe target whitespace --- vite-frontend/src/pages/tunnel/form.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vite-frontend/src/pages/tunnel/form.ts b/vite-frontend/src/pages/tunnel/form.ts index 334527d..18dc5ba 100644 --- a/vite-frontend/src/pages/tunnel/form.ts +++ b/vite-frontend/src/pages/tunnel/form.ts @@ -157,17 +157,19 @@ export const validateTunnelForm = ( errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)"; } - const probeHost = (form.probeTargetHost || "").trim(); + 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 (probeHost || hasProbePort) { + if (hasProbeHostInput || hasProbePort) { if (!probeHost) { errors.probeTargetHost = "请输入测试目标 Host"; } else if ( probeHost.includes("://") || - /[\s/?#]/.test(probeHost) || + /[\s/?#]/.test(rawProbeHost) || isSchemeLikeProbeHost(probeHost) ) { errors.probeTargetHost = "Host 不能包含协议、端口、空格或路径"; From 6033e39466c593454e95c37ca4a2bb1252999c65 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sat, 2 May 2026 00:02:23 +0800 Subject: [PATCH 19/21] fix: preserve probe targets in backups --- go-backend/internal/store/model/model.go | 28 +++++---- go-backend/internal/store/repo/repository.go | 29 +++++---- .../store/repo/repository_backup_test.go | 59 +++++++++++++++++++ 3 files changed, 90 insertions(+), 26 deletions(-) create mode 100644 go-backend/internal/store/repo/repository_backup_test.go diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index e5b0f59..965c6fe 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -405,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 { diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index dea9e02..4206ec8 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -2063,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 @@ -2458,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]) + } +} From 4c882d907b93625df3f8a63e1b8f1e5498cf07d1 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sat, 2 May 2026 00:08:24 +0800 Subject: [PATCH 20/21] fix: preserve probe targets on legacy updates --- go-backend/internal/http/handler/mutations.go | 25 +++++++++++------ .../handler/tunnel_probe_target_api_test.go | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 632d260..5ac689e 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -886,19 +886,28 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { } typeVal := asInt(req["type"], 1) ipPreference := asString(req["ipPreference"]) - probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req) - if err != nil { - response.WriteJSON(w, response.ErrDefault(err.Error())) - return - } + _, hasProbeTargetHost := req["probeTargetHost"] + _, hasProbeTargetPort := req["probeTargetPort"] + probeTargetFieldsPresent := hasProbeTargetHost || hasProbeTargetPort probeTargetHost := "" probeTargetPort := 0 - if probeTargetConfigured { - probeTargetHost = probeTarget.Host - probeTargetPort = probeTarget.Port + 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) 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 index 3072006..01f4e8b 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target_api_test.go +++ b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go @@ -74,6 +74,33 @@ func TestTunnelUpdatePersistsDefaultProbeTargetAsEmpty(t *testing.T) { } } +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 TestTunnelCreateRejectsInvalidProbeTarget(t *testing.T) { h := setupProbeTargetTunnelHandler(t) body := bytes.NewReader([]byte(`{ From 13764b4615d010671fa489e2038271bc63c5f368 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sat, 2 May 2026 00:12:41 +0800 Subject: [PATCH 21/21] fix: reject malformed probe target updates --- .../http/handler/tunnel_probe_target.go | 63 ++++++++++++++++++- .../handler/tunnel_probe_target_api_test.go | 47 ++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/go-backend/internal/http/handler/tunnel_probe_target.go b/go-backend/internal/http/handler/tunnel_probe_target.go index 2110854..d21200f 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target.go +++ b/go-backend/internal/http/handler/tunnel_probe_target.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/netip" + "strconv" "strings" "go-backend/internal/store/model" @@ -135,7 +136,67 @@ func parseTunnelProbeTargetFromRequest(req map[string]interface{}) (tunnelProbeT if req == nil { return defaultTunnelProbeTarget(), false, nil } - return normalizeTunnelProbeTarget(asString(req["probeTargetHost"]), asInt(req["probeTargetPort"], 0)) + 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 { 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 index 01f4e8b..159076f 100644 --- a/go-backend/internal/http/handler/tunnel_probe_target_api_test.go +++ b/go-backend/internal/http/handler/tunnel_probe_target_api_test.go @@ -101,6 +101,53 @@ func TestTunnelUpdateWithoutProbeTargetFieldsPreservesExistingTarget(t *testing. } } +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(`{