mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
fix: 节点离线时允许删除隧道关联,但禁止新增隧道 (#342)
- prepareTunnelCreateState: 更新隧道时允许已关联的离线节点,仅拒绝新增的离线节点 - syncForwardServicesWithWarnings: 离线节点跳过下发并返回警告,不再硬性失败 - applyTunnelRuntime: 所有节点类型均支持离线错误延迟处理 - 前端 validateTunnelForm: 编辑模式下跳过离线节点验证 Closes #342
This commit is contained in:
@@ -280,6 +280,16 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
|||||||
for _, fp := range ports {
|
for _, fp := range ports {
|
||||||
if limiterID != nil && speed != nil {
|
if limiterID != nil && speed != nil {
|
||||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != 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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +318,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
|||||||
warnings = append(warnings, warning)
|
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 {
|
if err != nil {
|
||||||
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
seen := make(map[int64]struct{}, len(nodeIDs))
|
||||||
for _, nodeID := range nodeIDs {
|
for _, nodeID := range nodeIDs {
|
||||||
if _, ok := seen[nodeID]; ok {
|
if _, ok := seen[nodeID]; ok {
|
||||||
@@ -2798,8 +2814,13 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
|||||||
return nil, errors.New("节点不存在")
|
return nil, errors.New("节点不存在")
|
||||||
}
|
}
|
||||||
if node.IsRemote != 1 && node.Status != 1 {
|
if node.IsRemote != 1 && node.Status != 1 {
|
||||||
|
// 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("部分节点不在线")
|
return nil, errors.New("部分节点不在线")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
state.Nodes[nodeID] = node
|
state.Nodes[nodeID] = node
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3198,7 +3219,6 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, inNode := range state.InNodes {
|
for _, inNode := range state.InNodes {
|
||||||
node := state.Nodes[inNode.NodeID]
|
|
||||||
targets := state.OutNodes
|
targets := state.OutNodes
|
||||||
if len(state.ChainHops) > 0 {
|
if len(state.ChainHops) > 0 {
|
||||||
targets = state.ChainHops[0]
|
targets = state.ChainHops[0]
|
||||||
@@ -3208,7 +3228,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
return createdChains, createdServices, err
|
return createdChains, createdServices, err
|
||||||
}
|
}
|
||||||
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||||
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
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]
|
nextTargets = state.ChainHops[i+1]
|
||||||
}
|
}
|
||||||
for _, chainNode := range hop {
|
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
|
continue
|
||||||
}
|
}
|
||||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
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
|
return createdChains, createdServices, err
|
||||||
}
|
}
|
||||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
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)
|
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||||
}
|
}
|
||||||
createdChains = append(createdChains, chainNode.NodeID)
|
createdChains = append(createdChains, chainNode.NodeID)
|
||||||
|
|
||||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
||||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
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)
|
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||||
}
|
}
|
||||||
createdServices = append(createdServices, chainNode.NodeID)
|
createdServices = append(createdServices, chainNode.NodeID)
|
||||||
@@ -3243,11 +3270,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, outNode := range state.OutNodes {
|
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
|
continue
|
||||||
}
|
}
|
||||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
||||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
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)
|
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||||
}
|
}
|
||||||
createdServices = append(createdServices, outNode.NodeID)
|
createdServices = append(createdServices, outNode.NodeID)
|
||||||
@@ -3338,6 +3369,13 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
|
|||||||
return false
|
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) {
|
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
|
||||||
fromNode := nodes[fromNodeID]
|
fromNode := nodes[fromNodeID]
|
||||||
if fromNode == nil {
|
if fromNode == nil {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -418,7 +418,7 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
// 表单验证
|
// 表单验证
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors = validateTunnelForm(form, nodes);
|
const newErrors = validateTunnelForm(form, nodes, isEdit);
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const createTunnelFormDefaults = () => {
|
|||||||
export const validateTunnelForm = (
|
export const validateTunnelForm = (
|
||||||
form: TunnelFormInput,
|
form: TunnelFormInput,
|
||||||
nodes: TunnelNodeInput[],
|
nodes: TunnelNodeInput[],
|
||||||
|
isEdit = false,
|
||||||
): Record<string, string> => {
|
): Record<string, string> => {
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -44,7 +45,9 @@ export const validateTunnelForm = (
|
|||||||
|
|
||||||
if (!form.inNodeId || form.inNodeId.length === 0) {
|
if (!form.inNodeId || form.inNodeId.length === 0) {
|
||||||
errors.inNodeId = "请至少选择一个入口节点";
|
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 offlineInNodes = form.inNodeId.filter((item) => {
|
||||||
const node = nodes.find((n) => n.id === item.nodeId);
|
const node = nodes.find((n) => n.id === item.nodeId);
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ export const validateTunnelForm = (
|
|||||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||||
errors.outNodeId = "请至少选择一个出口节点";
|
errors.outNodeId = "请至少选择一个出口节点";
|
||||||
} else {
|
} else {
|
||||||
|
if (!isEdit) {
|
||||||
const offlineOutNodes = form.outNodeId.filter((item) => {
|
const offlineOutNodes = form.outNodeId.filter((item) => {
|
||||||
const node = nodes.find((n) => n.id === item.nodeId);
|
const node = nodes.find((n) => n.id === item.nodeId);
|
||||||
|
|
||||||
@@ -73,6 +77,7 @@ export const validateTunnelForm = (
|
|||||||
if (offlineOutNodes.length > 0) {
|
if (offlineOutNodes.length > 0) {
|
||||||
errors.outNodeId = "所有出口节点必须在线";
|
errors.outNodeId = "所有出口节点必须在线";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
||||||
const outNodeIds = form.outNodeId.map((item) => item.nodeId);
|
const outNodeIds = form.outNodeId.map((item) => item.nodeId);
|
||||||
|
|||||||
Reference in New Issue
Block a user