Compare commits

...

15 Commits

Author SHA1 Message Date
root 2c2262b55d 完善分层 AGENTS.md,便于快速定位代码 2026-02-02 07:41:27 +00:00
root 7ca01aba5d 修复转发管理隧道下拉顺序不同步 2026-02-02 06:28:45 +00:00
sagit 0f57ec58b3 Merge pull request #10 from Sagit-chu/opencode/silent-falcon
优化拖拽排序顺滑度
2026-02-02 12:55:09 +08:00
root ac30f0172f 修复平铺模式用户切换与图表TS构建 2026-02-02 04:50:22 +00:00
root 531ba0bfed 新建隧道默认追加到末尾 2026-02-02 03:29:47 +00:00
root d787e4b07a 优化拖拽排序顺滑度 2026-02-02 03:14:18 +00:00
root 68e5d0ac0b 修复节点监控进度条频繁重置 2026-02-01 15:39:23 +00:00
root 38b70821c5 节点监控离线延迟去抖 2026-02-01 14:41:23 +00:00
root 936158dd32 节点地址输入支持域名 2026-02-01 12:34:31 +00:00
root e0d0553fd3 修复v6-only入口优先选择出口IPv6 2026-02-01 12:34:31 +00:00
root 78aa86b23c 前端版本号从tag注入并提交本地配置 2026-02-01 12:10:17 +00:00
root 1f850593dd 节点管理页支持填写IPv4/IPv6 2026-02-01 11:14:19 +00:00
root c4519c243a 组隧道链路按地址族自动匹配节点IP(v4优先) 2026-02-01 11:14:19 +00:00
root 31ccc48436 启动时迁移并回填节点双栈IP列 2026-02-01 11:14:19 +00:00
root 1c4914ec77 支持节点双栈IP字段 2026-02-01 11:14:19 +00:00
33 changed files with 1226 additions and 313 deletions
+5 -1
View File
@@ -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: |
+9
View File
@@ -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
+45 -22
View File
@@ -1,43 +1,66 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Sat Jan 24 2026
**Context:** Monorepo for Flux Panel (Traffic Forwarding)
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system based on [go-gost](https://github.com/go-gost/gost). It manages tunnels, port forwarding, and user quotas.
**Stack:** Monorepo (Java/Spring Boot Backend + React/Vite Frontend + Go/GOST Service).
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
/root/flux-panel/
├── springboot-backend/ # Java 21 + Spring Boot 2.7 Admin API
├── vite-frontend/ # React 18 + Vite + HeroUI/NextUI
├── go-gost/ # Go 1.23 + GOST Extensions (Core logic)
├── docker-compose*.yml # Deployment configs (v4/v6)
└── *.sh # Install scripts (panel_install.sh, install.sh)
./
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
├── android-app/ # Android WebView wrapper (optional)
├── ios-app/ # iOS WebView wrapper (optional)
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
├── install.sh # Node installer/upgrader (downloads gost binary)
└── .github/workflows/ # CI: build/push images + release artifacts
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Admin API** | `springboot-backend/` | Users, quotas, billing logic |
| **UI/Dashboard** | `vite-frontend/` | Management console |
| **Core Forwarding** | `go-gost/` | GOST implementation & extensions |
| **Deploy** | `docker-compose-v4.yml` | Container orchestration |
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
## CONVENTIONS
- **Monorepo**: 3 distinct languages/stacks. Treat each subdir as a separate project.
- **Docker**: Primary deployment method.
- **Scripts**: `panel_install.sh` for panel, `install.sh` for nodes.
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
## COMMANDS
```bash
# Quick Deploy (Panel)
./panel_install.sh
# Panel (Docker)
docker compose -f docker-compose-v4.yml up -d
docker compose -f docker-compose-v6.yml up -d
# Quick Deploy (Node)
# Release-based install scripts
./panel_install.sh
./install.sh
# Docker
docker-compose -f docker-compose-v4.yml up -d
# Local dev (per subproject)
(cd springboot-backend && mvn clean package)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
```
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
+21 -15
View File
@@ -1,31 +1,37 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Core forwarding service based on GOST v3.
**Stack:** Go 1.23, GOST Core v0.3.1, GOST x (Extensions).
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
## STRUCTURE
```
go-gost/
├── main.go # Entry point
├── x/ # Local extensions (REPLACES github.com/go-gost/x)
│ ├── api/ # Management API
│ ├── registry/ # Service registry
│ ├── handler/ # Protocol handlers (socks, tunnel, relay)
│ └── listener/ # Network listeners (tcp, udp, tun/tap)
└── go.mod # Defines local replacement
├── main.go # Entry; reads panel config.json; starts svc.Run(program)
├── config.go # Panel config.json loader (addr/secret + ports)
├── program.go # GOST runtime: parse config, run/reload services
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
└── go.mod # replace github.com/go-gost/x => ./x
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
## CONVENTIONS
- **Local Replace**: `go.mod` uses `replace github.com/go-gost/x => ./x`.
- **Extensions**: Custom logic lives in `x/`. This is the primary place for modifications.
- **Handlers**: Implements SOCKS5, Tunnel, Relay, etc.
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
## COMMANDS
```bash
# Run
cd go-gost
go run .
# Build
go test ./...
go build .
```
+42
View File
@@ -0,0 +1,42 @@
# GO-GOST/X KNOWLEDGE BASE
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
## STRUCTURE
```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
## ANTI-PATTERNS
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X API KNOWLEDGE BASE
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Route registration | `go-gost/x/api/api.go` | `Register(*gin.Engine, *Options)` |
| Auth gating | `go-gost/x/api/middleware.go` | Drops non-BasicAuth requests; optional auther check |
| Service CRUD + pause/resume | `go-gost/x/api/config_service.go` | Uses registry + `config.OnUpdate(...)` |
| Swagger spec | `go-gost/x/api/swagger.yaml` | Served at `/docs` via embedded FS |
## CONVENTIONS
- CORS is `AllowAllOrigins: true` (see `go-gost/x/api/api.go`).
- Requests without a valid Basic `Authorization` header are silently dropped (connection hijack + close) by `GlobalInterceptor()`.
- Many operations mutate the in-memory config via `config.OnUpdate(...)` after starting/stopping services.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Config structs + global state | `go-gost/x/config/config.go` | `Global()`, `Set()`, `OnUpdate()` |
| Default config file search | `go-gost/x/config/config.go` | Viper `SetConfigName("gost")` + paths `/etc/gost/`, `$HOME/.gost/`, `.` |
| Registry wiring | `go-gost/x/config/loader/loader.go` | Parses config sections and registers into registries |
| Metadata keys | `go-gost/x/config/parsing/parse.go` | `MDKey*` constants used by parsers |
| Config parser behavior | `go-gost/x/config/parsing/parser/parser.go` | CLI/env overrides; loads `gost.*` when empty |
## CONVENTIONS
- Default config file is named `gost` (e.g. `gost.json`) and is discovered via viper search paths.
- Runtime config mutations should go through `config.OnUpdate(...)` so changes are applied under the global mutex.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
## STRUCTURE
```
go-gost/x/dialer/
├── direct/ # Baseline dialer
├── tcp/
├── udp/
├── tls/
├── ws/
├── quic/
├── http2/
├── http3/
├── ssh/
├── wg/ # WireGuard dialer
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Pick a dialer | `go-gost/x/dialer/` | One subdir per transport |
| TCP baseline | `go-gost/x/dialer/tcp/dialer.go` | Reference implementation |
## CONVENTIONS
- Dialer implementations typically live in `dialer.go` with a paired `metadata.go` (e.g. `go-gost/x/dialer/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+32
View File
@@ -0,0 +1,32 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/handler/
├── http/ # handler.go + metadata.go (+ udp.go)
├── socks/ # SOCKS variants
├── tunnel/ # Tunnel forwarding
├── relay/ # Relay forwarding
├── redirect/ # TCP/UDP redirect handlers
├── router/ # Routing/association entrypoints
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Find a protocol handler | `go-gost/x/handler/` | Subdir per protocol (`http`, `socks`, `tunnel`, ...) |
| HTTP specifics | `go-gost/x/handler/http/handler.go` | Implements HTTP proxy behavior |
| SOCKS specifics | `go-gost/x/handler/socks/` | v4/v5 implementations |
## CONVENTIONS
- Handler implementations typically live in `handler.go` with a paired `metadata.go` (e.g. `go-gost/x/handler/http/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/listener/
├── tcp/ # listener.go + metadata.go
├── udp/
├── tls/
├── ws/
├── quic/
├── redirect/ # tcp/ + udp/
├── tun/ # TUN device listener
├── tap/ # TAP device listener
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Listener registry | `go-gost/x/listener/` | One subdir per transport |
| TCP baseline | `go-gost/x/listener/tcp/listener.go` | Reference for other transports |
| Redirect listeners | `go-gost/x/listener/redirect/` | Per-protocol accept + redirect |
| TUN/TAP | `go-gost/x/listener/tun/`, `go-gost/x/listener/tap/` | Virtual interface listeners |
## CONVENTIONS
- Listener implementations typically live in `listener.go` with a paired `metadata.go` (e.g. `go-gost/x/listener/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+3
View File
@@ -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>
+21 -19
View File
@@ -1,37 +1,39 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Admin API for Flux Panel. Manages users, licenses, and traffic rules.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus.
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # API Endpoints
│ ├── entity/ # DB Models (MyBatis Plus)
│ ├── mapper/ # Data Access
│ ├── service/ # Business Logic
│ └── common/ # Utils, DTOs
│ ├── controller/ # /api/v1/* endpoints
│ ├── entity/ # DB models
│ ├── mapper/ # MyBatis Plus mappers
│ ├── service/ # Business logic
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
│ └── common/ # DTOs, auth, exception handling, utilities
└── src/main/resources/
├── application.yml # Config
├── mapper/ # XML Mappers
├── data.sql # Init data
└── bgimages/ # Static resources
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
├── mapper/ # XML mappers
├── schema.sql # Schema
└── data.sql # Seed data
```
## CONVENTIONS
- **DB**: SQLite used via `sqlite-jdbc`.
- **ORM**: MyBatis Plus + MyBatis Plus Join.
- **JSON**: FastJSON2 used for serialization.
- **Utils**: Hutool used extensively.
- **Auth**: Likely custom or token-based (see `controller` logic).
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
## COMMANDS
```bash
# Build
cd springboot-backend
mvn clean package
# Run
mvn test
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
@@ -0,0 +1,32 @@
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
## OVERVIEW
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
## STRUCTURE
```
springboot-backend/src/main/java/com/admin/
├── controller/ # REST controllers (e.g., /api/v1/user)
├── service/ # Business logic interfaces + impl/
├── mapper/ # MyBatis Plus mappers
├── entity/ # DB entities
├── config/ # WebMvc/JWT/CORS/WebSocket config
└── common/ # DTOs, auth, exception handling, utilities
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
## CONVENTIONS
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
## ANTI-PATTERNS
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
@@ -16,6 +16,10 @@ public class NodeDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用端口不能为空")
private String port;
@@ -25,4 +29,4 @@ public class NodeDto {
private String udpListenAddr = "0.0.0.0";
}
}
@@ -17,6 +17,10 @@ public class NodeUpdateDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用port不能为空")
private String port;
@@ -28,4 +32,4 @@ public class NodeUpdateDto {
private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0";
}
}
@@ -7,6 +7,7 @@ import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import java.util.regex.Pattern;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -43,6 +44,7 @@ public class GostUtil {
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
JSONArray nodes = new JSONArray();
Node fromNode = node_s.get(node_id);
for (ChainTunnel chainTunnel : chainTunnels) {
JSONObject dialer = new JSONObject();
dialer.put("type", chainTunnel.getProtocol());
@@ -53,7 +55,11 @@ public class GostUtil {
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject node = new JSONObject();
node.put("name", "node_" + chainTunnel.getInx());
node.put("addr", processServerAddress(node_info.getServerIp() + ":" + chainTunnel.getPort()));
String dialHost = (fromNode != null && node_info != null)
? selectDialHost(fromNode, node_info)
: (node_info != null ? node_info.getServerIp() : null);
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
node.put("connector", connector);
node.put("dialer", dialer);
@@ -291,4 +297,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 {
}
}
}
@@ -20,9 +20,13 @@ public class Node extends BaseEntity {
private String name;
private String secret;
private String serverIp;
private String secret;
private String serverIp;
private String serverIpV4;
private String serverIpV6;
private String version;
@@ -45,5 +49,4 @@ public class Node extends BaseEntity {
* Lower value appears first.
*/
private Integer inx;
}
@@ -548,7 +548,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(firstChainNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), firstChainNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), firstChainNode.getPort(),
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
);
result.setFromChainType(1);
@@ -562,7 +562,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(1);
@@ -587,7 +587,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(nextNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), nextNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), nextNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -602,7 +602,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -21,7 +21,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.ArrayList;
@@ -54,21 +54,28 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Override
public R createNode(NodeDto nodeDto) {
validatePortRange(nodeDto.getPort());
Node node = new Node();
node.setSecret(IdUtil.simpleUUID());
node.setStatus(0);
node.setPort(nodeDto.getPort());
node.setName(nodeDto.getName());
node.setServerIp(nodeDto.getServerIp());
long currentTime = System.currentTimeMillis();
node.setCreatedTime(currentTime);
node.setUpdatedTime(currentTime);
node.setInterfaceName(nodeDto.getInterfaceName());
this.save(node);
return R.ok();
}
public R createNode(NodeDto nodeDto) {
validatePortRange(nodeDto.getPort());
String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
Node node = new Node();
node.setSecret(IdUtil.simpleUUID());
node.setStatus(0);
node.setPort(nodeDto.getPort());
node.setName(nodeDto.getName());
node.setServerIp(primaryServerIp);
node.setServerIpV4(normalizedV4);
node.setServerIpV6(normalizedV6);
long currentTime = System.currentTimeMillis();
node.setCreatedTime(currentTime);
node.setUpdatedTime(currentTime);
node.setInterfaceName(nodeDto.getInterfaceName());
this.save(node);
return R.ok();
}
@Override
public R getAllNodes() {
@@ -388,22 +395,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
}
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
validatePortRange(nodeUpdateDto.getPort());
Node node = new Node();
node.setId(nodeUpdateDto.getId());
node.setName(nodeUpdateDto.getName());
node.setServerIp(nodeUpdateDto.getServerIp());
node.setPort(nodeUpdateDto.getPort());
node.setHttp(nodeUpdateDto.getHttp());
node.setTls(nodeUpdateDto.getTls());
node.setSocks(nodeUpdateDto.getSocks());
node.setUpdatedTime(System.currentTimeMillis());
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
return node;
}
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
validatePortRange(nodeUpdateDto.getPort());
String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
Node node = new Node();
node.setId(nodeUpdateDto.getId());
node.setName(nodeUpdateDto.getName());
node.setServerIp(primaryServerIp);
node.setServerIpV4(normalizedV4);
node.setServerIpV6(normalizedV6);
node.setPort(nodeUpdateDto.getPort());
node.setHttp(nodeUpdateDto.getHttp());
node.setTls(nodeUpdateDto.getTls());
node.setSocks(nodeUpdateDto.getSocks());
node.setUpdatedTime(System.currentTimeMillis());
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
return node;
}
private String pickPrimaryServerIp(String serverIpV4, String serverIpV6, String fallback) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
return fallback != null ? fallback.trim() : null;
}
private String normalizeV4(String serverIpV4, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv4(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private String normalizeV6(String serverIpV6, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv6(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private boolean looksLikeIpv4(String value) {
// 仅用于判定地址族(不解析域名)
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
return ipv4.matcher(value).matches();
}
private boolean looksLikeIpv6(String value) {
// 粗略判定 IPv6(与 GostUtil.processServerAddress 一致思路)
long colonCount = value.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
private void validatePortRange(String port) {
@@ -117,14 +117,39 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
long currentTime = System.currentTimeMillis();
tunnel.setCreatedTime(currentTime);
tunnel.setUpdatedTime(currentTime);
// When tunnels are ordered via `inx`, new tunnels should be appended.
// Only apply this when an order already exists (max `inx` > 0) to avoid
// changing behavior for deployments still relying on local ordering.
Tunnel lastByInx = this.getOne(new QueryWrapper<Tunnel>()
.select("inx")
.orderByDesc("inx")
.orderByDesc("id")
.last("LIMIT 1"));
Integer maxInx = lastByInx == null ? null : lastByInx.getInx();
if (maxInx != null && maxInx > 0) {
tunnel.setInx(maxInx + 1);
}
if (StringUtils.isEmpty(tunnel.getInIp())){
StringBuilder in_ip = new StringBuilder();
java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) {
Node node = nodes.get(chainTunnel.getNodeId());
in_ip.append(node.getServerIp()).append(",");
if (node == null) continue;
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV4())) {
inIps.add(node.getServerIpV4().trim());
}
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV6())) {
inIps.add(node.getServerIpV6().trim());
}
if (cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV4())
&& cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV6())
&& cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIp())) {
inIps.add(node.getServerIp().trim());
}
}
if (!inIps.isEmpty()) {
tunnel.setInIp(String.join(",", inIps));
}
in_ip.deleteCharAt(in_ip.length() - 1);
tunnel.setInIp(in_ip.toString());
}
this.save(tunnel);
@@ -143,11 +168,33 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel in_node : tunnelDto.getInNodeId()) {
// 创建Chain, 指向chainNode的第一跳。如果chainNode为空就是指向出口
if (tunnelDto.getChainNodes().isEmpty()) { // 指向出口
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
isError(gostDto);
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
return R.err(e.getMessage());
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
return R.err(gostDto.getMsg());
}
} else {
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", in_node.getNodeId());
@@ -171,7 +218,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel chainTunnel : chainTunnels1) {
int inx = i+1;
if (inx >= tunnelDto.getChainNodes().size()) { // 指向出口
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId());
@@ -187,7 +245,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return R.err(gostDto.getMsg());
}
} else {
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
GostDto gostDto;
try {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId());
@@ -457,19 +526,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());
@@ -518,7 +597,10 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Integer roleId = JwtUtil.getRoleIdFromToken();
Integer userId = JwtUtil.getUserIdFromToken();
if (roleId == 0) {
tunnelEntities = this.list(new QueryWrapper<Tunnel>().eq("status", 1));
tunnelEntities = this.list(new QueryWrapper<Tunnel>()
.eq("status", 1)
.orderByAsc("inx")
.orderByAsc("id"));
} else {
tunnelEntities = java.util.Collections.emptyList(); // 返回空列表
List<UserTunnel> userTunnels = userTunnelMapper.selectList(
@@ -530,7 +612,9 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
.collect(Collectors.toList());
tunnelEntities = this.list(new QueryWrapper<Tunnel>()
.in("id", tunnelIds)
.eq("status", 1));
.eq("status", 1)
.orderByAsc("inx")
.orderByAsc("id"));
}
}
@@ -596,7 +680,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(firstChainNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), firstChainNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), firstChainNode.getPort(),
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
);
result.setFromChainType(1); // 入口
@@ -610,7 +694,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(1);
@@ -634,7 +718,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(nextNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), nextNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), nextNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -649,7 +733,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
fromNode, toNode.getServerIp(), outNode.getPort(),
fromNode, GostUtil.selectDialHost(fromNode, toNode), outNode.getPort(),
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
);
result.setFromChainType(2);
@@ -740,7 +824,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),
+16 -14
View File
@@ -1,34 +1,36 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Web management console for Flux Panel.
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI (NextUI).
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI.
## STRUCTURE
```
vite-frontend/
├── src/
│ ├── pages/ # Route views
│ ├── pages/ # Route views (some very large single-file pages)
│ ├── components/ # Reusable UI parts
│ ├── layouts/ # Page wrappers
│ ├── api/ # Axios wrappers
│ ├── config/ # App settings
│ └── utils/ # Helpers
├── vite.config.ts # Vite config (Base: '/')
│ ├── layouts/ # Admin vs H5 layouts
│ ├── api/ # API functions + axios wrapper
│ ├── config/ # Site config (title, repo, version)
│ └── utils/ # Auth/JWT + WebView helpers
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
├── eslint.config.mjs # ESLint 9 flat config
└── package.json
```
## CONVENTIONS
- **UI Lib**: HeroUI (formerly NextUI) + Tailwind CSS 4.
- **Routing**: React Router DOM 6.
- **State**: Check `provider.tsx` or local state.
- **Build**: Output to `dist/`.
- **Routing**: React Router v6 routes in `vite-frontend/src/App.tsx`.
- **Auth**: JWT stored as `localStorage.token`; sent as `Authorization` header (no prefix) in `vite-frontend/src/api/network.ts`.
- **Base URL**: Defaults to `/api/v1/` (or `VITE_API_BASE`); WebView mode selects a panel address via `vite-frontend/src/utils/panel.ts`.
- **UI**: HeroUI provider + theme + toast wired in `vite-frontend/src/provider.tsx`.
## COMMANDS
```bash
# Dev
cd vite-frontend
npm run dev
# Build
npm run build
npm run lint
```
+38
View File
@@ -0,0 +1,38 @@
# VITE FRONTEND (src) KNOWLEDGE BASE
## OVERVIEW
React app entry + routing + providers. This is where UI architecture decisions live.
## STRUCTURE
```
vite-frontend/src/
├── main.tsx # ReactDOM + BrowserRouter + Provider
├── provider.tsx # HeroUI + theme + toaster + i18n wrapper
├── App.tsx # Routes + ProtectedRoute + H5 layout selection
├── api/ # Axios wrapper + typed endpoint helpers
├── pages/ # Route views (large)
├── layouts/ # Admin/H5 page chrome
├── components/ # Shared UI components
├── utils/ # JWT parsing + auth helpers + WebView utilities
└── styles/ # globals.css
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Route definitions | `vite-frontend/src/App.tsx` | React Router v6 |
| API client baseURL | `vite-frontend/src/api/network.ts` | `/api/v1/` + token header |
| Token decoding | `vite-frontend/src/utils/jwt.ts` | Checks `exp` vs now |
| Role checks | `vite-frontend/src/utils/auth.ts` | `isAdmin()` is `role_id == 0` |
| WebView integration | `vite-frontend/src/api/network.ts` | Panel address selection in WebView mode |
## CONVENTIONS
- Token is stored in `localStorage.token` and sent as `Authorization` header (raw token string).
- H5 mode detection is in `vite-frontend/src/App.tsx` (screen/user-agent/query param `h5=true`).
## COMMANDS
```bash
cd vite-frontend
npm run dev
npm run lint
```
+16
View File
@@ -0,0 +1,16 @@
# VITE FRONTEND (src/api) KNOWLEDGE BASE
## OVERVIEW
API client layer. Wraps axios and normalizes backend responses (`{ code, msg, data }`).
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Axios wrapper | `vite-frontend/src/api/network.ts` | Sets `axios.defaults.baseURL`; adds `Authorization` header |
| BaseURL init (WebView vs web) | `vite-frontend/src/api/network.ts` | WebView mode calls `getPanelAddresses()` |
| Endpoint functions | `vite-frontend/src/api/index.ts` | Mostly `Network.post("/…")` |
## CONVENTIONS
- Default baseURL is `/api/v1/` (or `${VITE_API_BASE}/api/v1/`).
- In WebView mode, baseURL is derived from the selected panel address; if unset, requests return `code: -1` with a “set panel address” message.
- 401 responses clear localStorage and redirect to `/`.
+1 -1
View File
@@ -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";
+30
View File
@@ -0,0 +1,30 @@
# VITE FRONTEND (pages) KNOWLEDGE BASE
## OVERVIEW
Route views rendered by `vite-frontend/src/App.tsx`. Several pages are large, single-file screens.
## STRUCTURE
```
vite-frontend/src/pages/
├── index.tsx # Login + captcha flow
├── dashboard.tsx
├── forward.tsx # Large
├── tunnel.tsx # Large
├── node.tsx # Large
├── user.tsx # Large
├── config.tsx
├── limit.tsx
├── profile.tsx
├── settings.tsx
└── change-password.tsx
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Login flow | `vite-frontend/src/pages/index.tsx` | Calls `login()` and stores `localStorage.token` |
| API calls | `vite-frontend/src/api/index.ts` | Thin wrappers around `Network.post` |
| Token expiration behavior | `vite-frontend/src/api/network.ts` | Clears localStorage + redirects on 401 |
## CONVENTIONS
- Pages call API wrappers from `vite-frontend/src/api/index.ts` (most endpoints are POST).
+40 -26
View File
@@ -741,31 +741,45 @@ export default function DashboardPage() {
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
tickFormatter={(value) => {
if (value === 0) return '0';
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}K`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)}M`;
return `${(value / (1024 * 1024 * 1024)).toFixed(1)}G`;
}}
/>
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
<p className="font-medium text-foreground">{`时间: ${label}`}</p>
<p className="text-primary">
{`流量: ${formatFlow(payload[0]?.value as number || 0)}`}
</p>
</div>
);
}
return null;
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb', strokeWidth: 1 }}
tickFormatter={(value: number | string) => {
const v = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(v)) return String(value);
if (v === 0) return '0';
if (v < 1024) return `${v}B`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)}M`;
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
}}
/>
<Tooltip
content={({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ value?: number | string }>;
label?: string | number;
}) => {
if (active && payload && payload.length) {
const firstValue = payload[0]?.value;
const numericValue = typeof firstValue === 'number' ? firstValue : Number(firstValue);
const flowValue = Number.isFinite(numericValue) ? numericValue : 0;
return (
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
<p className="font-medium text-foreground">{`时间: ${label ?? ''}`}</p>
<p className="text-primary">
{`流量: ${formatFlow(flowValue)}`}
</p>
</div>
);
}
return null;
}}
/>
<Line
@@ -983,4 +997,4 @@ export default function DashboardPage() {
</div>
);
}
}
+42 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
@@ -15,7 +15,8 @@ import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
DragEndEvent,
@@ -353,8 +354,6 @@ export default function ForwardPage() {
const userMap = new Map<string, UserGroup>();
// 获取排序后的转发列表
const sortedForwards = getSortedForwards();
sortedForwards.forEach(forward => {
const userKey = forward.userId ? forward.userId.toString() : 'unknown';
const userName = forward.userName || '未知用户';
@@ -855,7 +854,7 @@ export default function ForwardPage() {
);
} else {
// 直接显示模式下,过滤指定隧道的转发
forwardsToExport = getSortedForwards().filter(forward => forward.tunnelId === selectedTunnelForExport);
forwardsToExport = sortedForwards.filter(forward => forward.tunnelId === selectedTunnelForExport);
}
if (forwardsToExport.length === 0) {
@@ -1102,64 +1101,80 @@ export default function ForwardPage() {
// 传感器配置 - 使用默认配置避免错误
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const tokenUserId = JwtUtil.getUserIdFromToken();
// 根据排序顺序获取转发列表
const getSortedForwards = (): Forward[] => {
const sortedForwards = useMemo((): Forward[] => {
// 确保 forwards 数组存在且有效
if (!forwards || forwards.length === 0) {
return [];
}
// 在平铺模式下,只显示当前用户的转发
let filteredForwards = forwards;
if (viewMode === 'direct') {
const currentUserId = JwtUtil.getUserIdFromToken();
if (currentUserId !== null) {
filteredForwards = forwards.filter(forward => forward.userId === currentUserId);
if (tokenUserId !== null) {
filteredForwards = forwards.filter(forward => forward.userId === tokenUserId);
}
}
// 确保过滤后的转发列表有效
if (!filteredForwards || filteredForwards.length === 0) {
return [];
}
// 优先使用数据库中的 inx 字段进行排序
const sortedForwards = [...filteredForwards].sort((a, b) => {
const sortedByDb = [...filteredForwards].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
// 如果数据库中没有排序信息,则使用本地存储的顺序
if (forwardOrder && forwardOrder.length > 0 && sortedForwards.every(f => f.inx === undefined || f.inx === 0)) {
if (forwardOrder && forwardOrder.length > 0 && sortedByDb.every(f => f.inx === undefined || f.inx === 0)) {
const forwardMap = new Map(filteredForwards.map(f => [f.id, f]));
const localSortedForwards: Forward[] = [];
forwardOrder.forEach(id => {
const forward = forwardMap.get(id);
if (forward) {
localSortedForwards.push(forward);
}
});
// 添加不在排序列表中的转发(新添加的)
filteredForwards.forEach(forward => {
if (!forwardOrder.includes(forward.id)) {
localSortedForwards.push(forward);
}
});
return localSortedForwards;
}
return sortedForwards;
};
return sortedByDb;
}, [forwards, forwardOrder, viewMode, tokenUserId]);
const sortableForwardIds = useMemo(
() => sortedForwards.map(f => f.id).filter(id => id > 0),
[sortedForwards]
);
// 可拖拽的转发卡片组件
const SortableForwardCard = ({ forward }: { forward: Forward }) => {
@@ -1179,8 +1194,9 @@ export default function ForwardPage() {
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
};
return (
@@ -1529,11 +1545,11 @@ export default function ForwardPage() {
onDragStart={() => {}} // 添加空的 onDragStart 处理器
>
<SortableContext
items={getSortedForwards().map(f => f.id || 0).filter(id => id > 0)}
items={sortableForwardIds}
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">
{getSortedForwards().map((forward) => (
{sortedForwards.map((forward) => (
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null
@@ -2394,4 +2410,4 @@ export default function ForwardPage() {
</div>
);
}
}
+242 -112
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
@@ -16,7 +16,8 @@ import axios from 'axios';
import {
DndContext,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
type DragEndEvent,
useSensor,
useSensors,
@@ -46,6 +47,8 @@ interface Node {
name: string;
ip: string;
serverIp: string;
serverIpV4?: string;
serverIpV6?: string;
port: string;
tcpListenAddr?: string;
udpListenAddr?: string;
@@ -70,7 +73,9 @@ interface Node {
interface NodeForm {
id: number | null;
name: string;
serverIp: string;
serverHost: string;
serverIpV4: string;
serverIpV6: string;
port: string;
tcpListenAddr: string;
udpListenAddr: string;
@@ -80,10 +85,42 @@ interface NodeForm {
socks: number; // 0 关 1 开
}
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: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
{children(listeners)}
</div>
);
};
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);
@@ -96,7 +133,9 @@ export default function NodePage() {
const [form, setForm] = useState<NodeForm>({
id: null,
name: '',
serverIp: '',
serverHost: '',
serverIpV4: '',
serverIpV6: '',
port: '1000-65535',
tcpListenAddr: '[::]',
udpListenAddr: '[::]',
@@ -116,6 +155,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();
@@ -200,12 +264,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) => {
@@ -223,9 +290,13 @@ export default function NodePage() {
websocketRef.current.onclose = () => {
websocketRef.current = null;
setWsConnected(false);
setWsConnecting(false);
attemptReconnect();
};
} catch (error) {
setWsConnected(false);
setWsConnecting(false);
attemptReconnect();
}
};
@@ -233,21 +304,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') {
@@ -310,10 +387,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);
}
@@ -325,8 +404,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;
@@ -341,12 +425,6 @@ export default function NodePage() {
websocketRef.current = null;
}
setNodeList(prev => prev.map(node => ({
...node,
connectionStatus: 'offline',
systemInfo: null
})));
};
@@ -398,29 +476,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
@@ -486,10 +555,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);
@@ -515,10 +599,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 || '[::]',
@@ -609,8 +701,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);
@@ -619,20 +713,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();
}
@@ -651,7 +747,9 @@ export default function NodePage() {
setForm({
id: null,
name: '',
serverIp: '',
serverHost: '',
serverIpV4: '',
serverIpV6: '',
port: '1000-65535',
tcpListenAddr: '[::]',
udpListenAddr: '[::]',
@@ -710,24 +808,34 @@ export default function NodePage() {
// 传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 根据排序顺序获取节点列表
const getSortedNodes = (): Node[] => {
const sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
const sortedNodes = [...nodeList].sort((a, b) => {
const sortedByDb = [...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)) {
if (nodeOrder && nodeOrder.length > 0 && sortedByDb.every((n) => n.inx === undefined || n.inx === 0)) {
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
const localSorted: Node[] = [];
@@ -745,37 +853,10 @@ export default function NodePage() {
return localSorted;
}
return sortedNodes;
};
return sortedByDb;
}, [nodeList, nodeOrder]);
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>
);
};
const sortableNodeIds = useMemo(() => sortedNodes.map((n) => n.id), [sortedNodes]);
return (
@@ -797,6 +878,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">
@@ -824,11 +914,11 @@ export default function NodePage() {
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={getSortedNodes().map((n) => n.id)}
items={sortableNodeIds}
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) => (
{sortedNodes.map((node) => (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
<Card
@@ -869,9 +959,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">
@@ -1045,15 +1150,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"
+25 -11
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input, Textarea } from "@heroui/input";
@@ -13,7 +13,8 @@ import toast from 'react-hot-toast';
import {
DndContext,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
type DragEndEvent,
useSensor,
useSensors,
@@ -586,24 +587,34 @@ export default function TunnelPage() {
// 传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 根据排序顺序获取隧道列表
const getSortedTunnels = (): Tunnel[] => {
const sortedTunnels = useMemo((): Tunnel[] => {
if (!tunnels || tunnels.length === 0) return [];
const sortedTunnels = [...tunnels].sort((a, b) => {
const sortedByDb = [...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)) {
if (tunnelOrder && tunnelOrder.length > 0 && sortedByDb.every((t) => t.inx === undefined || t.inx === 0)) {
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
const localSorted: Tunnel[] = [];
@@ -621,8 +632,10 @@ export default function TunnelPage() {
return localSorted;
}
return sortedTunnels;
};
return sortedByDb;
}, [tunnels, tunnelOrder]);
const sortableTunnelIds = useMemo(() => sortedTunnels.map((t) => t.id), [sortedTunnels]);
const SortableItem = ({
id,
@@ -642,8 +655,9 @@ export default function TunnelPage() {
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: 'transform',
};
return (
@@ -690,11 +704,11 @@ export default function TunnelPage() {
{tunnels.length > 0 ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={getSortedTunnels().map((t) => t.id)}
items={sortableTunnelIds}
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) => {
{sortedTunnels.map((tunnel) => {
const typeDisplay = getTypeDisplay(tunnel.type);
return (
+11
View File
@@ -0,0 +1,11 @@
declare module 'recharts' {
import * as React from 'react';
export const LineChart: React.ComponentType<Record<string, unknown>>;
export const Line: React.ComponentType<Record<string, unknown>>;
export const XAxis: React.ComponentType<Record<string, unknown>>;
export const YAxis: React.ComponentType<Record<string, unknown>>;
export const CartesianGrid: React.ComponentType<Record<string, unknown>>;
export const Tooltip: React.ComponentType<Record<string, unknown>>;
export const ResponsiveContainer: React.ComponentType<Record<string, unknown>>;
}
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
readonly VITE_GITHUB_REPO?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}