Compare commits

..

4 Commits

Author SHA1 Message Date
root 1f850593dd 节点管理页支持填写IPv4/IPv6 2026-02-01 11:14:19 +00:00
root c4519c243a 组隧道链路按地址族自动匹配节点IP(v4优先) 2026-02-01 11:14:19 +00:00
root 31ccc48436 启动时迁移并回填节点双栈IP列 2026-02-01 11:14:19 +00:00
root 1c4914ec77 支持节点双栈IP字段 2026-02-01 11:14:19 +00:00
10 changed files with 473 additions and 110 deletions
@@ -16,6 +16,10 @@ public class NodeDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用端口不能为空")
private String port;
@@ -25,4 +29,4 @@ public class NodeDto {
private String udpListenAddr = "0.0.0.0";
}
}
@@ -17,6 +17,10 @@ public class NodeUpdateDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用port不能为空")
private String port;
@@ -28,4 +32,4 @@ public class NodeUpdateDto {
private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0";
}
}
@@ -7,6 +7,7 @@ import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import java.util.regex.Pattern;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -43,6 +44,7 @@ public class GostUtil {
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
JSONArray nodes = new JSONArray();
Node fromNode = node_s.get(node_id);
for (ChainTunnel chainTunnel : chainTunnels) {
JSONObject dialer = new JSONObject();
dialer.put("type", chainTunnel.getProtocol());
@@ -53,7 +55,11 @@ public class GostUtil {
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject node = new JSONObject();
node.put("name", "node_" + chainTunnel.getInx());
node.put("addr", processServerAddress(node_info.getServerIp() + ":" + chainTunnel.getPort()));
String dialHost = (fromNode != null && node_info != null)
? selectDialHost(fromNode, node_info)
: (node_info != null ? node_info.getServerIp() : null);
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
node.put("connector", connector);
node.put("dialer", dialer);
@@ -291,4 +297,103 @@ public class GostUtil {
long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
/**
* v4 优先:当两端都有 v4 时选择 v4,否则尝试 v6。
* 用于节点之间建立链路(A -> B 需要选择 B 的地址族,且 A 需要支持该地址族)。
*/
public static String selectDialHost(Node fromNode, Node toNode) {
if (fromNode == null || toNode == null) {
throw new IllegalArgumentException("node is null");
}
boolean fromV4 = supportsV4(fromNode);
boolean fromV6 = supportsV6(fromNode);
boolean toV4 = supportsV4(toNode);
boolean toV6 = supportsV6(toNode);
if (fromV4 && toV4) {
return pickToAddressV4(toNode);
}
if (fromV6 && toV6) {
return pickToAddressV6(toNode);
}
throw new RuntimeException(
"节点链路不兼容:" + safeName(fromNode) + "(v4=" + fromV4 + ",v6=" + fromV6 + ") -> "
+ safeName(toNode) + "(v4=" + toV4 + ",v6=" + toV6 + ")"
);
}
private static String safeName(Node node) {
if (node.getName() == null || node.getName().isBlank()) {
return "node_" + node.getId();
}
return node.getName();
}
private static boolean supportsV4(Node node) {
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return true;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (looksLikeIpv4(legacy)) {
return true;
}
if (isIPv6Address(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static boolean supportsV6(Node node) {
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return true;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (isIPv6Address(legacy)) {
return true;
}
if (looksLikeIpv4(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static String pickToAddressV4(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV4())) {
return toNode.getServerIpV4().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static String pickToAddressV6(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV6())) {
return toNode.getServerIpV6().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static boolean looksLikeIpv4(String value) {
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
return ipv4.matcher(value).matches();
}
}
@@ -10,7 +10,12 @@ import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
/**
* SQLite 数据库配置
@@ -39,13 +44,98 @@ public class SQLiteConfig implements ApplicationRunner {
statement.execute("PRAGMA temp_store=MEMORY;");
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
ensureNodeDualStackColumns(connection);
log.info("SQLite WAL mode configured successfully");
} catch (Exception e) {
log.error("Failed to configure SQLite database", e);
throw e;
}
}
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
Set<String> cols = getTableColumns(connection, "node");
if (cols.isEmpty()) {
return;
}
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
backfillNodeDualStackColumns(connection);
}
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
) {
while (rs.next()) {
long id = rs.getLong("id");
String serverIp = rs.getString("server_ip");
String v4 = rs.getString("server_ip_v4");
String v6 = rs.getString("server_ip_v6");
if (serverIp == null || serverIp.isBlank()) {
continue;
}
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
continue;
}
String trimmed = serverIp.trim();
if (ipv4.matcher(trimmed).matches()) {
updV4.setString(1, trimmed);
updV4.setLong(2, id);
updV4.executeUpdate();
} else {
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
if (colonCount >= 2) {
updV6.setString(1, trimmed);
updV6.setLong(2, id);
updV6.executeUpdate();
}
}
}
}
}
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
Set<String> cols = new HashSet<>();
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
while (rs.next()) {
String name = rs.getString("name");
if (name != null && !name.isBlank()) {
cols.add(name);
}
}
}
return cols;
}
private void ensureColumnIfMissing(
Connection connection,
Set<String> existingColumns,
String table,
String column,
String type
) throws Exception {
if (existingColumns.contains(column)) {
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
}
log.info("SQLite schema updated: added {}.{}", table, column);
}
/**
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
@@ -80,4 +170,3 @@ public class SQLiteConfig implements ApplicationRunner {
}
}
}
@@ -20,9 +20,13 @@ public class Node extends BaseEntity {
private String name;
private String secret;
private String serverIp;
private String secret;
private String serverIp;
private String serverIpV4;
private String serverIpV6;
private String version;
@@ -45,5 +49,4 @@ public class Node extends BaseEntity {
* Lower value appears first.
*/
private Integer inx;
}
@@ -548,7 +548,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(firstChainNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), firstChainNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), firstChainNode.getPort(),
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
);
result.setFromChainType(1);
@@ -562,7 +562,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(1);
@@ -587,7 +587,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(nextNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), nextNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), nextNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -602,7 +602,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -21,7 +21,7 @@ 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.HashMap;
import java.util.ArrayList;
@@ -54,21 +54,28 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Override
public R createNode(NodeDto nodeDto) {
validatePortRange(nodeDto.getPort());
Node node = new Node();
node.setSecret(IdUtil.simpleUUID());
node.setStatus(0);
node.setPort(nodeDto.getPort());
node.setName(nodeDto.getName());
node.setServerIp(nodeDto.getServerIp());
long currentTime = System.currentTimeMillis();
node.setCreatedTime(currentTime);
node.setUpdatedTime(currentTime);
node.setInterfaceName(nodeDto.getInterfaceName());
this.save(node);
return R.ok();
}
public R createNode(NodeDto nodeDto) {
validatePortRange(nodeDto.getPort());
String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
Node node = new Node();
node.setSecret(IdUtil.simpleUUID());
node.setStatus(0);
node.setPort(nodeDto.getPort());
node.setName(nodeDto.getName());
node.setServerIp(primaryServerIp);
node.setServerIpV4(normalizedV4);
node.setServerIpV6(normalizedV6);
long currentTime = System.currentTimeMillis();
node.setCreatedTime(currentTime);
node.setUpdatedTime(currentTime);
node.setInterfaceName(nodeDto.getInterfaceName());
this.save(node);
return R.ok();
}
@Override
public R getAllNodes() {
@@ -388,22 +395,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
}
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
validatePortRange(nodeUpdateDto.getPort());
Node node = new Node();
node.setId(nodeUpdateDto.getId());
node.setName(nodeUpdateDto.getName());
node.setServerIp(nodeUpdateDto.getServerIp());
node.setPort(nodeUpdateDto.getPort());
node.setHttp(nodeUpdateDto.getHttp());
node.setTls(nodeUpdateDto.getTls());
node.setSocks(nodeUpdateDto.getSocks());
node.setUpdatedTime(System.currentTimeMillis());
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
return node;
}
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
validatePortRange(nodeUpdateDto.getPort());
String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
Node node = new Node();
node.setId(nodeUpdateDto.getId());
node.setName(nodeUpdateDto.getName());
node.setServerIp(primaryServerIp);
node.setServerIpV4(normalizedV4);
node.setServerIpV6(normalizedV6);
node.setPort(nodeUpdateDto.getPort());
node.setHttp(nodeUpdateDto.getHttp());
node.setTls(nodeUpdateDto.getTls());
node.setSocks(nodeUpdateDto.getSocks());
node.setUpdatedTime(System.currentTimeMillis());
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
return node;
}
private String pickPrimaryServerIp(String serverIpV4, String serverIpV6, String fallback) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
return fallback != null ? fallback.trim() : null;
}
private String normalizeV4(String serverIpV4, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv4(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private String normalizeV6(String serverIpV6, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv6(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private boolean looksLikeIpv4(String value) {
// 仅用于判定地址族(不解析域名)
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
return ipv4.matcher(value).matches();
}
private boolean looksLikeIpv6(String value) {
// 粗略判定 IPv6(与 GostUtil.processServerAddress 一致思路)
long colonCount = value.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
private void validatePortRange(String port) {
@@ -118,13 +118,25 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
tunnel.setCreatedTime(currentTime);
tunnel.setUpdatedTime(currentTime);
if (StringUtils.isEmpty(tunnel.getInIp())){
StringBuilder in_ip = new StringBuilder();
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) {
Node node = nodes.get(chainTunnel.getNodeId());
in_ip.append(node.getServerIp()).append(",");
if (node == null) continue;
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV4())) {
inIps.add(node.getServerIpV4().trim());
}
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV6())) {
inIps.add(node.getServerIpV6().trim());
}
if (cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV4())
&& cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV6())
&& cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIp())) {
inIps.add(node.getServerIp().trim());
}
}
if (!inIps.isEmpty()) {
tunnel.setInIp(String.join(",", inIps));
}
in_ip.deleteCharAt(in_ip.length() - 1);
tunnel.setInIp(in_ip.toString());
}
this.save(tunnel);
@@ -143,11 +155,33 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel in_node : tunnelDto.getInNodeId()) {
// 创建Chain, 指向chainNode的第一跳。如果chainNode为空就是指向出口
if (tunnelDto.getChainNodes().isEmpty()) { // 指向出口
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
isError(gostDto);
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
return R.err(e.getMessage());
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
return R.err(gostDto.getMsg());
}
} else {
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", in_node.getNodeId());
@@ -171,7 +205,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel chainTunnel : chainTunnels1) {
int inx = i+1;
if (inx >= tunnelDto.getChainNodes().size()) { // 指向出口
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId());
@@ -187,7 +232,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return R.err(gostDto.getMsg());
}
} else {
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId());
@@ -457,19 +513,29 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
tunnel.setInIp(tunnelUpdateDto.getInIp());
if (StringUtils.isEmpty(tunnel.getInIp())) {
StringBuilder inIp = new StringBuilder();
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
);
for (ChainTunnel chainTunnel : chainTunnels) {
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null) return R.err("隧道节点数据错误,部分节点不存在");
inIp.append(node.getServerIp()).append(",");
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV4())) {
inIps.add(node.getServerIpV4().trim());
}
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV6())) {
inIps.add(node.getServerIpV6().trim());
}
if (cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV4())
&& cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV6())
&& cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIp())) {
inIps.add(node.getServerIp().trim());
}
}
if (inIp.length() > 0) {
inIp.deleteCharAt(inIp.length() - 1);
if (!inIps.isEmpty()) {
tunnel.setInIp(String.join(",", inIps));
}
tunnel.setInIp(inIp.toString());
}
tunnel.setUpdatedTime(System.currentTimeMillis());
@@ -596,7 +662,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(firstChainNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), firstChainNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), firstChainNode.getPort(),
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
);
result.setFromChainType(1); // 入口
@@ -610,7 +676,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(1);
@@ -634,7 +700,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(nextNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), nextNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), nextNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -649,7 +715,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -740,7 +806,12 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
}
private void isError(GostDto gostDto){
if (gostDto == null) {
throw new RuntimeException("节点无响应");
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
throw new RuntimeException(gostDto.getMsg());
}
}
private DiagnosisResult performTcpPingDiagnosis(Node node, String targetIp, int port, String description) {
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS node (
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
server_ip_v4 VARCHAR(100),
server_ip_v6 VARCHAR(100),
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
+75 -46
View File
@@ -46,6 +46,8 @@ interface Node {
name: string;
ip: string;
serverIp: string;
serverIpV4?: string;
serverIpV6?: string;
port: string;
tcpListenAddr?: string;
udpListenAddr?: string;
@@ -70,7 +72,8 @@ interface Node {
interface NodeForm {
id: number | null;
name: string;
serverIp: string;
serverIpV4: string;
serverIpV6: string;
port: string;
tcpListenAddr: string;
udpListenAddr: string;
@@ -96,7 +99,8 @@ export default function NodePage() {
const [form, setForm] = useState<NodeForm>({
id: null,
name: '',
serverIp: '',
serverIpV4: '',
serverIpV6: '',
port: '1000-65535',
tcpListenAddr: '[::]',
udpListenAddr: '[::]',
@@ -398,30 +402,12 @@ export default function NodePage() {
return "danger";
};
// 验证IP地址格式
const validateIp = (ip: string): boolean => {
if (!ip || !ip.trim()) return false;
const trimmedIp = ip.trim();
// IPv4格式验证
const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// IPv6格式验证
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
if (ipv4Regex.test(trimmedIp) || ipv6Regex.test(trimmedIp) || trimmedIp === 'localhost') {
return true;
}
// 验证域名格式
if (/^\d+$/.test(trimmedIp)) return false;
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)+$/;
const singleLabelDomain = /^[a-zA-Z][a-zA-Z0-9\-]{0,62}$/;
return domainRegex.test(trimmedIp) || singleLabelDomain.test(trimmedIp);
};
// IPv4/IPv6 格式验证(仅用于判定地址族)
const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
const validateIpv4Literal = (ip: string): boolean => ipv4Regex.test(ip.trim());
const validateIpv6Literal = (ip: string): boolean => ipv6Regex.test(ip.trim());
// 验证端口格式:支持 80,443,100-600
const validatePort = (portStr: string): { valid: boolean; error?: string } => {
@@ -486,10 +472,19 @@ export default function NodePage() {
newErrors.name = '节点名称长度不能超过50位';
}
if (!form.serverIp.trim()) {
newErrors.serverIp = '请输入服务器IP地址';
} else if (!validateIp(form.serverIp.trim())) {
newErrors.serverIp = '请输入有效的IPv4、IPv6地址或域名';
const v4 = form.serverIpV4.trim();
const v6 = form.serverIpV6.trim();
if (!v4 && !v6) {
newErrors.serverIpV4 = '请至少填写一个IPv4或IPv6地址';
newErrors.serverIpV6 = '请至少填写一个IPv4或IPv6地址';
} else {
if (v4 && !validateIpv4Literal(v4)) {
newErrors.serverIpV4 = '请输入有效的IPv4地址';
}
if (v6 && !validateIpv6Literal(v6)) {
newErrors.serverIpV6 = '请输入有效的IPv6地址';
}
}
const portValidation = validatePort(form.port);
@@ -518,7 +513,8 @@ export default function NodePage() {
setForm({
id: node.id,
name: node.name,
serverIp: node.serverIp || '',
serverIpV4: node.serverIpV4?.trim() || (validateIpv4Literal((node.serverIp || '').trim()) ? (node.serverIp || '').trim() : ''),
serverIpV6: node.serverIpV6?.trim() || (validateIpv6Literal((node.serverIp || '').trim()) ? (node.serverIp || '').trim() : ''),
port: node.port || '1000-65535',
tcpListenAddr: node.tcpListenAddr || '[::]',
udpListenAddr: node.udpListenAddr || '[::]',
@@ -610,7 +606,8 @@ export default function NodePage() {
try {
const apiCall = isEdit ? updateNode : createNode;
const data = {
...form
...form,
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || ''
};
const res = await apiCall(data);
@@ -623,7 +620,9 @@ export default function NodePage() {
n.id === form.id ? {
...n,
name: form.name,
serverIp: form.serverIp,
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || '',
serverIpV4: form.serverIpV4,
serverIpV6: form.serverIpV6,
port: form.port,
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
@@ -651,7 +650,8 @@ export default function NodePage() {
setForm({
id: null,
name: '',
serverIp: '',
serverIpV4: '',
serverIpV6: '',
port: '1000-65535',
tcpListenAddr: '[::]',
udpListenAddr: '[::]',
@@ -869,9 +869,24 @@ export default function NodePage() {
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">IP</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2">
<span className="font-mono truncate block" title={node.serverIp.trim()}>
{node.serverIp.trim()}
</span>
{(node.serverIpV4?.trim() || node.serverIpV6?.trim()) ? (
<div className="space-y-0.5">
{node.serverIpV4?.trim() && (
<span className="font-mono truncate block" title={node.serverIpV4.trim()}>
{node.serverIpV4.trim()}
</span>
)}
{node.serverIpV6?.trim() && (
<span className="font-mono truncate block" title={node.serverIpV6.trim()}>
{node.serverIpV6.trim()}
</span>
)}
</div>
) : (
<span className="font-mono truncate block" title={node.serverIp.trim()}>
{node.serverIp.trim()}
</span>
)}
</div>
</div>
<div className="flex justify-between text-sm">
@@ -1044,15 +1059,29 @@ export default function NodePage() {
variant="bordered"
/>
<Input
label="服务器IP"
placeholder="请输入服务器IP地址,如: 192.168.1.100 或 example.com"
value={form.serverIp}
onChange={(e) => setForm(prev => ({ ...prev, serverIp: e.target.value }))}
isInvalid={!!errors.serverIp}
errorMessage={errors.serverIp}
variant="bordered"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="服务器IPv4"
placeholder="例如: 203.0.113.10"
value={form.serverIpV4}
onChange={(e) => setForm(prev => ({ ...prev, serverIpV4: e.target.value }))}
isInvalid={!!errors.serverIpV4}
errorMessage={errors.serverIpV4}
variant="bordered"
description="双栈节点组隧道时优先使用 IPv4"
/>
<Input
label="服务器IPv6"
placeholder="例如: 2001:db8::10"
value={form.serverIpV6}
onChange={(e) => setForm(prev => ({ ...prev, serverIpV6: e.target.value }))}
isInvalid={!!errors.serverIpV6}
errorMessage={errors.serverIpV6}
variant="bordered"
description="至少填写一个 IPv4/IPv6 地址"
/>
</div>
<Input
label="可用端口"