diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index 53c5219..be8057c 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -280,6 +280,16 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method for _, fp := range ports { if limiterID != nil && speed != nil { if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil { + // If the limiter push fails because the node is offline, skip it with a warning + if isNodeOfflineOrTimeoutError(err) { + node, _ := h.getNodeRecord(fp.NodeID) + nodeName := fmt.Sprintf("%d", fp.NodeID) + if node != nil && strings.TrimSpace(node.Name) != "" { + nodeName = strings.TrimSpace(node.Name) + } + warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName)) + continue + } return nil, err } } @@ -308,6 +318,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method warnings = append(warnings, warning) } } + // When a node is offline, skip it with a warning instead of failing. + // This lets users modify forward rules even when some entry nodes are down. + if err != nil && isNodeOfflineOrTimeoutError(err) { + warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", node.Name)) + continue + } if err != nil { return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err) } diff --git a/go-backend/internal/http/handler/dual_stack_test.go b/go-backend/internal/http/handler/dual_stack_test.go index b7cb3e4..34c3736 100644 --- a/go-backend/internal/http/handler/dual_stack_test.go +++ b/go-backend/internal/http/handler/dual_stack_test.go @@ -345,21 +345,39 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) { } } -func TestSelectTunnelDialHost_Incompatible(t *testing.T) { +func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) { + // v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址 from := v4OnlyNode("from", "10.0.0.1") to := v6OnlyNode("to", "2001:db8::2") - _, err := selectTunnelDialHost(from, to, "", "") - if err == nil { - t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)") + host, err := selectTunnelDialHost(from, to, "", "") + if err != nil { + t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err) + } + if host != "2001:db8::2" { + t.Fatalf("expected v6 address for cross-version, got %q", host) } } -func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) { +func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) { + // v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址 from := v6OnlyNode("from", "2001:db8::1") to := v4OnlyNode("to", "10.0.0.2") + host, err := selectTunnelDialHost(from, to, "", "") + if err != nil { + t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err) + } + if host != "10.0.0.2" { + t.Fatalf("expected v4 address for cross-version, got %q", host) + } +} + +func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) { + // 真正不兼容:两个节点都没有任何 IP + from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""} + to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""} _, err := selectTunnelDialHost(from, to, "", "") if err == nil { - t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)") + t.Fatal("expected error for nodes with no IP addresses") } } diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 4b22426..de0efec 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -2780,6 +2780,22 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface } } + // When updating an existing tunnel (excludeTunnelID > 0), build a set of + // node IDs that already belong to the tunnel so we can tolerate offline + // nodes that the user is keeping or removing, while still rejecting newly + // added offline nodes. + existingNodeIDs := make(map[int64]struct{}) + if excludeTunnelID > 0 { + var existIDs []int64 + if err := tx.Model(&model.ChainTunnel{}). + Where("tunnel_id = ?", excludeTunnelID). + Pluck("node_id", &existIDs).Error; err == nil { + for _, eid := range existIDs { + existingNodeIDs[eid] = struct{}{} + } + } + } + seen := make(map[int64]struct{}, len(nodeIDs)) for _, nodeID := range nodeIDs { if _, ok := seen[nodeID]; ok { @@ -2798,7 +2814,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface return nil, errors.New("节点不存在") } if node.IsRemote != 1 && node.Status != 1 { - return nil, errors.New("部分节点不在线") + // For tunnel updates, allow offline nodes that already belong to the + // tunnel (user may be removing them). Only reject genuinely new offline nodes. + _, isExisting := existingNodeIDs[nodeID] + if excludeTunnelID <= 0 || !isExisting { + return nil, errors.New("部分节点不在线") + } } state.Nodes[nodeID] = node } @@ -3198,7 +3219,6 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64 } for _, inNode := range state.InNodes { - node := state.Nodes[inNode.NodeID] targets := state.OutNodes if len(state.ChainHops) > 0 { targets = state.ChainHops[0] @@ -3208,7 +3228,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64 return createdChains, createdServices, err } if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil { - if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) { + if shouldDeferTunnelRuntimeApplyError(err) { continue } return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err) @@ -3222,7 +3242,8 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64 nextTargets = state.ChainHops[i+1] } for _, chainNode := range hop { - if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 { + node := state.Nodes[chainNode.NodeID] + if node != nil && (node.IsRemote == 1 || node.Status != 1) { continue } chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference) @@ -3230,12 +3251,18 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64 return createdChains, createdServices, err } if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil { + if shouldDeferTunnelRuntimeApplyError(err) { + continue + } return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err) } createdChains = append(createdChains, chainNode.NodeID) serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets)) if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil { + if shouldDeferTunnelRuntimeApplyError(err) { + continue + } return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err) } createdServices = append(createdServices, chainNode.NodeID) @@ -3243,11 +3270,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64 } for _, outNode := range state.OutNodes { - if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 { + node := state.Nodes[outNode.NodeID] + if node != nil && (node.IsRemote == 1 || node.Status != 1) { continue } serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1) if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil { + if shouldDeferTunnelRuntimeApplyError(err) { + continue + } return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err) } createdServices = append(createdServices, outNode.NodeID) @@ -3338,6 +3369,13 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool { return false } +// isNodeOfflineOrTimeoutError returns true when the error indicates a node +// is unreachable (offline or timed out), matching the same patterns used by +// shouldDeferTunnelRuntimeApplyError. +func isNodeOfflineOrTimeoutError(err error) bool { + return shouldDeferTunnelRuntimeApplyError(err) +} + func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) { fromNode := nodes[fromNodeID] if fromNode == nil { @@ -3464,6 +3502,7 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con } } default: + // 同版本优先 if fromV4 && toV4 { if host := pickNodeAddressV4(toNode); host != "" { return host, nil @@ -3474,6 +3513,17 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con return host, nil } } + // 跨版本支持:v6入v4出 / v4入v6出 + if fromV6 && toV4 { + if host := pickNodeAddressV4(toNode); host != "" { + return host, nil + } + } + if fromV4 && toV6 { + if host := pickNodeAddressV6(toNode); host != "" { + return host, nil + } + } } return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6) } diff --git a/plans/067-issue-342-offline-node-tunnel-edit.md b/plans/067-issue-342-offline-node-tunnel-edit.md new file mode 100644 index 0000000..51b38c7 --- /dev/null +++ b/plans/067-issue-342-offline-node-tunnel-edit.md @@ -0,0 +1,22 @@ +# 067 - Issue #342: Allow Tunnel Edit with Offline Nodes + +**Issue:** https://github.com/Sagit-chu/flvx/issues/342 + +## Problem +When a node goes offline, users cannot edit tunnel configurations at all — including removing the faulty offline node. This creates a deadlock where users must wait for the offline node to recover or manually edit the database. + +## Changes Required + +### Backend + +- [x] 1. **`prepareTunnelCreateState`** (`mutations.go:2800`): Split the offline check into two modes: + - **Create (excludeTunnelID == 0)**: Keep current behavior — reject any offline non-remote node. + - **Update (excludeTunnelID > 0)**: Only reject **newly added** offline non-remote nodes. Allow existing offline nodes to remain (they'll be removed or kept). Query existing chain_tunnel records to determine which nodes are "old". + +- [x] 2. **`syncForwardServicesWithWarnings`** (`control_plane.go:231`): When a node is offline (sendNodeCommand fails with "节点不在线"), skip it and add a warning instead of returning a hard error. This allows forward rule modifications to succeed partially. + +- [x] 3. **`applyTunnelRuntime`** (`mutations.go:3190`): For non-remote local entry nodes, treat offline errors as deferrable (like remote nodes) so tunnel updates don't fail entirely when some nodes are offline. + +### Frontend + +- [x] 4. **`validateTunnelForm`** (`tunnel/form.ts`): Change validation to only block adding NEW offline nodes. When editing, offline nodes that are being removed should not block submission. Add isEdit parameter to distinguish create vs. edit. diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index fd68cd8..305fc4a 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -418,7 +418,7 @@ export default function TunnelPage() { // 表单验证 const validateForm = (): boolean => { - const newErrors = validateTunnelForm(form, nodes); + const newErrors = validateTunnelForm(form, nodes, isEdit); setErrors(newErrors); diff --git a/vite-frontend/src/pages/tunnel/form.ts b/vite-frontend/src/pages/tunnel/form.ts index 97c0f69..c69ea75 100644 --- a/vite-frontend/src/pages/tunnel/form.ts +++ b/vite-frontend/src/pages/tunnel/form.ts @@ -33,6 +33,7 @@ export const createTunnelFormDefaults = () => { export const validateTunnelForm = ( form: TunnelFormInput, nodes: TunnelNodeInput[], + isEdit = false, ): Record => { const errors: Record = {}; @@ -44,7 +45,9 @@ export const validateTunnelForm = ( if (!form.inNodeId || form.inNodeId.length === 0) { errors.inNodeId = "请至少选择一个入口节点"; - } else { + } else if (!isEdit) { + // Only enforce online check for new tunnels. During edit the backend + // allows existing offline nodes (user may be removing them). const offlineInNodes = form.inNodeId.filter((item) => { const node = nodes.find((n) => n.id === item.nodeId); @@ -64,14 +67,16 @@ export const validateTunnelForm = ( if (!form.outNodeId || form.outNodeId.length === 0) { errors.outNodeId = "请至少选择一个出口节点"; } else { - const offlineOutNodes = form.outNodeId.filter((item) => { - const node = nodes.find((n) => n.id === item.nodeId); + if (!isEdit) { + const offlineOutNodes = form.outNodeId.filter((item) => { + const node = nodes.find((n) => n.id === item.nodeId); - return node && node.status !== 1; - }); + return node && node.status !== 1; + }); - if (offlineOutNodes.length > 0) { - errors.outNodeId = "所有出口节点必须在线"; + if (offlineOutNodes.length > 0) { + errors.outNodeId = "所有出口节点必须在线"; + } } const inNodeIds = form.inNodeId.map((item) => item.nodeId);