From 5d22d4cb068038d4da73f40ee84e971ffc363785 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Mon, 4 May 2026 17:18:19 +0800 Subject: [PATCH] fix: refine advanced settings layout --- .../plans/2026-05-01-best-exit-selection.md | 1077 +++++++++++++++++ vite-frontend/src/pages/node.tsx | 17 +- vite-frontend/src/pages/tunnel.tsx | 285 ++--- 3 files changed, 1232 insertions(+), 147 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-01-best-exit-selection.md diff --git a/docs/superpowers/plans/2026-05-01-best-exit-selection.md b/docs/superpowers/plans/2026-05-01-best-exit-selection.md new file mode 100644 index 0000000..6fe8773 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-best-exit-selection.md @@ -0,0 +1,1077 @@ +# Best Exit Selection 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:** Add a `best` multi-exit tunnel strategy that routes new connections through the currently best end-to-end exit without interrupting existing connections. + +**Architecture:** Store `best` in `chain_tunnel.strategy`, but render it to GOST as `fifo` plus panel-controlled node ordering. Extend the existing tunnel quality prober to score each chain owner node's candidate exits, debounce switch decisions, and send `UpdateChains` only when the best exit changes after confirmation. + +**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL, GOST v3 fork, WebSocket node commands, Vite/React/TypeScript, existing shadcn bridge components. + +--- + +## File Structure + +- Create: `go-backend/internal/http/handler/tunnel_best_exit.go` for constants, scoring helpers, in-memory decision state, and target ordering. +- Create: `go-backend/internal/http/handler/tunnel_best_exit_test.go` for scoring, debounce, cooldown, and ordering tests. +- Modify: `go-backend/internal/http/handler/handler.go` to initialize the best-exit manager on `Handler`. +- Modify: `go-backend/internal/http/handler/mutations.go` to render `best` as GOST `fifo` and apply best-exit ordering during initial tunnel runtime creation and normal tunnel updates. +- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go` to evaluate all `best` exit candidates and apply confirmed chain updates. +- Create: `go-gost/x/socket/chain_test.go` for safe `UpdateChains` replacement tests. +- Modify: `go-gost/x/socket/chain.go` so parse failures do not unregister the currently active chain. +- Modify: `vite-frontend/src/pages/tunnel.tsx` to add the `最优` strategy option and keep form comments aligned. + +Implementation must not create git commits unless the user explicitly requests them. If commits are requested later, commit at task boundaries. + +--- + +### Task 1: Add Best-Exit Scoring And Decision State + +**Files:** +- Create: `go-backend/internal/http/handler/tunnel_best_exit.go` +- Create: `go-backend/internal/http/handler/tunnel_best_exit_test.go` + +- [ ] **Step 1: Write failing scoring tests** + +Create `go-backend/internal/http/handler/tunnel_best_exit_test.go` with these tests: + +```go +package handler + +import ( + "testing" + "time" +) + +func TestBestExitScoreCombinesLatencyAndLoss(t *testing.T) { + exit := chainNodeRecord{NodeID: 30, NodeName: "exit-a"} + score := scoreBestExitCandidate(10, exit, 25, 2, 80, 3) + + if !score.Success { + t.Fatalf("expected successful score") + } + if score.OwnerNodeID != 10 || score.ExitNodeID != 30 { + t.Fatalf("unexpected owner/exit ids: %+v", score) + } + if score.TotalLatency != 105 { + t.Fatalf("expected total latency 105, got %v", score.TotalLatency) + } + if score.TotalLoss < 4.9 || score.TotalLoss > 5.0 { + t.Fatalf("expected combined loss about 4.94, got %v", score.TotalLoss) + } + if score.Score < 599 || score.Score > 600 { + t.Fatalf("expected score about 599, got %v", score.Score) + } +} + +func TestBestExitScorePenalizesLoss(t *testing.T) { + stable := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 80, 0, 80, 0) + lowLatencyLossy := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 10, 5, 10, 5) + + if !bestExitScoreLess(stable, lowLatencyLossy) { + t.Fatalf("expected stable exit to beat low-latency lossy exit: stable=%+v lossy=%+v", stable, lowLatencyLossy) + } +} + +func TestBestExitFailedCandidateSortsLast(t *testing.T) { + failed := failedBestExitCandidate(10, chainNodeRecord{NodeID: 30}, "dial timeout") + good := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 100, 0, 100, 0) + + scores := []bestExitCandidateScore{failed, good} + sortBestExitScores(scores) + + if scores[0].ExitNodeID != 31 || scores[1].ExitNodeID != 30 { + t.Fatalf("expected good score first and failed score last, got %+v", scores) + } +} + +func TestBestExitDecisionRequiresConfirmationsAndCooldown(t *testing.T) { + m := newBestExitManager() + key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10} + now := time.Unix(100, 0) + current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0) + candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0) + + m.setApplied(key, 30, now.Add(-time.Minute)) + + if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now); decision.Switch { + t.Fatalf("first observation should not switch: %+v", decision) + } + if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Second)); decision.Switch { + t.Fatalf("second observation should not switch: %+v", decision) + } + decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(2*time.Second)) + if !decision.Switch || decision.ExitNodeID != 31 { + t.Fatalf("third confirmed observation should switch to 31: %+v", decision) + } + + betterAgain := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 20, 0, 20, 0) + if decision := m.observeScores(key, []bestExitCandidateScore{betterAgain, candidate}, now.Add(3*time.Second)); decision.Switch { + t.Fatalf("cooldown should block immediate switch back: %+v", decision) + } +} + +func TestBestExitOrderingUsesAppliedDecision(t *testing.T) { + m := newBestExitManager() + key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10} + m.setApplied(key, 31, time.Unix(100, 0)) + targets := []tunnelRuntimeNode{ + {NodeID: 30, Strategy: tunnelStrategyBest}, + {NodeID: 31, Strategy: tunnelStrategyBest}, + {NodeID: 32, Strategy: tunnelStrategyBest}, + } + + ordered := m.orderTargets(key, targets) + if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 || ordered[2].NodeID != 32 { + t.Fatalf("unexpected order: %+v", ordered) + } + if targets[0].NodeID != 30 { + t.Fatalf("orderTargets mutated input: %+v", targets) + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBestExit' -count=1 +``` + +Expected: FAIL with undefined `scoreBestExitCandidate`, `failedBestExitCandidate`, `bestExitCandidateScore`, `sortBestExitScores`, `newBestExitManager`, `bestExitOwnerKey`, and `tunnelStrategyBest`. + +- [ ] **Step 3: Implement scoring and manager** + +Create `go-backend/internal/http/handler/tunnel_best_exit.go`: + +```go +package handler + +import ( + "sort" + "strings" + "sync" + "time" +) + +const ( + tunnelStrategyBest = "best" + bestExitRuntimeStrategy = "fifo" + bestExitPublicTargetHost = "www.bing.com" + bestExitPublicTargetPort = 443 + bestExitLossPenaltyMsPerPercent = 100.0 + bestExitConfirmationRounds = 3 + bestExitSwitchCooldown = 30 * time.Second + bestExitMinLatencyAdvantageMs = 20.0 + bestExitMinScoreAdvantageRatio = 0.15 +) + +type bestExitOwnerKey struct { + TunnelID int64 + OwnerNodeID int64 +} + +type bestExitCandidateScore struct { + OwnerNodeID int64 + ExitNodeID int64 + ExitName string + + OwnerToExitLatency float64 + ExitToBingLatency float64 + OwnerToExitLoss float64 + ExitToBingLoss float64 + TotalLatency float64 + TotalLoss float64 + Score float64 + Success bool + ErrorMessage string +} + +type bestExitSwitchDecision struct { + Switch bool + ExitNodeID int64 + Reason string + Scores []bestExitCandidateScore +} + +type bestExitDecision struct { + AppliedExitNodeID int64 + PendingExitNodeID int64 + PendingCount int + LastSwitchAt time.Time + LastReason string + Scores []bestExitCandidateScore +} + +type bestExitManager struct { + mu sync.Mutex + decisions map[bestExitOwnerKey]*bestExitDecision +} + +func newBestExitManager() *bestExitManager { + return &bestExitManager{decisions: make(map[bestExitOwnerKey]*bestExitDecision)} +} + +func isBestTunnelStrategy(strategy string) bool { + return strings.EqualFold(strings.TrimSpace(strategy), tunnelStrategyBest) +} + +func runtimeTunnelStrategy(strategy string) string { + if isBestTunnelStrategy(strategy) { + return bestExitRuntimeStrategy + } + return strategy +} + +func scoreBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, ownerLatency, ownerLoss, publicLatency, publicLoss float64) bestExitCandidateScore { + totalLatency := ownerLatency + publicLatency + totalLoss := combineLossPercent(ownerLoss, publicLoss) + return bestExitCandidateScore{ + OwnerNodeID: ownerNodeID, + ExitNodeID: exit.NodeID, + ExitName: exit.NodeName, + OwnerToExitLatency: ownerLatency, + ExitToBingLatency: publicLatency, + OwnerToExitLoss: ownerLoss, + ExitToBingLoss: publicLoss, + TotalLatency: totalLatency, + TotalLoss: totalLoss, + Score: totalLatency + totalLoss*bestExitLossPenaltyMsPerPercent, + Success: true, + } +} + +func failedBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, message string) bestExitCandidateScore { + return bestExitCandidateScore{ + OwnerNodeID: ownerNodeID, + ExitNodeID: exit.NodeID, + ExitName: exit.NodeName, + Success: false, + ErrorMessage: message, + } +} + +func combineLossPercent(a, b float64) float64 { + a = clampPercent(a) + b = clampPercent(b) + return (1 - (1-a/100.0)*(1-b/100.0)) * 100.0 +} + +func clampPercent(v float64) float64 { + if v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v +} + +func sortBestExitScores(scores []bestExitCandidateScore) { + sort.SliceStable(scores, func(i, j int) bool { + return bestExitScoreLess(scores[i], scores[j]) + }) +} + +func bestExitScoreLess(a, b bestExitCandidateScore) bool { + if a.Success != b.Success { + return a.Success + } + if !a.Success && !b.Success { + return a.ExitNodeID < b.ExitNodeID + } + if a.Score != b.Score { + return a.Score < b.Score + } + return a.ExitNodeID < b.ExitNodeID +} + +func bestExitHasMinimumAdvantage(candidate, current bestExitCandidateScore) bool { + if !candidate.Success { + return false + } + if !current.Success { + return true + } + improvement := current.Score - candidate.Score + threshold := current.Score * bestExitMinScoreAdvantageRatio + if threshold < bestExitMinLatencyAdvantageMs { + threshold = bestExitMinLatencyAdvantageMs + } + return improvement >= threshold +} + +func (m *bestExitManager) setApplied(key bestExitOwnerKey, exitNodeID int64, at time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + d := m.decisionLocked(key) + d.AppliedExitNodeID = exitNodeID + d.PendingExitNodeID = 0 + d.PendingCount = 0 + d.LastSwitchAt = at +} + +func (m *bestExitManager) observeScores(key bestExitOwnerKey, scores []bestExitCandidateScore, now time.Time) bestExitSwitchDecision { + m.mu.Lock() + defer m.mu.Unlock() + + ordered := append([]bestExitCandidateScore(nil), scores...) + sortBestExitScores(ordered) + d := m.decisionLocked(key) + d.Scores = ordered + + if len(ordered) == 0 || !ordered[0].Success { + d.LastReason = "all exits failed" + return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered} + } + + candidate := ordered[0] + if d.AppliedExitNodeID == 0 { + d.AppliedExitNodeID = candidate.ExitNodeID + d.LastSwitchAt = now + d.LastReason = "initial best exit" + return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered} + } + if candidate.ExitNodeID == d.AppliedExitNodeID { + d.PendingExitNodeID = 0 + d.PendingCount = 0 + d.LastReason = "current exit remains best" + return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered} + } + if now.Sub(d.LastSwitchAt) < bestExitSwitchCooldown { + d.LastReason = "cooldown" + return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered} + } + + current := findBestExitScore(ordered, d.AppliedExitNodeID) + if !bestExitHasMinimumAdvantage(candidate, current) { + d.PendingExitNodeID = 0 + d.PendingCount = 0 + d.LastReason = "insufficient advantage" + return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered} + } + + if d.PendingExitNodeID != candidate.ExitNodeID { + d.PendingExitNodeID = candidate.ExitNodeID + d.PendingCount = 1 + d.LastReason = "candidate pending confirmation" + return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered} + } + d.PendingCount++ + if d.PendingCount < bestExitConfirmationRounds { + d.LastReason = "candidate pending confirmation" + return bestExitSwitchDecision{Reason: d.LastReason, Scores: ordered} + } + + d.AppliedExitNodeID = candidate.ExitNodeID + d.PendingExitNodeID = 0 + d.PendingCount = 0 + d.LastSwitchAt = now + d.LastReason = "switch confirmed" + return bestExitSwitchDecision{Switch: true, ExitNodeID: candidate.ExitNodeID, Reason: d.LastReason, Scores: ordered} +} + +func findBestExitScore(scores []bestExitCandidateScore, exitNodeID int64) bestExitCandidateScore { + for _, score := range scores { + if score.ExitNodeID == exitNodeID { + return score + } + } + return failedBestExitCandidate(0, chainNodeRecord{NodeID: exitNodeID}, "current exit has no successful score") +} + +func (m *bestExitManager) decisionLocked(key bestExitOwnerKey) *bestExitDecision { + if d := m.decisions[key]; d != nil { + return d + } + d := &bestExitDecision{} + m.decisions[key] = d + return d +} + +func (m *bestExitManager) orderTargets(key bestExitOwnerKey, targets []tunnelRuntimeNode) []tunnelRuntimeNode { + out := append([]tunnelRuntimeNode(nil), targets...) + if m == nil || len(out) <= 1 { + return out + } + m.mu.Lock() + applied := int64(0) + if d := m.decisions[key]; d != nil { + applied = d.AppliedExitNodeID + } + m.mu.Unlock() + if applied <= 0 { + return out + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].NodeID == applied { + return true + } + if out[j].NodeID == applied { + return false + } + return false + }) + return out +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBestExit' -count=1 +``` + +Expected: PASS. + +--- + +### Task 2: Render `best` As FIFO With Applied Ordering + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` +- Modify: `go-backend/internal/http/handler/mutations.go` +- Modify: `go-backend/internal/http/handler/tunnel_best_exit_test.go` + +- [ ] **Step 1: Write failing runtime rendering tests** + +Append to `go-backend/internal/http/handler/tunnel_best_exit_test.go`: + +```go +func TestBuildTunnelChainConfigMapsBestStrategyToFIFO(t *testing.T) { + nodes := map[int64]*nodeRecord{ + 10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"}, + 30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"}, + 31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"}, + } + targets := []tunnelRuntimeNode{ + {NodeID: 30, Port: 30030, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3}, + {NodeID: 31, Port: 30031, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3}, + } + + chainData, err := buildTunnelChainConfig(77, 10, targets, nodes, "") + if err != nil { + t.Fatalf("build chain: %v", err) + } + hops := chainData["hops"].([]map[string]interface{}) + selector := hops[0]["selector"].(map[string]interface{}) + if selector["strategy"] != bestExitRuntimeStrategy { + t.Fatalf("expected best to render as fifo, got %v", selector["strategy"]) + } +} + +func TestHandlerOrdersBestExitTargetsForOwner(t *testing.T) { + h := &Handler{bestExit: newBestExitManager()} + key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10} + h.bestExit.setApplied(key, 31, time.Unix(100, 0)) + targets := []tunnelRuntimeNode{ + {NodeID: 30, Port: 30030, Strategy: tunnelStrategyBest}, + {NodeID: 31, Port: 30031, Strategy: tunnelStrategyBest}, + } + + ordered := h.orderBestExitTargets(77, 10, targets) + if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 { + t.Fatalf("unexpected ordered targets: %+v", ordered) + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBuildTunnelChainConfigMapsBestStrategyToFIFO|TestHandlerOrdersBestExitTargetsForOwner' -count=1 +``` + +Expected: FAIL because `best` still renders as `best`, `Handler.bestExit` does not exist, and `orderBestExitTargets` does not exist. + +- [ ] **Step 3: Initialize best-exit manager** + +In `go-backend/internal/http/handler/handler.go`, add the field to `Handler`: + +```go + bestExit *bestExitManager +``` + +In `New`, initialize it in the struct literal: + +```go + bestExit: newBestExitManager(), +``` + +- [ ] **Step 4: Add ordering helper and render strategy mapping** + +In `go-backend/internal/http/handler/mutations.go`, add this method near `buildTunnelChainConfig`: + +```go +func (h *Handler) orderBestExitTargets(tunnelID, ownerNodeID int64, targets []tunnelRuntimeNode) []tunnelRuntimeNode { + if len(targets) <= 1 || !isBestTunnelStrategy(targets[0].Strategy) { + return append([]tunnelRuntimeNode(nil), targets...) + } + if h == nil || h.bestExit == nil { + return append([]tunnelRuntimeNode(nil), targets...) + } + return h.bestExit.orderTargets(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}, targets) +} +``` + +In `buildTunnelChainConfig`, replace: + +```go + strategy := defaultString(strings.TrimSpace(targets[0].Strategy), "round") +``` + +with: + +```go + strategy := runtimeTunnelStrategy(defaultString(strings.TrimSpace(targets[0].Strategy), "round")) +``` + +In `applyTunnelRuntimeWithMode`, order targets before each `buildTunnelChainConfig` call. For entry nodes, replace the first chain build block with: + +```go + for _, inNode := range state.InNodes { + targets := state.OutNodes + if len(state.ChainHops) > 0 { + targets = state.ChainHops[0] + } else { + targets = h.orderBestExitTargets(state.TunnelID, inNode.NodeID, targets) + } + chainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference) +``` + +For middle hop nodes, replace the `nextTargets` selection block with: + +```go + nextTargets := state.OutNodes + if i+1 < len(state.ChainHops) { + nextTargets = state.ChainHops[i+1] + } else { + nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets) + } +``` + +- [ ] **Step 5: Run tests** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBestExit|TestBuildTunnelChainConfigMapsBestStrategyToFIFO|TestHandlerOrdersBestExitTargetsForOwner' -count=1 +``` + +Expected: PASS. + +--- + +### Task 3: Evaluate Best Exit Candidates In The Quality Prober + +**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 candidate evaluation tests** + +Append to `go-backend/internal/http/handler/tunnel_best_exit_test.go`: + +```go +func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) { + owner := chainNodeRecord{NodeID: 10, NodeName: "entry"} + exits := []chainNodeRecord{ + {NodeID: 30, NodeName: "exit-a", Port: 30030}, + {NodeID: 31, NodeName: "exit-b", Port: 30031}, + } + nodes := map[int64]*nodeRecord{ + 10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"}, + 30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"}, + 31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"}, + } + pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) { + switch { + case nodeID == 10 && port == 30030: + return 60, 0, nil + case nodeID == 10 && port == 30031: + return 20, 0, nil + case nodeID == 30 && ip == bestExitPublicTargetHost: + return 60, 0, nil + case nodeID == 31 && ip == bestExitPublicTargetHost: + return 20, 0, nil + default: + t.Fatalf("unexpected ping node=%d ip=%s port=%d", nodeID, ip, port) + return 0, 100, nil + } + } + + scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger) + if len(scores) != 2 { + t.Fatalf("expected two scores, got %+v", scores) + } + if scores[0].ExitNodeID != 31 { + t.Fatalf("expected exit-b first, got %+v", scores) + } +} + +func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testing.T) { + owner := chainNodeRecord{NodeID: 10, NodeName: "entry"} + exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}} + nodes := map[int64]*nodeRecord{ + 10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"}, + 30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"}, + } + pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) { + return 0, 100, errBestExitProbeForTest + } + + scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger) + if len(scores) != 1 || scores[0].Success { + t.Fatalf("expected failed candidate, got %+v", scores) + } +} +``` + +Add imports to the test file: + +```go +import ( + "errors" + "testing" + "time" +) + +var errBestExitProbeForTest = errors.New("probe failed") +``` + +If the file already has an import block from Task 1, merge `errors` into that import block and place `var errBestExitProbeForTest` after imports. + +- [ ] **Step 2: Run tests to verify failure** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestEvaluateBestExitOwner' -count=1 +``` + +Expected: FAIL with undefined `evaluateBestExitOwner`. + +- [ ] **Step 3: Implement candidate evaluation helpers** + +Append to `go-backend/internal/http/handler/tunnel_best_exit.go`: + +```go +type bestExitProbeFunc func(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) + +func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, ping bestExitProbeFunc) []bestExitCandidateScore { + scores := make([]bestExitCandidateScore, 0, len(exits)) + if owner.NodeID <= 0 || len(exits) == 0 || ping == nil { + return scores + } + ownerNode := nodes[owner.NodeID] + for _, exit := range exits { + exitNode := nodes[exit.NodeID] + if exitNode == nil { + scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "exit node unavailable")) + continue + } + targetIP, targetPort, resolveErr := resolveChainProbeTarget(ownerNode, exitNode, exit.Port, ipPreference, exit.ConnectIP) + if resolveErr != nil { + scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, resolveErr.Error())) + continue + } + ownerLatency, ownerLoss, ownerErr := ping(owner.NodeID, targetIP, targetPort, options) + if ownerErr != nil { + scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, ownerErr.Error())) + continue + } + publicLatency, publicLoss, publicErr := ping(exit.NodeID, bestExitPublicTargetHost, bestExitPublicTargetPort, options) + if publicErr != nil { + scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, publicErr.Error())) + continue + } + scores = append(scores, scoreBestExitCandidate(owner.NodeID, exit, ownerLatency, ownerLoss, publicLatency, publicLoss)) + } + sortBestExitScores(scores) + return scores +} + +func bestExitChainOwners(inNodes []chainNodeRecord, chainHops [][]chainNodeRecord) []chainNodeRecord { + if len(chainHops) == 0 { + return inNodes + } + return chainHops[len(chainHops)-1] +} +``` + +- [ ] **Step 4: Wire evaluation into the quality prober** + +In `go-backend/internal/http/handler/tunnel_quality_prober.go`, after `inNodes, midNodesGrouped, outNodes := splitChainNodeGroups(chainRows)`, call a new method for `best` tunnels: + +```go + p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options) +``` + +Append these methods to `tunnel_quality_prober.go`: + +```go +func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions) { + if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 { + return + } + if !isBestTunnelStrategy(outNodes[0].Strategy) { + return + } + owners := bestExitChainOwners(inNodes, chainHops) + if len(owners) == 0 { + return + } + nodeMap := make(map[int64]*nodeRecord, len(owners)+len(outNodes)) + for _, owner := range owners { + if node, err := p.handler.getNodeRecord(owner.NodeID); err == nil && node != nil { + nodeMap[owner.NodeID] = node + } + } + for _, exit := range outNodes { + if node, err := p.handler.getNodeRecord(exit.NodeID); err == nil && node != nil { + nodeMap[exit.NodeID] = node + } + } + for _, owner := range owners { + if nodeMap[owner.NodeID] == nil { + continue + } + scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, p.tcpPingNode) + decision := p.handler.bestExit.observeScores(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: owner.NodeID}, scores, time.Now()) + if decision.Switch { + p.handler.applyBestExitChainOrder(tunnelID, owner.NodeID, outNodes, decision.Scores, ipPreference) + } + } +} +``` + +Append this method to `go-backend/internal/http/handler/mutations.go` near `applyTunnelChainOnNode`: + +```go +func (h *Handler) applyBestExitChainOrder(tunnelID, ownerNodeID int64, outNodes []chainNodeRecord, scores []bestExitCandidateScore, ipPreference string) { + if h == nil || tunnelID <= 0 || ownerNodeID <= 0 || len(outNodes) == 0 { + return + } + targets := chainRecordsToRuntimeTargets(outNodes) + orderedIDs := make([]int64, 0, len(scores)) + for _, score := range scores { + if score.ExitNodeID > 0 { + orderedIDs = append(orderedIDs, score.ExitNodeID) + } + } + targets = orderRuntimeTargetsByNodeID(targets, orderedIDs) + nodes := make(map[int64]*nodeRecord, len(targets)+1) + if owner, err := h.getNodeRecord(ownerNodeID); err == nil && owner != nil { + nodes[ownerNodeID] = owner + } + for _, target := range targets { + if node, err := h.getNodeRecord(target.NodeID); err == nil && node != nil { + nodes[target.NodeID] = node + } + } + chainData, err := buildTunnelChainConfig(tunnelID, ownerNodeID, targets, nodes, ipPreference) + if err != nil { + log.Printf("best_exit: build chain failed tunnel=%d owner=%d err=%v", tunnelID, ownerNodeID, err) + return + } + if err := h.applyTunnelChainOnNode(ownerNodeID, chainData, true); err != nil { + log.Printf("best_exit: update chain failed tunnel=%d owner=%d err=%v", tunnelID, ownerNodeID, err) + return + } + log.Printf("best_exit: updated chain tunnel=%d owner=%d best_exit=%d", tunnelID, ownerNodeID, targets[0].NodeID) +} +``` + +Add helper functions in `tunnel_best_exit.go`: + +```go +func chainRecordsToRuntimeTargets(rows []chainNodeRecord) []tunnelRuntimeNode { + out := make([]tunnelRuntimeNode, 0, len(rows)) + for _, row := range rows { + out = append(out, tunnelRuntimeNode{ + NodeID: row.NodeID, + Protocol: row.Protocol, + Strategy: row.Strategy, + Inx: int(row.Inx), + ChainType: row.ChainType, + Port: row.Port, + ConnectIP: row.ConnectIP, + }) + } + return out +} + +func orderRuntimeTargetsByNodeID(targets []tunnelRuntimeNode, orderedIDs []int64) []tunnelRuntimeNode { + out := append([]tunnelRuntimeNode(nil), targets...) + if len(out) <= 1 || len(orderedIDs) == 0 { + return out + } + positions := make(map[int64]int, len(orderedIDs)) + for i, id := range orderedIDs { + if _, ok := positions[id]; !ok { + positions[id] = i + } + } + sort.SliceStable(out, func(i, j int) bool { + pi, iok := positions[out[i].NodeID] + pj, jok := positions[out[j].NodeID] + if iok != jok { + return iok + } + if iok && jok && pi != pj { + return pi < pj + } + return false + }) + return out +} +``` + +Add `log` to `mutations.go` imports if it is not already present. + +- [ ] **Step 5: Run focused tests** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBestExit|TestEvaluateBestExitOwner|TestBuildTunnelChainConfigMapsBestStrategyToFIFO|TestHandlerOrdersBestExitTargetsForOwner' -count=1 +``` + +Expected: PASS. + +--- + +### Task 4: Preserve Existing Chain On Agent Update Parse Failure + +**Files:** +- Create: `go-gost/x/socket/chain_test.go` +- Modify: `go-gost/x/socket/chain.go` + +- [ ] **Step 1: Write failing agent tests** + +Create `go-gost/x/socket/chain_test.go`: + +```go +package socket + +import ( + "testing" + + corelogger "github.com/go-gost/core/logger" + "github.com/go-gost/x/config" + xlogger "github.com/go-gost/x/logger" + "github.com/go-gost/x/registry" +) + +func TestUpdateChainParseFailureKeepsExistingChainRegistered(t *testing.T) { + corelogger.SetDefault(xlogger.Nop()) + + name := "chain_update_parse_failure_tdd" + originalConfig := config.Global() + defer config.Set(originalConfig) + registry.ChainRegistry().Unregister(name) + defer registry.ChainRegistry().Unregister(name) + config.Set(&config.Config{}) + + valid := config.ChainConfig{ + Name: name, + Hops: []*config.HopConfig{{ + Name: "hop-valid", + Nodes: []*config.NodeConfig{{ + Name: "node-valid", + Addr: "127.0.0.1:443", + Connector: &config.ConnectorConfig{Type: "relay"}, + Dialer: &config.DialerConfig{Type: "tcp"}, + }}, + }}, + } + if err := createChain(createChainRequest{Data: valid}); err != nil { + t.Fatalf("create valid chain: %v", err) + } + before := registry.ChainRegistry().Get(name) + if before == nil { + t.Fatalf("expected chain registered before update") + } + + invalid := config.ChainConfig{ + Hops: []*config.HopConfig{{ + Name: "hop-invalid", + Nodes: []*config.NodeConfig{{ + Name: "node-invalid", + Addr: "127.0.0.1:443", + Connector: &config.ConnectorConfig{Type: "connector-does-not-exist"}, + Dialer: &config.DialerConfig{Type: "tcp"}, + }}, + }}, + } + err := updateChain(updateChainRequest{Chain: name, Data: invalid}) + if err == nil { + t.Fatalf("expected invalid chain update to fail") + } + after := registry.ChainRegistry().Get(name) + if after == nil { + t.Fatalf("expected old chain to remain registered after failed update") + } +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run from `go-gost`: + +```bash +go test ./x/socket -run TestUpdateChainParseFailureKeepsExistingChainRegistered -count=1 +``` + +Expected: FAIL because the old chain is unregistered before parsing the invalid replacement. + +- [ ] **Step 3: Parse before unregistering** + +In `go-gost/x/socket/chain.go`, replace `updateChain` with: + +```go +func updateChain(req updateChainRequest) error { + name := strings.TrimSpace(req.Chain) + if name == "" { + name = strings.TrimSpace(req.Data.Name) + } + if name == "" { + return errors.New("chain name is required") + } + + req.Data.Name = name + v, err := parser.ParseChain(&req.Data, logger.Default()) + if err != nil { + return errors.New("create chain " + name + " failed: " + err.Error()) + } + + if registry.ChainRegistry().IsRegistered(name) { + registry.ChainRegistry().Unregister(name) + } + if err := registry.ChainRegistry().Register(name, v); err != nil { + return errors.New("chain " + name + " already exists") + } + + return config.OnUpdate(func(c *config.Config) error { + found := false + for i := range c.Chains { + if c.Chains[i].Name == name { + c.Chains[i] = &req.Data + found = true + break + } + } + if !found { + c.Chains = append(c.Chains, &req.Data) + } + return nil + }) +} +``` + +- [ ] **Step 4: Run socket tests** + +Run from `go-gost`: + +```bash +go test ./x/socket -run 'TestUpdateChainParseFailureKeepsExistingChainRegistered|TestUpdateServicesSkipsUnchangedServiceWithoutRestart|TestCreateConnLimiterUpdatesGlobalConfig' -count=1 +``` + +Expected: PASS. + +--- + +### Task 5: Add Frontend `最优` Strategy Option + +**Files:** +- Modify: `vite-frontend/src/pages/tunnel.tsx` + +- [ ] **Step 1: Update tunnel strategy comment** + +In `vite-frontend/src/pages/tunnel.tsx`, change the `ChainTunnel.strategy` comment to include `best`: + +```ts + strategy?: string; // 'fifo' | 'round' | 'rand' | 'best' - 仅转发链/多出口需要 +``` + +- [ ] **Step 2: Add the select item** + +In the exit “负载策略” selector, change the options block to: + +```tsx + 主备 + 轮询 + 随机 + 最优 +``` + +- [ ] **Step 3: Build frontend** + +Run from `vite-frontend`: + +```bash +pnpm run build +``` + +Expected: PASS with `tsc && vite build` completing successfully. + +--- + +### Task 6: Full Verification + +**Files:** +- Verify only. + +- [ ] **Step 1: Run backend tests** + +Run from `go-backend`: + +```bash +go test ./... +``` + +Expected: PASS. Investigate any failure before continuing. + +- [ ] **Step 2: Run agent tests** + +Run from `go-gost`: + +```bash +go test ./... +``` + +Expected: PASS. Investigate any failure before continuing. + +- [ ] **Step 3: Run frontend build** + +Run from `vite-frontend`: + +```bash +pnpm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Inspect final diff** + +Run from repository root: + +```bash +git diff -- go-backend/internal/http/handler/tunnel_best_exit.go go-backend/internal/http/handler/tunnel_best_exit_test.go go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/tunnel_quality_prober.go go-gost/x/socket/chain.go go-gost/x/socket/chain_test.go vite-frontend/src/pages/tunnel.tsx +``` + +Expected: Diff shows only the `best` strategy, quality decision, safe chain update, and UI option changes described in this plan. + +--- + +## Self-Review + +- Spec coverage: `best` UI option is Task 5; DB persistence is covered by reusing existing `strategy` handling and verified through runtime tests; runtime `fifo` mapping and ordering are Task 2; scoring, confirmation, cooldown, and all-failed behavior are Task 1; prober candidate evaluation and `UpdateChains` are Task 3; agent parse-failure safety is Task 4; verification is Task 6. +- Placeholder scan: The plan contains concrete file paths, constants, function names, code blocks, commands, and expected outcomes. +- Type consistency: `bestExitOwnerKey`, `bestExitCandidateScore`, `bestExitManager`, `runtimeTunnelStrategy`, and `evaluateBestExitOwner` are defined before they are used by later tasks. diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 6687ec4..0cbb03f 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -2582,11 +2582,16 @@ export default function NodePage() { /> {/* 高级配置 */} - + + 高级配置 + + } >
)}
{/* HTTP tile */} -
+
+
+
- - - - setForm((prev) => ({ - ...prev, - trafficRatio: parseFloat(e.target.value) || 0, - })) - } - /> -
- -