From 6adf90d45cc6e212f83118c42cc55c2e793b689b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 05:00:18 +0000 Subject: [PATCH] fix: make node deletion detach tunnels safely When deleting a node, detach it from affected tunnel chains and keep tunnel config consistent instead of deleting entire tunnels. --- .../admin/service/impl/NodeServiceImpl.java | 285 ++++++++++++++++-- 1 file changed, 253 insertions(+), 32 deletions(-) diff --git a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java index c291f8e..03a3823 100644 --- a/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java +++ b/springboot-backend/src/main/java/com/admin/service/impl/NodeServiceImpl.java @@ -9,26 +9,32 @@ import com.admin.common.lang.R; import com.admin.common.utils.GostUtil; import com.admin.common.utils.WebSocketServer; import com.admin.entity.*; -import com.admin.mapper.NodeMapper; -import com.admin.mapper.TunnelMapper; -import com.admin.service.*; -import com.alibaba.fastjson.JSONObject; -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import org.springframework.beans.BeanUtils; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; +import com.admin.mapper.NodeMapper; +import com.admin.mapper.TunnelMapper; +import com.admin.service.*; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; -import javax.annotation.Resource; -import java.util.List; -import java.util.Objects; -import java.util.regex.Pattern; +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Value; -@Service -public class NodeServiceImpl extends ServiceImpl implements NodeService { +@Service +@Slf4j +public class NodeServiceImpl extends ServiceImpl implements NodeService { @Resource @@ -38,8 +44,11 @@ public class NodeServiceImpl extends ServiceImpl implements No @Resource ViteConfigService viteConfigService; - @Resource - ChainTunnelService chainTunnelService; + @Resource + ChainTunnelService chainTunnelService; + + @Resource + ForwardPortService forwardPortService; @Override @@ -101,20 +110,232 @@ public class NodeServiceImpl extends ServiceImpl implements No return R.ok(); } - @Override - public R deleteNode(Long id) { - Node node = this.getById(id); - if (node == null) { - return R.err("节点不存在"); - } - - List list = chainTunnelService.list(new QueryWrapper().eq("node_id", id).groupBy("tunnel_id")); - for (ChainTunnel tunnel : list) { - tunnelService.deleteTunnel(tunnel.getTunnelId()); - } - this.removeById(id); - return R.ok(); - } + @Override + public R deleteNode(Long id) { + Node node = this.getById(id); + if (node == null) { + return R.err("节点不存在"); + } + + List affected = chainTunnelService.list(new QueryWrapper().eq("node_id", id)); + Map> byTunnelId = affected.stream() + .filter(ct -> ct.getTunnelId() != null) + .collect(Collectors.groupingBy(ChainTunnel::getTunnelId)); + + for (Map.Entry> entry : byTunnelId.entrySet()) { + Long tunnelId = entry.getKey(); + Tunnel tunnel = tunnelService.getById(tunnelId); + + List before = chainTunnelService.list(new QueryWrapper().eq("tunnel_id", tunnelId)); + + // Remove the node from the tunnel definition (do NOT delete the tunnel). + chainTunnelService.remove(new QueryWrapper().eq("tunnel_id", tunnelId).eq("node_id", id)); + + if (tunnel == null) { + continue; + } + + List after = chainTunnelService.list(new QueryWrapper().eq("tunnel_id", tunnelId)); + Integer removedChainType = entry.getValue().isEmpty() ? null : entry.getValue().get(0).getChainType(); + + // Keep tunnel.inIp consistent when it was auto-derived from entry nodes. + String oldDerivedInIp = buildDerivedInIp(before); + String newDerivedInIp = buildDerivedInIp(after); + if (shouldUpdateTunnelInIp(tunnel.getInIp(), oldDerivedInIp)) { + updateTunnelInIp(tunnelId, newDerivedInIp); + } + + boolean valid = isTunnelConfigValid(tunnel, after); + if (!valid) { + disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete"); + continue; + } + + // For tunnel-forwarding (type=2), removing a chain/out node requires rebuilding config. + // Removing an entry node (chainType=1) does not affect remaining nodes' chain targets. + if (tunnel.getType() != null && tunnel.getType() == 2 && removedChainType != null && removedChainType != 1) { + try { + cleanupGostConfig(after, tunnelId); + rebuildGostConfig(after, tunnel); + } catch (Exception e) { + log.warn("Failed to rebuild gost config after node delete. tunnelId={}, nodeId={}, err={}", tunnelId, id, e.getMessage(), e); + disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete:rebuild-failed"); + } + } + } + + // Remove per-forward port allocations on this node (avoid orphan ForwardPort rows). + try { + forwardPortService.remove(new QueryWrapper().eq("node_id", id)); + } catch (Exception e) { + log.warn("Failed to cleanup forward ports when deleting node. nodeId={}, err={}", id, e.getMessage(), e); + } + + this.removeById(id); + return R.ok(); + } + + private boolean isTunnelConfigValid(Tunnel tunnel, List chainTunnels) { + if (tunnel == null || chainTunnels == null) { + return false; + } + + long inCount = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 1) + .count(); + if (inCount <= 0) { + return false; + } + + if (tunnel.getType() != null && tunnel.getType() == 2) { + long outCount = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 3) + .count(); + return outCount > 0; + } + + return true; + } + + private boolean shouldUpdateTunnelInIp(String currentInIp, String oldDerivedInIp) { + if (StrUtil.isBlank(currentInIp)) { + return true; + } + if (oldDerivedInIp == null) { + return false; + } + return Objects.equals(currentInIp, oldDerivedInIp); + } + + private void updateTunnelInIp(Long tunnelId, String derivedInIp) { + Tunnel update = new Tunnel(); + update.setId(tunnelId); + update.setInIp(derivedInIp == null ? "" : derivedInIp); + update.setUpdatedTime(System.currentTimeMillis()); + tunnelService.updateById(update); + } + + private String buildDerivedInIp(List chainTunnels) { + if (chainTunnels == null) { + return ""; + } + List inNodes = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 1) + .collect(Collectors.toList()); + if (inNodes.isEmpty()) { + return ""; + } + + StringBuilder inIp = new StringBuilder(); + for (ChainTunnel inNode : inNodes) { + Node n = this.getById(inNode.getNodeId()); + if (n == null || StrUtil.isBlank(n.getServerIp())) { + return null; + } + inIp.append(n.getServerIp()).append(","); + } + inIp.deleteCharAt(inIp.length() - 1); + return inIp.toString(); + } + + private void disableTunnelAndCleanupGostIfNeeded(Tunnel tunnel, List remaining, String reason) { + try { + Tunnel update = new Tunnel(); + update.setId(tunnel.getId()); + update.setStatus(0); + update.setUpdatedTime(System.currentTimeMillis()); + tunnelService.updateById(update); + } catch (Exception e) { + log.warn("Failed to disable tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e); + } + + if (tunnel.getType() != null && tunnel.getType() == 2) { + try { + cleanupGostConfig(remaining, tunnel.getId()); + } catch (Exception e) { + log.warn("Failed to cleanup gost config when disabling tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e); + } + } + } + + private void cleanupGostConfig(List chainTunnels, Long tunnelId) { + if (chainTunnels == null) { + return; + } + for (ChainTunnel chainTunnel : chainTunnels) { + if (chainTunnel.getChainType() == null) { + continue; + } + if (chainTunnel.getChainType() == 1) { + GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId); + } else if (chainTunnel.getChainType() == 2) { + GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId); + JSONArray services = new JSONArray(); + services.add(tunnelId + "_tls"); + GostUtil.DeleteService(chainTunnel.getNodeId(), services); + } else if (chainTunnel.getChainType() == 3) { + JSONArray services = new JSONArray(); + services.add(tunnelId + "_tls"); + GostUtil.DeleteService(chainTunnel.getNodeId(), services); + } + } + } + + private void rebuildGostConfig(List chainTunnels, Tunnel tunnel) { + if (tunnel == null || chainTunnels == null) { + return; + } + + Map nodes = new HashMap<>(); + for (ChainTunnel ct : chainTunnels) { + Node n = this.getById(ct.getNodeId()); + if (n != null) { + nodes.put(n.getId(), n); + } + } + + List inNodes = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 1) + .collect(Collectors.toList()); + + Map> chainNodesMap = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 2) + .collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0)); + + List> chainNodesList = chainNodesMap.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + + List outNodes = chainTunnels.stream() + .filter(ct -> ct.getChainType() != null && ct.getChainType() == 3) + .collect(Collectors.toList()); + + if (tunnel.getType() != null && tunnel.getType() == 2) { + for (ChainTunnel inNode : inNodes) { + if (chainNodesList.isEmpty()) { + GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes); + } else { + GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes); + } + } + + for (int i = 0; i < chainNodesList.size(); i++) { + for (ChainTunnel chainNode : chainNodesList.get(i)) { + if (i + 1 >= chainNodesList.size()) { + GostUtil.AddChains(chainNode.getNodeId(), outNodes, nodes); + } else { + GostUtil.AddChains(chainNode.getNodeId(), chainNodesList.get(i + 1), nodes); + } + GostUtil.AddChainService(chainNode.getNodeId(), chainNode, nodes); + } + } + + for (ChainTunnel outNode : outNodes) { + GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes); + } + } + } @Override