diff --git a/AGENTS.md b/AGENTS.md index 6d9c6ce..2f59aeb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ **Generated:** Tue Mar 24 2026 **Commit:** 8ebde9d **Branch:** main -**Tag:** 2.1.9-rc7 +**Tag:** 2.1.9-rc8 ## OVERVIEW FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers. diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index b7ce1a7..7337c32 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -1102,50 +1102,111 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in if err != nil { continue } - port := pickForwardPortFromRecords(oldPorts) - if port <= 0 { + referencePort := pickForwardPortFromRecords(oldPorts) + if referencePort <= 0 { continue } - // If the existing port is outside any entry node's allowed range, - // pick a new random port that satisfies all entry nodes. - if !h.isPortValidForAllEntryNodes(port, entryNodeIDs) { - newPort := h.pickTunnelPort(tunnelID) - if newPort > 0 { - port = newPort + // Build a map of existing node → port/inIP from old records. + oldPortByNode := make(map[int64]forwardPortRecord) + for _, fp := range oldPorts { + if fp.NodeID > 0 { + oldPortByNode[fp.NodeID] = fp } } - var entries []forwardPortReplaceEntry - if allowInIP { - entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port) - } else { - entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs)) - for _, nid := range entryNodeIDs { - entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""}) + entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs)) + for _, nid := range entryNodeIDs { + if existing, ok := oldPortByNode[nid]; ok && existing.Port > 0 { + // Existing entry node: keep its current port. + inIP := existing.InIP + if !allowInIP { + inIP = "" + } + entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: existing.Port, InIP: inIP}) + continue } + + // New entry node: try to follow the reference port. + port := h.resolvePortForNewEntryNode(nid, referencePort, f.ID) + inIP := "" + if allowInIP { + // For single-entry tunnels, try to preserve inIP from old records. + for _, fp := range oldPorts { + if strings.TrimSpace(fp.InIP) != "" { + inIP = fp.InIP + break + } + } + } + entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIP}) } _ = h.repo.ReplaceForwardPorts(f.ID, entries) } } -func (h *Handler) isPortValidForAllEntryNodes(port int, entryNodeIDs []int64) bool { - if port <= 0 { - return false +// resolvePortForNewEntryNode determines the port for a forward on a newly added +// entry node. It tries to reuse referencePort (from existing entries); if that +// port is out of range or already occupied, it picks a random available port +// for this specific node. +func (h *Handler) resolvePortForNewEntryNode(nodeID int64, referencePort int, forwardID int64) int { + node, err := h.getNodeRecord(nodeID) + if err != nil { + return referencePort } - for _, nodeID := range entryNodeIDs { - node, err := h.getNodeRecord(nodeID) - if err != nil { - continue - } - if validateLocalNodePort(node, port) != nil { - return false - } - if validateRemoteNodePort(node, port) != nil { - return false + + // Check if referencePort is within the node's allowed range. + if validateLocalNodePort(node, referencePort) == nil && + validateRemoteNodePort(node, referencePort) == nil { + // In range — check availability. + occupied, occErr := h.repo.HasOtherForwardOnNodePort(nodeID, referencePort, forwardID) + if occErr == nil && !occupied { + return referencePort } } - return true + + // referencePort doesn't work for this node; pick a random one. + newPort := h.pickRandomPortForNode(nodeID) + if newPort > 0 { + return newPort + } + return referencePort // last resort fallback +} + +// pickRandomPortForNode picks a random available port from a single node's +// port range, excluding ports already occupied by other forwards or chains. +func (h *Handler) pickRandomPortForNode(nodeID int64) int { + portRange, err := h.repo.GetNodePortRange(nodeID) + if err != nil { + return 0 + } + if portRange == "" { + portRange = "1000-65535" + } + + nodePorts, err := parsePorts(portRange) + if err != nil || len(nodePorts) == 0 { + return 0 + } + + used, err := h.getUsedPorts(nodeID) + if err != nil { + return 0 + } + + var available []int + for _, p := range nodePorts { + if !used[p] { + available = append(available, p) + } + } + + if len(available) == 0 { + return 0 + } + + idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(available)))) + return available[idx.Int64()] } func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) { diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 8c7ca4d..e55c329 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -3348,7 +3348,7 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model err := r.db.Model(&model.NodeMetric{}). Select( fmt.Sprintf( - "? AS node_id, "+ + "%d AS node_id, "+ "CAST(%s AS INTEGER) AS timestamp, "+ "AVG(cpu_usage) AS cpu_usage, "+ "AVG(mem_usage) AS mem_usage, "+ @@ -3363,9 +3363,8 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model "CAST(AVG(tcp_conns) AS INTEGER) AS tcp_conns, "+ "CAST(AVG(udp_conns) AS INTEGER) AS udp_conns, "+ "CAST(MAX(uptime) AS INTEGER) AS uptime", - bucketExpr, + nodeID, bucketExpr, ), - nodeID, ). Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs). Group(groupExpr). diff --git a/plans/013-tunnel-entry-port-validation.md b/plans/013-tunnel-entry-port-validation.md index 3b18615..b21bf32 100644 --- a/plans/013-tunnel-entry-port-validation.md +++ b/plans/013-tunnel-entry-port-validation.md @@ -2,18 +2,20 @@ ## Issue - GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373) -- 添加隧道入口时,端口校验不严格 - 默认端口可能不在新入口设置范围内 - -## 问题分析 -当已有隧道上添加新入口节点时,系统会使用既有转发的端口部署到新入口节点上, -但该端口可能不在新入口节点设置的端口范围内,且没有校验提示。 - -### 根因 -`syncTunnelForwardsEntryPorts` 函数在同步入口端口时,直接复用了原有端口, -没有检查该端口是否在新入口节点的允许范围内。 ## 修复方案 -- [x] 1. 新增 `isPortValidForAllEntryNodes` 辅助方法,校验端口是否在各节点范围内 -- [x] 2. 在 `syncTunnelForwardsEntryPorts` 中检测端口超范围时,通过 `pickTunnelPort` 自动随机分配新端口 -- [x] 3. 构建通过 + 全量测试通过 +在 `syncTunnelForwardsEntryPorts` 中实现逐节点端口分配: + +- **旧入口节点**:保留原端口不变 +- **新入口节点**:通过 `resolvePortForNewEntryNode` 决策: + - 参考端口在范围内且未被占用 → 跟随设置一样的端口 + - 参考端口超出范围或被占用 → 通过 `pickRandomPortForNode` 为该节点单独随机分配 + +## 任务清单 + +- [x] 1. 实现 `pickRandomPortForNode` 辅助方法(单节点端口随机分配) +- [x] 2. 实现 `resolvePortForNewEntryNode` 方法(端口决策逻辑) +- [x] 3. 重写 `syncTunnelForwardsEntryPorts` 为逐节点分配 +- [x] 4. 移除不再需要的 `isPortValidForAllEntryNodes` +- [x] 5. 构建通过 + 全量测试通过