mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
16 Commits
2.0.9
...
2.0.11-beta3
| Author | SHA1 | Date | |
|---|---|---|---|
| 38b70821c5 | |||
| 936158dd32 | |||
| e0d0553fd3 | |||
| 78aa86b23c | |||
| 1f850593dd | |||
| c4519c243a | |||
| 31ccc48436 | |||
| 1c4914ec77 | |||
| 71c40127d5 | |||
| aac1d63ac0 | |||
| 8222b31917 | |||
| 81c9c117a2 | |||
| de8e487dff | |||
| f4c59f64b0 | |||
| 6adf90d45c | |||
| 71eab8e07e |
@@ -150,7 +150,11 @@ jobs:
|
||||
|
||||
- name: Prepare build args
|
||||
run: |
|
||||
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}" > ./vite-frontend/.env.production
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
{
|
||||
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
|
||||
echo "VITE_APP_VERSION=$VERSION"
|
||||
} > ./vite-frontend/.env.production
|
||||
|
||||
- name: Build and push Vite Docker images
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 100
|
||||
completion_promise: "DONE"
|
||||
started_at: "2026-01-24T05:20:50.887Z"
|
||||
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
|
||||
---
|
||||
Complete the task as instructed
|
||||
+83
-10
@@ -1,5 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
# 获取系统架构
|
||||
get_architecture() {
|
||||
ARCH=$(uname -m)
|
||||
@@ -16,20 +19,90 @@ get_architecture() {
|
||||
esac
|
||||
}
|
||||
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
|
||||
# 识别国家(用于镜像加速)
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://ghfast.top/${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
local effective_url tag api_tag latest_url api_url
|
||||
|
||||
latest_url="https://github.com/${REPO}/releases/latest"
|
||||
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# CN 环境下可尝试通过镜像访问(不影响非 CN)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 方式2:GitHub API(无需 jq)
|
||||
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
if [[ -n "${VERSION:-}" ]]; then
|
||||
echo "$VERSION"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||
echo "$FLUX_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if resolve_latest_release_tag; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./install.sh" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 构建下载地址
|
||||
build_download_url() {
|
||||
local ARCH=$(get_architecture)
|
||||
echo "https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/gost-${ARCH}"
|
||||
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||
}
|
||||
|
||||
# 下载地址
|
||||
DOWNLOAD_URL=$(build_download_url)
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
||||
fi
|
||||
# 解析版本并构建下载地址
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||
|
||||
|
||||
|
||||
@@ -365,4 +438,4 @@ main() {
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main
|
||||
main
|
||||
|
||||
+74
-8
@@ -7,16 +7,82 @@ export LC_ALL=C
|
||||
|
||||
|
||||
|
||||
# 全局下载地址配置
|
||||
DOCKER_COMPOSEV4_URL="https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/docker-compose-v4.yml"
|
||||
DOCKER_COMPOSEV6_URL="https://github.com/Sagit-chu/flux-panel/releases/download/2.0.8/docker-compose-v6.yml"
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}"
|
||||
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}"
|
||||
fi
|
||||
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://ghfast.top/${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
local effective_url tag api_tag latest_url api_url
|
||||
|
||||
latest_url="https://github.com/${REPO}/releases/latest"
|
||||
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
if [[ -n "${VERSION:-}" ]]; then
|
||||
echo "$VERSION"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||
echo "$FLUX_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if resolve_latest_release_tag; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./panel_install.sh" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
|
||||
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml")
|
||||
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v6.yml")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<factorypath>
|
||||
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
|
||||
</factorypath>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ import java.util.List;
|
||||
public class TunnelDetailDto {
|
||||
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Display/order index for drag-sorting in admin UI.
|
||||
* Lower value appears first.
|
||||
*/
|
||||
private Integer inx;
|
||||
|
||||
private String name;
|
||||
|
||||
@@ -40,4 +46,3 @@ public class TunnelDetailDto {
|
||||
// 出口节点列表
|
||||
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
||||
}
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.admin.common.migration;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Lightweight SQLite schema migration.
|
||||
*
|
||||
* Spring Boot SQL init uses CREATE TABLE IF NOT EXISTS, so existing installations
|
||||
* won't automatically receive new columns. This runner adds missing columns in-place.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class SqliteSchemaMigration implements ApplicationRunner {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public SqliteSchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
private void ensureColumn(String table, String column, String columnDefinition) {
|
||||
Set<String> columns = new HashSet<>(
|
||||
jdbcTemplate.query(
|
||||
"PRAGMA table_info(" + table + ")",
|
||||
(rs, rowNum) -> rs.getString("name")
|
||||
)
|
||||
);
|
||||
|
||||
if (columns.contains(column)) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Adding missing column {}.{}", table, column);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,115 @@ 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) {
|
||||
// New dual-stack fields take precedence over legacy serverIp.
|
||||
// If user explicitly provided only v6, treat as v6-only.
|
||||
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
// New dual-stack fields take precedence over legacy serverIp.
|
||||
// If user explicitly provided only v4, treat as v4-only.
|
||||
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,24 @@ public class NodeController extends BaseController {
|
||||
return nodeService.deleteNode(id);
|
||||
}
|
||||
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/install")
|
||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||
Long id = Long.valueOf(params.get("id").toString());
|
||||
return nodeService.getInstallCommand(id);
|
||||
}
|
||||
|
||||
}
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/install")
|
||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||
Long id = Long.valueOf(params.get("id").toString());
|
||||
return nodeService.getInstallCommand(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点排序
|
||||
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||
* @return 更新结果
|
||||
*/
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/update-order")
|
||||
public R updateNodeOrder(@RequestBody Map<String, Object> params) {
|
||||
return nodeService.updateNodeOrder(params);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -137,4 +137,16 @@ public class TunnelController extends BaseController {
|
||||
return tunnelService.diagnoseTunnel(tunnelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新隧道排序
|
||||
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||
* @return 更新结果
|
||||
*/
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/update-order")
|
||||
public R updateTunnelOrder(@RequestBody Map<String, Object> params) {
|
||||
return tunnelService.updateTunnelOrder(params);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -14,15 +14,19 @@ import lombok.EqualsAndHashCode;
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Node extends BaseEntity {
|
||||
public class Node extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String name;
|
||||
|
||||
private String secret;
|
||||
|
||||
private String serverIp;
|
||||
private String secret;
|
||||
|
||||
private String serverIp;
|
||||
|
||||
private String serverIpV4;
|
||||
|
||||
private String serverIpV6;
|
||||
|
||||
private String version;
|
||||
|
||||
@@ -38,6 +42,11 @@ public class Node extends BaseEntity {
|
||||
|
||||
private String tcpListenAddr;
|
||||
|
||||
private String udpListenAddr;
|
||||
|
||||
}
|
||||
private String udpListenAddr;
|
||||
|
||||
/**
|
||||
* Display/order index for drag-sorting in admin UI.
|
||||
* Lower value appears first.
|
||||
*/
|
||||
private Integer inx;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import lombok.EqualsAndHashCode;
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName(autoResultMap = true)
|
||||
public class Tunnel extends BaseEntity {
|
||||
public class Tunnel extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -32,7 +32,13 @@ public class Tunnel extends BaseEntity {
|
||||
|
||||
private int flow;
|
||||
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
private String inIp;
|
||||
}
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
private String inIp;
|
||||
|
||||
/**
|
||||
* Display/order index for drag-sorting in admin UI.
|
||||
* Lower value appears first.
|
||||
*/
|
||||
private Integer inx;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.admin.service;
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Node;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.admin.entity.Node;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
@@ -14,7 +16,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
public interface NodeService extends IService<Node> {
|
||||
public interface NodeService extends IService<Node> {
|
||||
|
||||
R createNode(NodeDto nodeDto);
|
||||
|
||||
@@ -24,6 +26,12 @@ public interface NodeService extends IService<Node> {
|
||||
|
||||
R deleteNode(Long id);
|
||||
|
||||
R getInstallCommand(Long id);
|
||||
|
||||
}
|
||||
R getInstallCommand(Long id);
|
||||
|
||||
/**
|
||||
* 更新节点排序(管理员)
|
||||
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||
*/
|
||||
R updateNodeOrder(Map<String, Object> params);
|
||||
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import com.admin.common.dto.TunnelDto;
|
||||
import com.admin.common.dto.TunnelUpdateDto;
|
||||
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
@@ -15,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
public interface TunnelService extends IService<Tunnel> {
|
||||
public interface TunnelService extends IService<Tunnel> {
|
||||
|
||||
/**
|
||||
* 创建隧道
|
||||
@@ -55,5 +57,11 @@ public interface TunnelService extends IService<Tunnel> {
|
||||
* @param tunnelId 隧道ID
|
||||
* @return 诊断结果
|
||||
*/
|
||||
R diagnoseTunnel(Long tunnelId);
|
||||
}
|
||||
R diagnoseTunnel(Long tunnelId);
|
||||
|
||||
/**
|
||||
* 更新隧道排序(管理员)
|
||||
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||
*/
|
||||
R updateTunnelOrder(Map<String, Object> params);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,26 +9,34 @@ 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.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
@Service
|
||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||
@Service
|
||||
@Slf4j
|
||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||
|
||||
|
||||
@Resource
|
||||
@@ -38,33 +46,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
@Resource
|
||||
ViteConfigService viteConfigService;
|
||||
|
||||
@Resource
|
||||
ChainTunnelService chainTunnelService;
|
||||
@Resource
|
||||
ChainTunnelService chainTunnelService;
|
||||
|
||||
@Resource
|
||||
ForwardPortService forwardPortService;
|
||||
|
||||
|
||||
@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() {
|
||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByDesc("status"));
|
||||
nodeList.forEach(node -> node.setSecret(null));
|
||||
return R.ok(nodeList);
|
||||
}
|
||||
@Override
|
||||
public R getAllNodes() {
|
||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByAsc("inx").orderByAsc("id"));
|
||||
nodeList.forEach(node -> node.setSecret(null));
|
||||
return R.ok(nodeList);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R updateNodeOrder(Map<String, Object> params) {
|
||||
if (!params.containsKey("nodes")) {
|
||||
return R.err("缺少nodes参数");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> nodesList = (List<Map<String, Object>>) params.get("nodes");
|
||||
if (nodesList == null || nodesList.isEmpty()) {
|
||||
return R.err("nodes参数不能为空");
|
||||
}
|
||||
|
||||
List<Node> nodesToUpdate = new ArrayList<>();
|
||||
for (Map<String, Object> nodeData : nodesList) {
|
||||
Long id = Long.valueOf(nodeData.get("id").toString());
|
||||
Integer inx = Integer.valueOf(nodeData.get("inx").toString());
|
||||
|
||||
Node node = new Node();
|
||||
node.setId(id);
|
||||
node.setInx(inx);
|
||||
nodesToUpdate.add(node);
|
||||
}
|
||||
|
||||
this.updateBatchById(nodesToUpdate);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
||||
@@ -101,20 +147,232 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public R deleteNode(Long id) {
|
||||
Node node = this.getById(id);
|
||||
if (node == null) {
|
||||
return R.err("节点不存在");
|
||||
}
|
||||
|
||||
List<ChainTunnel> list = chainTunnelService.list(new QueryWrapper<ChainTunnel>().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<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
|
||||
Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
|
||||
.filter(ct -> ct.getTunnelId() != null)
|
||||
.collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
|
||||
|
||||
for (Map.Entry<Long, List<ChainTunnel>> entry : byTunnelId.entrySet()) {
|
||||
Long tunnelId = entry.getKey();
|
||||
Tunnel tunnel = tunnelService.getById(tunnelId);
|
||||
|
||||
List<ChainTunnel> before = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||
|
||||
// Remove the node from the tunnel definition (do NOT delete the tunnel).
|
||||
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId).eq("node_id", id));
|
||||
|
||||
if (tunnel == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ChainTunnel> after = chainTunnelService.list(new QueryWrapper<ChainTunnel>().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<ForwardPort>().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<ChainTunnel> 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<ChainTunnel> chainTunnels) {
|
||||
if (chainTunnels == null) {
|
||||
return "";
|
||||
}
|
||||
List<ChainTunnel> 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<ChainTunnel> 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<ChainTunnel> 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<ChainTunnel> chainTunnels, Tunnel tunnel) {
|
||||
if (tunnel == null || chainTunnels == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Long, Node> nodes = new HashMap<>();
|
||||
for (ChainTunnel ct : chainTunnels) {
|
||||
Node n = this.getById(ct.getNodeId());
|
||||
if (n != null) {
|
||||
nodes.put(n.getId(), n);
|
||||
}
|
||||
}
|
||||
|
||||
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
|
||||
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
|
||||
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
|
||||
|
||||
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.map(Map.Entry::getValue)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<ChainTunnel> 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
|
||||
@@ -126,8 +384,8 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
||||
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
||||
StringBuilder command = new StringBuilder();
|
||||
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.7-beta/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
||||
command.append("./install.sh")
|
||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||
@@ -137,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) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import lombok.Data;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.*;
|
||||
@@ -117,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);
|
||||
@@ -142,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());
|
||||
@@ -170,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());
|
||||
@@ -186,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());
|
||||
@@ -252,7 +309,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
||||
|
||||
@Override
|
||||
public R getAllTunnels() {
|
||||
List<Tunnel> tunnelList = this.list();
|
||||
List<Tunnel> tunnelList = this.list(new QueryWrapper<Tunnel>().orderByAsc("inx").orderByAsc("id"));
|
||||
|
||||
// 查询所有隧道的ChainTunnel信息
|
||||
List<Long> tunnelIds = tunnelList.stream()
|
||||
@@ -317,6 +374,34 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
|
||||
return R.ok(detailDtoList);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R updateTunnelOrder(Map<String, Object> params) {
|
||||
if (!params.containsKey("tunnels")) {
|
||||
return R.err("缺少tunnels参数");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> tunnelsList = (List<Map<String, Object>>) params.get("tunnels");
|
||||
if (tunnelsList == null || tunnelsList.isEmpty()) {
|
||||
return R.err("tunnels参数不能为空");
|
||||
}
|
||||
|
||||
List<Tunnel> tunnelsToUpdate = new ArrayList<>();
|
||||
for (Map<String, Object> tunnelData : tunnelsList) {
|
||||
Long id = Long.valueOf(tunnelData.get("id").toString());
|
||||
Integer inx = Integer.valueOf(tunnelData.get("inx").toString());
|
||||
|
||||
Tunnel tunnel = new Tunnel();
|
||||
tunnel.setId(id);
|
||||
tunnel.setInx(inx);
|
||||
tunnelsToUpdate.add(tunnel);
|
||||
}
|
||||
|
||||
this.updateBatchById(tunnelsToUpdate);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
|
||||
@@ -428,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());
|
||||
@@ -567,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); // 入口
|
||||
@@ -581,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);
|
||||
@@ -605,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);
|
||||
@@ -620,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);
|
||||
@@ -711,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),
|
||||
@@ -39,7 +41,8 @@ CREATE TABLE IF NOT EXISTS node (
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||
@@ -72,7 +75,8 @@ CREATE TABLE IF NOT EXISTS tunnel (
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT
|
||||
in_ip TEXT,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||
@@ -123,4 +127,3 @@ CREATE TABLE IF NOT EXISTS vite_config (
|
||||
value VARCHAR(200) NOT NULL,
|
||||
time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.admin;
|
||||
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Node;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.service.NodeService;
|
||||
import com.admin.service.TunnelService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
// use a local sqlite file for tests (resolved via ${DB_PATH} placeholder)
|
||||
"DB_PATH=./target/test-gost-ordering.db",
|
||||
})
|
||||
class OrderingUpdateTests {
|
||||
|
||||
@Resource
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Resource
|
||||
private NodeService nodeService;
|
||||
|
||||
@Resource
|
||||
private TunnelService tunnelService;
|
||||
|
||||
@BeforeEach
|
||||
void cleanup() {
|
||||
// keep it simple; other tables may have foreign references in real runs
|
||||
jdbcTemplate.execute("DELETE FROM node");
|
||||
jdbcTemplate.execute("DELETE FROM tunnel");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNodeOrder_updatesInx() {
|
||||
Node n1 = new Node();
|
||||
n1.setName("n1");
|
||||
n1.setSecret("s1");
|
||||
n1.setServerIp("127.0.0.1");
|
||||
n1.setPort("1000-2000");
|
||||
n1.setInterfaceName("");
|
||||
n1.setHttp(0);
|
||||
n1.setTls(0);
|
||||
n1.setSocks(0);
|
||||
n1.setTcpListenAddr("[::]");
|
||||
n1.setUdpListenAddr("[::]");
|
||||
n1.setStatus(0);
|
||||
n1.setInx(0);
|
||||
long now = System.currentTimeMillis();
|
||||
n1.setCreatedTime(now);
|
||||
n1.setUpdatedTime(now);
|
||||
assertTrue(nodeService.save(n1));
|
||||
|
||||
Node n2 = new Node();
|
||||
n2.setName("n2");
|
||||
n2.setSecret("s2");
|
||||
n2.setServerIp("127.0.0.2");
|
||||
n2.setPort("1000-2000");
|
||||
n2.setInterfaceName("");
|
||||
n2.setHttp(0);
|
||||
n2.setTls(0);
|
||||
n2.setSocks(0);
|
||||
n2.setTcpListenAddr("[::]");
|
||||
n2.setUdpListenAddr("[::]");
|
||||
n2.setStatus(0);
|
||||
n2.setInx(0);
|
||||
n2.setCreatedTime(now);
|
||||
n2.setUpdatedTime(now);
|
||||
assertTrue(nodeService.save(n2));
|
||||
|
||||
Node n3 = new Node();
|
||||
n3.setName("n3");
|
||||
n3.setSecret("s3");
|
||||
n3.setServerIp("127.0.0.3");
|
||||
n3.setPort("1000-2000");
|
||||
n3.setInterfaceName("");
|
||||
n3.setHttp(0);
|
||||
n3.setTls(0);
|
||||
n3.setSocks(0);
|
||||
n3.setTcpListenAddr("[::]");
|
||||
n3.setUdpListenAddr("[::]");
|
||||
n3.setStatus(0);
|
||||
n3.setInx(0);
|
||||
n3.setCreatedTime(now);
|
||||
n3.setUpdatedTime(now);
|
||||
assertTrue(nodeService.save(n3));
|
||||
|
||||
List<Map<String, Object>> nodes = new ArrayList<>();
|
||||
nodes.add(mapIdInx(n2.getId(), 0));
|
||||
nodes.add(mapIdInx(n1.getId(), 1));
|
||||
nodes.add(mapIdInx(n3.getId(), 2));
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("nodes", nodes);
|
||||
|
||||
R res = nodeService.updateNodeOrder(params);
|
||||
assertEquals(0, res.getCode());
|
||||
|
||||
assertEquals(1, nodeService.getById(n1.getId()).getInx());
|
||||
assertEquals(0, nodeService.getById(n2.getId()).getInx());
|
||||
assertEquals(2, nodeService.getById(n3.getId()).getInx());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateTunnelOrder_updatesInx() {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
Tunnel t1 = new Tunnel();
|
||||
t1.setName("t1");
|
||||
t1.setType(1);
|
||||
t1.setFlow(1);
|
||||
t1.setTrafficRatio(new BigDecimal("1.0"));
|
||||
t1.setInIp("");
|
||||
t1.setStatus(1);
|
||||
t1.setInx(0);
|
||||
t1.setCreatedTime(now);
|
||||
t1.setUpdatedTime(now);
|
||||
assertTrue(tunnelService.save(t1));
|
||||
|
||||
Tunnel t2 = new Tunnel();
|
||||
t2.setName("t2");
|
||||
t2.setType(2);
|
||||
t2.setFlow(2);
|
||||
t2.setTrafficRatio(new BigDecimal("1.0"));
|
||||
t2.setInIp("");
|
||||
t2.setStatus(1);
|
||||
t2.setInx(0);
|
||||
t2.setCreatedTime(now);
|
||||
t2.setUpdatedTime(now);
|
||||
assertTrue(tunnelService.save(t2));
|
||||
|
||||
List<Map<String, Object>> tunnels = new ArrayList<>();
|
||||
tunnels.add(mapIdInx(t2.getId(), 0));
|
||||
tunnels.add(mapIdInx(t1.getId(), 1));
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("tunnels", tunnels);
|
||||
|
||||
R res = tunnelService.updateTunnelOrder(params);
|
||||
assertEquals(0, res.getCode());
|
||||
|
||||
assertEquals(1, tunnelService.getById(t1.getId()).getInx());
|
||||
assertEquals(0, tunnelService.getById(t2.getId()).getInx());
|
||||
}
|
||||
|
||||
private static Map<String, Object> mapIdInx(Long id, int inx) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", id);
|
||||
m.put("inx", inx);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const getNodeList = () => Network.post("/node/list");
|
||||
export const updateNode = (data: any) => Network.post("/node/update", data);
|
||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
|
||||
export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }> }) => Network.post("/node/update-order", data);
|
||||
export const checkNodeStatus = (nodeId?: number) => {
|
||||
const params = nodeId ? { nodeId } : {};
|
||||
return Network.post("/node/check-status", params);
|
||||
@@ -41,6 +42,7 @@ export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id })
|
||||
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
||||
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
||||
export const updateTunnelOrder = (data: { tunnels: Array<{ id: number; inx: number }> }) => Network.post("/tunnel/update-order", data);
|
||||
|
||||
// 用户隧道权限管理操作 - 全部使用POST请求
|
||||
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
|
||||
@@ -88,4 +90,4 @@ export const updateConfig = (name: string, value: string) => Network.post("/conf
|
||||
// 验证码相关接口
|
||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
|
||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
|
||||
|
||||
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'vite_config_';
|
||||
const VERSION = "2.0.9";
|
||||
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
const APP_VERSION = "1.0.3";
|
||||
const GITHUB_REPO = import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
|
||||
@@ -13,20 +13,41 @@ import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||
import toast from 'react-hot-toast';
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
|
||||
import {
|
||||
createNode,
|
||||
getNodeList,
|
||||
updateNode,
|
||||
deleteNode,
|
||||
getNodeInstallCommand
|
||||
getNodeInstallCommand,
|
||||
updateNodeOrder
|
||||
} from "@/api";
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
inx?: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
serverIp: string;
|
||||
serverIpV4?: string;
|
||||
serverIpV6?: string;
|
||||
port: string;
|
||||
tcpListenAddr?: string;
|
||||
udpListenAddr?: string;
|
||||
@@ -51,7 +72,9 @@ interface Node {
|
||||
interface NodeForm {
|
||||
id: number | null;
|
||||
name: string;
|
||||
serverIp: string;
|
||||
serverHost: string;
|
||||
serverIpV4: string;
|
||||
serverIpV6: string;
|
||||
port: string;
|
||||
tcpListenAddr: string;
|
||||
udpListenAddr: string;
|
||||
@@ -63,7 +86,10 @@ interface NodeForm {
|
||||
|
||||
export default function NodePage() {
|
||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [wsConnecting, setWsConnecting] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState('');
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
@@ -76,7 +102,9 @@ export default function NodePage() {
|
||||
const [form, setForm] = useState<NodeForm>({
|
||||
id: null,
|
||||
name: '',
|
||||
serverIp: '',
|
||||
serverHost: '',
|
||||
serverIpV4: '',
|
||||
serverIpV6: '',
|
||||
port: '1000-65535',
|
||||
tcpListenAddr: '[::]',
|
||||
udpListenAddr: '[::]',
|
||||
@@ -96,6 +124,31 @@ export default function NodePage() {
|
||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const offlineTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const offlineDelayMs = 3000;
|
||||
|
||||
const clearOfflineTimer = (nodeId: number) => {
|
||||
const timer = offlineTimersRef.current.get(nodeId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
offlineTimersRef.current.delete(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNodeOffline = (nodeId: number) => {
|
||||
if (offlineTimersRef.current.has(nodeId)) return;
|
||||
const timer = setTimeout(() => {
|
||||
offlineTimersRef.current.delete(nodeId);
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (node.connectionStatus === 'offline' && node.systemInfo === null) return node;
|
||||
return { ...node, connectionStatus: 'offline', systemInfo: null };
|
||||
})
|
||||
);
|
||||
}, offlineDelayMs);
|
||||
offlineTimersRef.current.set(nodeId, timer);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
@@ -112,12 +165,49 @@ export default function NodePage() {
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
if (res.code === 0) {
|
||||
setNodeList(res.data.map((node: any) => ({
|
||||
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||
...node,
|
||||
inx: node.inx ?? 0,
|
||||
connectionStatus: node.status === 1 ? 'online' : 'offline',
|
||||
systemInfo: null,
|
||||
copyLoading: false
|
||||
})));
|
||||
copyLoading: false,
|
||||
}));
|
||||
|
||||
setNodeList(nodesData);
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||
const hasDbOrdering = nodesData.some((n) => n.inx !== undefined && n.inx !== 0);
|
||||
if (hasDbOrdering) {
|
||||
const dbOrder = [...nodesData]
|
||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||
.map((n) => n.id);
|
||||
setNodeOrder(dbOrder);
|
||||
} else {
|
||||
try {
|
||||
const stored = localStorage.getItem('node-order');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
const existingIds = new Set(nodesData.map((n) => n.id));
|
||||
const validOrder = parsed
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => existingIds.has(id));
|
||||
|
||||
if (validOrder.length > 0) {
|
||||
setNodeOrder(validOrder);
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} catch {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error(res.msg || '加载节点列表失败');
|
||||
}
|
||||
@@ -143,12 +233,15 @@ export default function NodePage() {
|
||||
// 构建WebSocket URL,使用axios的baseURL
|
||||
const baseUrl = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/api\/v1\/$/, '') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
|
||||
|
||||
|
||||
try {
|
||||
setWsConnecting(true);
|
||||
websocketRef.current = new WebSocket(wsUrl);
|
||||
|
||||
websocketRef.current.onopen = () => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(true);
|
||||
setWsConnecting(false);
|
||||
};
|
||||
|
||||
websocketRef.current.onmessage = (event) => {
|
||||
@@ -166,9 +259,13 @@ export default function NodePage() {
|
||||
|
||||
websocketRef.current.onclose = () => {
|
||||
websocketRef.current = null;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
attemptReconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
attemptReconnect();
|
||||
}
|
||||
};
|
||||
@@ -176,21 +273,27 @@ export default function NodePage() {
|
||||
// 处理WebSocket消息
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
const { id, type, data: messageData } = data;
|
||||
const nodeId = Number(id);
|
||||
if (Number.isNaN(nodeId)) return;
|
||||
|
||||
if (type === 'status') {
|
||||
setNodeList(prev => prev.map(node => {
|
||||
if (node.id == id) {
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: messageData === 1 ? 'online' : 'offline',
|
||||
systemInfo: messageData === 0 ? null : node.systemInfo
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}));
|
||||
if (messageData === 1) {
|
||||
clearOfflineTimer(nodeId);
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (node.connectionStatus === 'online') return node;
|
||||
return { ...node, connectionStatus: 'online' };
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// 离线事件做延迟处理,避免短抖动导致频繁闪烁
|
||||
scheduleNodeOffline(nodeId);
|
||||
}
|
||||
} else if (type === 'info') {
|
||||
clearOfflineTimer(nodeId);
|
||||
setNodeList(prev => prev.map(node => {
|
||||
if (node.id == id) {
|
||||
if (node.id === nodeId) {
|
||||
try {
|
||||
let systemInfo;
|
||||
if (typeof messageData === 'string') {
|
||||
@@ -253,10 +356,12 @@ export default function NodePage() {
|
||||
|
||||
// 尝试重新连接
|
||||
const attemptReconnect = () => {
|
||||
if (reconnectTimerRef.current) return;
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
initWebSocket();
|
||||
}, 3000 * reconnectAttemptsRef.current);
|
||||
}
|
||||
@@ -268,8 +373,13 @@ export default function NodePage() {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
|
||||
offlineTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
offlineTimersRef.current.clear();
|
||||
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
|
||||
if (websocketRef.current) {
|
||||
websocketRef.current.onopen = null;
|
||||
@@ -284,12 +394,6 @@ export default function NodePage() {
|
||||
|
||||
websocketRef.current = null;
|
||||
}
|
||||
|
||||
setNodeList(prev => prev.map(node => ({
|
||||
...node,
|
||||
connectionStatus: 'offline',
|
||||
systemInfo: null
|
||||
})));
|
||||
};
|
||||
|
||||
|
||||
@@ -341,29 +445,20 @@ 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());
|
||||
|
||||
// Hostname/domain validation (no scheme/port)
|
||||
const hostnameRegex = /^(?=.{1,253}$)(?:[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 validateHostname = (host: string): boolean => {
|
||||
const v = host.trim();
|
||||
if (!v) return false;
|
||||
if (v === 'localhost') return true;
|
||||
return hostnameRegex.test(v);
|
||||
};
|
||||
|
||||
// 验证端口格式:支持 80,443,100-600
|
||||
@@ -429,10 +524,25 @@ 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();
|
||||
const host = form.serverHost.trim();
|
||||
|
||||
if (!v4 && !v6 && !host) {
|
||||
const msg = '请至少填写一个 IPv4/IPv6 地址或域名';
|
||||
newErrors.serverIpV4 = msg;
|
||||
newErrors.serverIpV6 = msg;
|
||||
newErrors.serverHost = msg;
|
||||
} else {
|
||||
if (v4 && !validateIpv4Literal(v4)) {
|
||||
newErrors.serverIpV4 = '请输入有效的IPv4地址';
|
||||
}
|
||||
if (v6 && !validateIpv6Literal(v6)) {
|
||||
newErrors.serverIpV6 = '请输入有效的IPv6地址';
|
||||
}
|
||||
if (host && !validateHostname(host)) {
|
||||
newErrors.serverHost = '请输入有效的域名/主机名';
|
||||
}
|
||||
}
|
||||
|
||||
const portValidation = validatePort(form.port);
|
||||
@@ -458,10 +568,18 @@ export default function NodePage() {
|
||||
const handleEdit = (node: Node) => {
|
||||
setDialogTitle('编辑节点');
|
||||
setIsEdit(true);
|
||||
|
||||
const legacy = (node.serverIp || '').trim();
|
||||
const normalizedV4 = node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : '');
|
||||
const normalizedV6 = node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : '');
|
||||
const normalizedHost = (!normalizedV4 && !normalizedV6 && legacy) ? legacy : '';
|
||||
|
||||
setForm({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
serverIp: node.serverIp || '',
|
||||
serverHost: normalizedHost,
|
||||
serverIpV4: normalizedV4,
|
||||
serverIpV6: normalizedV6,
|
||||
port: node.port || '1000-65535',
|
||||
tcpListenAddr: node.tcpListenAddr || '[::]',
|
||||
udpListenAddr: node.udpListenAddr || '[::]',
|
||||
@@ -552,8 +670,10 @@ export default function NodePage() {
|
||||
|
||||
try {
|
||||
const apiCall = isEdit ? updateNode : createNode;
|
||||
const data = {
|
||||
...form
|
||||
const { serverHost: _serverHost, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || ''
|
||||
};
|
||||
|
||||
const res = await apiCall(data);
|
||||
@@ -562,20 +682,22 @@ export default function NodePage() {
|
||||
setDialogVisible(false);
|
||||
|
||||
if (isEdit) {
|
||||
setNodeList(prev => prev.map(n =>
|
||||
n.id === form.id ? {
|
||||
...n,
|
||||
name: form.name,
|
||||
serverIp: form.serverIp,
|
||||
port: form.port,
|
||||
tcpListenAddr: form.tcpListenAddr,
|
||||
udpListenAddr: form.udpListenAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
http: form.http,
|
||||
tls: form.tls,
|
||||
socks: form.socks
|
||||
} : n
|
||||
));
|
||||
setNodeList(prev => prev.map(n =>
|
||||
n.id === form.id ? {
|
||||
...n,
|
||||
name: form.name,
|
||||
serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || '',
|
||||
serverIpV4: form.serverIpV4,
|
||||
serverIpV6: form.serverIpV6,
|
||||
port: form.port,
|
||||
tcpListenAddr: form.tcpListenAddr,
|
||||
udpListenAddr: form.udpListenAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
http: form.http,
|
||||
tls: form.tls,
|
||||
socks: form.socks
|
||||
} : n
|
||||
));
|
||||
} else {
|
||||
loadNodes();
|
||||
}
|
||||
@@ -594,7 +716,9 @@ export default function NodePage() {
|
||||
setForm({
|
||||
id: null,
|
||||
name: '',
|
||||
serverIp: '',
|
||||
serverHost: '',
|
||||
serverIpV4: '',
|
||||
serverIpV6: '',
|
||||
port: '1000-65535',
|
||||
tcpListenAddr: '[::]',
|
||||
udpListenAddr: '[::]',
|
||||
@@ -606,6 +730,120 @@ export default function NodePage() {
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over || active.id === over.id) return;
|
||||
if (!nodeOrder || nodeOrder.length === 0) return;
|
||||
|
||||
const activeId = Number(active.id);
|
||||
const overId = Number(over.id);
|
||||
if (isNaN(activeId) || isNaN(overId)) return;
|
||||
|
||||
const oldIndex = nodeOrder.indexOf(activeId);
|
||||
const newIndex = nodeOrder.indexOf(overId);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
|
||||
const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);
|
||||
setNodeOrder(newOrder);
|
||||
|
||||
// 保存到 localStorage
|
||||
try {
|
||||
localStorage.setItem('node-order', JSON.stringify(newOrder));
|
||||
} catch (error) {
|
||||
console.warn('无法保存节点排序到localStorage:', error);
|
||||
}
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
|
||||
const response = await updateNodeOrder({ nodes: nodesToUpdate });
|
||||
if (response.code === 0) {
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
const updated = nodesToUpdate.find((n) => n.id === node.id);
|
||||
return updated ? { ...node, inx: updated.inx } : node;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error('保存排序失败:' + (response.msg || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存节点排序到数据库失败:', error);
|
||||
toast.error('保存排序失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 传感器配置
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// 根据排序顺序获取节点列表
|
||||
const getSortedNodes = (): Node[] => {
|
||||
if (!nodeList || nodeList.length === 0) return [];
|
||||
|
||||
const sortedNodes = [...nodeList].sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
return aInx - bInx;
|
||||
});
|
||||
|
||||
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||
if (nodeOrder && nodeOrder.length > 0 && sortedNodes.every((n) => n.inx === undefined || n.inx === 0)) {
|
||||
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
|
||||
const localSorted: Node[] = [];
|
||||
|
||||
nodeOrder.forEach((id) => {
|
||||
const node = nodeMap.get(id);
|
||||
if (node) localSorted.push(node);
|
||||
});
|
||||
|
||||
nodeList.forEach((node) => {
|
||||
if (!nodeOrder.includes(node.id)) {
|
||||
localSorted.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return localSorted;
|
||||
}
|
||||
|
||||
return sortedNodes;
|
||||
};
|
||||
|
||||
const SortableItem = ({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
children: (listeners: any) => any;
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
transition: transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
@@ -626,6 +864,15 @@ export default function NodePage() {
|
||||
|
||||
</div>
|
||||
|
||||
{!wsConnected && (
|
||||
<Alert
|
||||
color="warning"
|
||||
variant="flat"
|
||||
description={wsConnecting ? '监控连接中...' : '监控连接已断开,正在重连...'}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 节点列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -651,18 +898,35 @@ export default function NodePage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{nodeList.map((node) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={getSortedNodes().map((n) => n.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{getSortedNodes().map((node) => (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
{...listeners}
|
||||
title="拖拽排序"
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
</div>
|
||||
<Chip
|
||||
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
|
||||
variant="flat"
|
||||
@@ -681,9 +945,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">
|
||||
@@ -825,9 +1104,13 @@ export default function NodePage() {
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</SortableItem>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑节点对话框 */}
|
||||
<Modal
|
||||
@@ -853,15 +1136,40 @@ export default function NodePage() {
|
||||
/>
|
||||
|
||||
<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}
|
||||
label="服务器域名/主机名"
|
||||
placeholder="例如: node.example.com"
|
||||
value={form.serverHost}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, serverHost: e.target.value }))}
|
||||
isInvalid={!!errors.serverHost}
|
||||
errorMessage={errors.serverHost}
|
||||
variant="bordered"
|
||||
description="可选:不带协议、不带端口。至少填写一个 IPv4/IPv6/域名"
|
||||
/>
|
||||
|
||||
<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="可用端口"
|
||||
placeholder="例如: 80,443,1000-65535"
|
||||
@@ -1123,4 +1431,4 @@ export default function NodePage() {
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,23 @@ import { Divider } from "@heroui/divider";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
|
||||
import {
|
||||
createTunnel,
|
||||
@@ -17,7 +34,8 @@ import {
|
||||
updateTunnel,
|
||||
deleteTunnel,
|
||||
getNodeList,
|
||||
diagnoseTunnel
|
||||
diagnoseTunnel,
|
||||
updateTunnelOrder
|
||||
} from "@/api";
|
||||
|
||||
interface ChainTunnel {
|
||||
@@ -30,6 +48,7 @@ interface ChainTunnel {
|
||||
|
||||
interface Tunnel {
|
||||
id: number;
|
||||
inx?: number;
|
||||
name: string;
|
||||
type: number; // 1: 端口转发, 2: 隧道转发
|
||||
inNodeId: ChainTunnel[]; // 入口节点列表
|
||||
@@ -87,6 +106,7 @@ interface DiagnosisResult {
|
||||
export default function TunnelPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
|
||||
// 模态框状态
|
||||
@@ -131,7 +151,45 @@ export default function TunnelPage() {
|
||||
]);
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
|
||||
...t,
|
||||
inx: t.inx ?? 0,
|
||||
}));
|
||||
setTunnels(tunnelsData);
|
||||
|
||||
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
|
||||
const hasDbOrdering = tunnelsData.some((t) => t.inx !== undefined && t.inx !== 0);
|
||||
if (hasDbOrdering) {
|
||||
const dbOrder = [...tunnelsData]
|
||||
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||
.map((t) => t.id);
|
||||
setTunnelOrder(dbOrder);
|
||||
} else {
|
||||
try {
|
||||
const stored = localStorage.getItem('tunnel-order');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
const existingIds = new Set(tunnelsData.map((t) => t.id));
|
||||
const validOrder = parsed
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => existingIds.has(id));
|
||||
|
||||
if (validOrder.length > 0) {
|
||||
setTunnelOrder(validOrder);
|
||||
} else {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
} else {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
} else {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
} catch {
|
||||
setTunnelOrder(tunnelsData.map((t) => t.id));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error(tunnelsRes.msg || '获取隧道列表失败');
|
||||
}
|
||||
@@ -481,6 +539,120 @@ export default function TunnelPage() {
|
||||
return { text: '😵 很差', color: 'danger' };
|
||||
};
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over || active.id === over.id) return;
|
||||
if (!tunnelOrder || tunnelOrder.length === 0) return;
|
||||
|
||||
const activeId = Number(active.id);
|
||||
const overId = Number(over.id);
|
||||
if (isNaN(activeId) || isNaN(overId)) return;
|
||||
|
||||
const oldIndex = tunnelOrder.indexOf(activeId);
|
||||
const newIndex = tunnelOrder.indexOf(overId);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
|
||||
const newOrder = arrayMove(tunnelOrder, oldIndex, newIndex);
|
||||
setTunnelOrder(newOrder);
|
||||
|
||||
// 保存到 localStorage
|
||||
try {
|
||||
localStorage.setItem('tunnel-order', JSON.stringify(newOrder));
|
||||
} catch (error) {
|
||||
console.warn('无法保存隧道排序到localStorage:', error);
|
||||
}
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
const tunnelsToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
|
||||
const response = await updateTunnelOrder({ tunnels: tunnelsToUpdate });
|
||||
if (response.code === 0) {
|
||||
setTunnels((prev) =>
|
||||
prev.map((tunnel) => {
|
||||
const updated = tunnelsToUpdate.find((t) => t.id === tunnel.id);
|
||||
return updated ? { ...tunnel, inx: updated.inx } : tunnel;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error('保存排序失败:' + (response.msg || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存隧道排序到数据库失败:', error);
|
||||
toast.error('保存排序失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 传感器配置
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// 根据排序顺序获取隧道列表
|
||||
const getSortedTunnels = (): Tunnel[] => {
|
||||
if (!tunnels || tunnels.length === 0) return [];
|
||||
|
||||
const sortedTunnels = [...tunnels].sort((a, b) => {
|
||||
const aInx = a.inx ?? 0;
|
||||
const bInx = b.inx ?? 0;
|
||||
return aInx - bInx;
|
||||
});
|
||||
|
||||
// 如果数据库中没有排序信息,则使用本地存储的顺序
|
||||
if (tunnelOrder && tunnelOrder.length > 0 && sortedTunnels.every((t) => t.inx === undefined || t.inx === 0)) {
|
||||
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
|
||||
const localSorted: Tunnel[] = [];
|
||||
|
||||
tunnelOrder.forEach((id) => {
|
||||
const tunnel = tunnelMap.get(id);
|
||||
if (tunnel) localSorted.push(tunnel);
|
||||
});
|
||||
|
||||
tunnels.forEach((tunnel) => {
|
||||
if (!tunnelOrder.includes(tunnel.id)) {
|
||||
localSorted.push(tunnel);
|
||||
}
|
||||
});
|
||||
|
||||
return localSorted;
|
||||
}
|
||||
|
||||
return sortedTunnels;
|
||||
};
|
||||
|
||||
const SortableItem = ({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
children: (listeners: any) => any;
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
transition: transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -516,17 +688,24 @@ export default function TunnelPage() {
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{tunnels.map((tunnel) => {
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={getSortedTunnels().map((t) => t.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{getSortedTunnels().map((tunnel) => {
|
||||
const typeDisplay = getTypeDisplay(tunnel.type);
|
||||
|
||||
return (
|
||||
<Card key={tunnel.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||
{(listeners) => (
|
||||
<Card key={tunnel.id} className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Chip
|
||||
color={typeDisplay.color as any}
|
||||
variant="flat"
|
||||
@@ -535,13 +714,23 @@ export default function TunnelPage() {
|
||||
>
|
||||
{typeDisplay.text}
|
||||
</Chip>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
{...listeners}
|
||||
title="拖拽排序"
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
<CardBody className="pt-0 pb-3">
|
||||
<div className="space-y-3">
|
||||
{/* 拓扑结构 */}
|
||||
<div className="pt-2 border-t border-divider">
|
||||
@@ -654,10 +843,14 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
</SortableItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
@@ -1629,4 +1822,4 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+9
@@ -1 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
readonly VITE_GITHUB_REPO?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user