mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
17 Commits
2.1.9-rc6
...
2.1.9-rc12
| Author | SHA1 | Date | |
|---|---|---|---|
| 400a40fe80 | |||
| 103290ed35 | |||
| 363e714603 | |||
| afd1258fcd | |||
| e69082a596 | |||
| d30363d164 | |||
| 8e1a87bf5a | |||
| 6180b5a198 | |||
| 61d95ab5d5 | |||
| c27be19915 | |||
| f62a35c3f9 | |||
| 2a1caf32c4 | |||
| fdcc30a493 | |||
| efaffb0475 | |||
| 4954526cbc | |||
| 9d50071915 | |||
| ceceee6ebd |
@@ -3,7 +3,7 @@
|
||||
**Generated:** Tue Mar 24 2026
|
||||
**Commit:** 8ebde9d
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-rc6
|
||||
**Tag:** 2.1.9-rc10
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -280,6 +280,16 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
for _, fp := range ports {
|
||||
if limiterID != nil && speed != nil {
|
||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||
// If the limiter push fails because the node is offline, skip it with a warning
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
node, _ := h.getNodeRecord(fp.NodeID)
|
||||
nodeName := fmt.Sprintf("%d", fp.NodeID)
|
||||
if node != nil && strings.TrimSpace(node.Name) != "" {
|
||||
nodeName = strings.TrimSpace(node.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -308,6 +318,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
}
|
||||
// When a node is offline, skip it with a warning instead of failing.
|
||||
// This lets users modify forward rules even when some entry nodes are down.
|
||||
if err != nil && isNodeOfflineOrTimeoutError(err) {
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", node.Name))
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||
}
|
||||
|
||||
@@ -345,21 +345,39 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
|
||||
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("expected v6 address for cross-version, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) {
|
||||
// v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("expected v4 address for cross-version, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) {
|
||||
// 真正不兼容:两个节点都没有任何 IP
|
||||
from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
|
||||
to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
|
||||
t.Fatal("expected error for nodes with no IP addresses")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ type Handler struct {
|
||||
qualityProber *tunnelQualityProber
|
||||
}
|
||||
|
||||
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -886,11 +888,32 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
}
|
||||
|
||||
return pngDataURLPrefix + payload, nil
|
||||
case monitorTunnelQualityEnabledConfigKey:
|
||||
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||
switch normalized {
|
||||
case "true", "false":
|
||||
return normalized, nil
|
||||
default:
|
||||
return "", fmt.Errorf("隧道质量检测开关配置值无效")
|
||||
}
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(monitorTunnelQualityEnabledConfigKey)
|
||||
if err != nil || cfg == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false"
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
|
||||
@@ -66,9 +66,11 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
|
||||
|
||||
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.qualityProber != nil {
|
||||
h.qualityProber.Start(ctx)
|
||||
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
|
||||
@@ -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) {
|
||||
@@ -1496,6 +1557,12 @@ func (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
userID, tunnelID, lookupErr := h.repo.GetUserTunnelUserAndTunnel(id)
|
||||
if lookupErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, lookupErr.Error()))
|
||||
return
|
||||
}
|
||||
h.cleanupForwardsForUserTunnel(userID, tunnelID)
|
||||
if err := h.repo.DeleteUserTunnel(id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -2422,14 +2489,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
revokedPairs, revokeErr := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs)
|
||||
if revokeErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, pair := range revokedPairs {
|
||||
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
|
||||
}
|
||||
_ = h.syncPermissionsByUserGroup(req.GroupID)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -2473,9 +2544,12 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
var revokedPairs []repo.RevokedUserTunnelPair
|
||||
if exists {
|
||||
if err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
var revokeErr error
|
||||
revokedPairs, revokeErr = h.repo.RevokeGroupPermissionPairTx(tx, ug, tg)
|
||||
if revokeErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2484,6 +2558,9 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, pair := range revokedPairs {
|
||||
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -2703,6 +2780,22 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
}
|
||||
}
|
||||
|
||||
// When updating an existing tunnel (excludeTunnelID > 0), build a set of
|
||||
// node IDs that already belong to the tunnel so we can tolerate offline
|
||||
// nodes that the user is keeping or removing, while still rejecting newly
|
||||
// added offline nodes.
|
||||
existingNodeIDs := make(map[int64]struct{})
|
||||
if excludeTunnelID > 0 {
|
||||
var existIDs []int64
|
||||
if err := tx.Model(&model.ChainTunnel{}).
|
||||
Where("tunnel_id = ?", excludeTunnelID).
|
||||
Pluck("node_id", &existIDs).Error; err == nil {
|
||||
for _, eid := range existIDs {
|
||||
existingNodeIDs[eid] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(nodeIDs))
|
||||
for _, nodeID := range nodeIDs {
|
||||
if _, ok := seen[nodeID]; ok {
|
||||
@@ -2721,7 +2814,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
if node.IsRemote != 1 && node.Status != 1 {
|
||||
return nil, errors.New("部分节点不在线")
|
||||
// For tunnel updates, allow offline nodes that already belong to the
|
||||
// tunnel (user may be removing them). Only reject genuinely new offline nodes.
|
||||
_, isExisting := existingNodeIDs[nodeID]
|
||||
if excludeTunnelID <= 0 || !isExisting {
|
||||
return nil, errors.New("部分节点不在线")
|
||||
}
|
||||
}
|
||||
state.Nodes[nodeID] = node
|
||||
}
|
||||
@@ -3121,7 +3219,6 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, inNode := range state.InNodes {
|
||||
node := state.Nodes[inNode.NodeID]
|
||||
targets := state.OutNodes
|
||||
if len(state.ChainHops) > 0 {
|
||||
targets = state.ChainHops[0]
|
||||
@@ -3131,7 +3228,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, err
|
||||
}
|
||||
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||
@@ -3145,7 +3242,8 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
nextTargets = state.ChainHops[i+1]
|
||||
}
|
||||
for _, chainNode := range hop {
|
||||
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
node := state.Nodes[chainNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
continue
|
||||
}
|
||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
||||
@@ -3153,12 +3251,18 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, err
|
||||
}
|
||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdChains = append(createdChains, chainNode.NodeID)
|
||||
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, chainNode.NodeID)
|
||||
@@ -3166,11 +3270,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, outNode := range state.OutNodes {
|
||||
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
node := state.Nodes[outNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||
}
|
||||
createdServices = append(createdServices, outNode.NodeID)
|
||||
@@ -3261,6 +3369,13 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isNodeOfflineOrTimeoutError returns true when the error indicates a node
|
||||
// is unreachable (offline or timed out), matching the same patterns used by
|
||||
// shouldDeferTunnelRuntimeApplyError.
|
||||
func isNodeOfflineOrTimeoutError(err error) bool {
|
||||
return shouldDeferTunnelRuntimeApplyError(err)
|
||||
}
|
||||
|
||||
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
|
||||
fromNode := nodes[fromNodeID]
|
||||
if fromNode == nil {
|
||||
@@ -3387,6 +3502,7 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 同版本优先
|
||||
if fromV4 && toV4 {
|
||||
if host := pickNodeAddressV4(toNode); host != "" {
|
||||
return host, nil
|
||||
@@ -3397,6 +3513,17 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
// 跨版本支持:v6入v4出 / v4入v6出
|
||||
if fromV6 && toV4 {
|
||||
if host := pickNodeAddressV4(toNode); host != "" {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
if fromV4 && toV6 {
|
||||
if host := pickNodeAddressV6(toNode); host != "" {
|
||||
return host, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
|
||||
}
|
||||
@@ -3939,6 +4066,27 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupForwardsForUserTunnel deletes all forwarding rules belonging to a
|
||||
// specific user+tunnel pair. It notifies nodes to remove the runtime services
|
||||
// first, then deletes the DB records. This is best-effort: individual failures
|
||||
// do not abort the overall cleanup so that remaining forwards are still cleaned.
|
||||
func (h *Handler) cleanupForwardsForUserTunnel(userID, tunnelID int64) {
|
||||
if userID <= 0 || tunnelID <= 0 {
|
||||
return
|
||||
}
|
||||
forwards, err := h.repo.ListForwardsByUserAndTunnel(userID, tunnelID)
|
||||
if err != nil || len(forwards) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range forwards {
|
||||
f := &forwards[i]
|
||||
if f.Status == 1 {
|
||||
_ = h.controlForwardServices(f, "DeleteService", true)
|
||||
}
|
||||
_ = h.deleteForwardByID(f.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
||||
if speedID == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -48,11 +48,8 @@ type tunnelQualityProber struct {
|
||||
|
||||
// newTunnelQualityProber creates a new prober (not yet running).
|
||||
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &tunnelQualityProber{
|
||||
handler: h,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
interval: tunnelQualityProbeInterval,
|
||||
}
|
||||
}
|
||||
@@ -66,6 +63,10 @@ func (p *tunnelQualityProber) Start(ctx context.Context) {
|
||||
|
||||
// Stop halts the background probe loop.
|
||||
func (p *tunnelQualityProber) Stop() {
|
||||
if p == nil || p.cancel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
@@ -106,8 +107,20 @@ func (p *tunnelQualityProber) loop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) isEnabled() bool {
|
||||
if p == nil || p.handler == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return p.handler.isTunnelQualityMonitoringEnabled()
|
||||
}
|
||||
|
||||
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
|
||||
func (p *tunnelQualityProber) maybePrune() {
|
||||
if !p.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
|
||||
return
|
||||
@@ -126,6 +139,10 @@ func (p *tunnelQualityProber) maybePrune() {
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeAll() {
|
||||
if !p.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if previous probe round is still running (interval < timeout guard)
|
||||
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
|
||||
return
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -3241,7 +3243,7 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
}
|
||||
|
||||
if ip != "" {
|
||||
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
|
||||
pair := formatForwardIngressAddress(ip, row.Port.Int64)
|
||||
if _, ok := seenPairs[pair]; !ok {
|
||||
seenPairs[pair] = struct{}{}
|
||||
entries = append(entries, pair)
|
||||
@@ -3258,6 +3260,17 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
return strings.Join(entries, ","), inPort, nil
|
||||
}
|
||||
|
||||
func formatForwardIngressAddress(host string, port int64) string {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || port <= 0 {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.FormatInt(port, 10))
|
||||
}
|
||||
|
||||
func nullableString(v sql.NullString) interface{} {
|
||||
if v.Valid {
|
||||
return v.String
|
||||
@@ -3348,24 +3361,23 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
|
||||
err := r.db.Model(&model.NodeMetric{}).
|
||||
Select(
|
||||
fmt.Sprintf(
|
||||
"? AS node_id, "+
|
||||
"CAST(%s AS INTEGER) AS timestamp, "+
|
||||
"%d AS node_id, "+
|
||||
"CAST(%s AS BIGINT) AS timestamp, "+
|
||||
"AVG(cpu_usage) AS cpu_usage, "+
|
||||
"AVG(mem_usage) AS mem_usage, "+
|
||||
"AVG(disk_usage) AS disk_usage, "+
|
||||
"CAST(AVG(net_in_bytes) AS INTEGER) AS net_in_bytes, "+
|
||||
"CAST(AVG(net_out_bytes) AS INTEGER) AS net_out_bytes, "+
|
||||
"CAST(AVG(net_in_speed) AS INTEGER) AS net_in_speed, "+
|
||||
"CAST(AVG(net_out_speed) AS INTEGER) AS net_out_speed, "+
|
||||
"CAST(AVG(net_in_bytes) AS BIGINT) AS net_in_bytes, "+
|
||||
"CAST(AVG(net_out_bytes) AS BIGINT) AS net_out_bytes, "+
|
||||
"CAST(AVG(net_in_speed) AS BIGINT) AS net_in_speed, "+
|
||||
"CAST(AVG(net_out_speed) AS BIGINT) AS net_out_speed, "+
|
||||
"AVG(load1) AS load1, "+
|
||||
"AVG(load5) AS load5, "+
|
||||
"AVG(load15) AS load15, "+
|
||||
"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,
|
||||
"CAST(AVG(tcp_conns) AS BIGINT) AS tcp_conns, "+
|
||||
"CAST(AVG(udp_conns) AS BIGINT) AS udp_conns, "+
|
||||
"CAST(MAX(uptime) AS BIGINT) AS uptime",
|
||||
nodeID, bucketExpr,
|
||||
),
|
||||
nodeID,
|
||||
).
|
||||
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||
Group(groupExpr).
|
||||
|
||||
@@ -80,6 +80,37 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
|
||||
@@ -997,9 +997,16 @@ func (r *Repository) DeleteGroupPermissionByIDTx(tx *gorm.DB, id int64) error {
|
||||
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
|
||||
// RevokedUserTunnelPair holds the (userID, tunnelID) of a deleted user_tunnel row,
|
||||
// so the handler layer can clean up associated forwarding rules.
|
||||
type RevokedUserTunnelPair struct {
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) ([]RevokedUserTunnelPair, error) {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||
for _, uid := range currentUserIDs {
|
||||
@@ -1018,7 +1025,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
}
|
||||
}
|
||||
if len(removedUserIDs) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type grantRow struct {
|
||||
@@ -1026,6 +1033,8 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
CreatedByGroup int
|
||||
}
|
||||
|
||||
var revoked []RevokedUserTunnelPair
|
||||
|
||||
for _, userID := range removedUserIDs {
|
||||
var rows []grantRow
|
||||
if err := tx.Model(&model.GroupPermissionGrant{}).
|
||||
@@ -1033,7 +1042,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
Joins("JOIN user_tunnel ON user_tunnel.id = group_permission_grant.user_tunnel_id").
|
||||
Where("group_permission_grant.user_group_id = ? AND user_tunnel.user_id = ?", userGroupID, userID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
@@ -1046,28 +1055,32 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
||||
userTunnelIDs := tx.Model(&model.UserTunnel{}).Select("id").Where("user_id = ?", userID)
|
||||
if err := tx.Where("user_group_id = ? AND user_tunnel_id IN (?)", userGroupID, userTunnelIDs).
|
||||
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int64
|
||||
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
if remaining == 0 {
|
||||
var ut model.UserTunnel
|
||||
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
|
||||
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
|
||||
}
|
||||
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) error {
|
||||
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) ([]RevokedUserTunnelPair, error) {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
|
||||
type grantRow struct {
|
||||
@@ -1080,7 +1093,7 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
|
||||
Select("user_tunnel_id, created_by_group").
|
||||
Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
@@ -1092,22 +1105,27 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
|
||||
|
||||
if err := tx.Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
||||
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var revoked []RevokedUserTunnelPair
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int64
|
||||
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
if remaining == 0 {
|
||||
var ut model.UserTunnel
|
||||
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
|
||||
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
|
||||
}
|
||||
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||
return err
|
||||
return revoked, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIssue349_ForwardListFormatsIPv6EntryAddressesContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue349-tunnel", 1.0, 1, "tcp", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "issue349-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue349-entry-node-a", "entry-secret-a", "2001:db8::10", "", "2001:db8::10", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node a: %v", err)
|
||||
}
|
||||
nodeAID := mustLastInsertID(t, repo, "issue349-entry-node-a")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue349-entry-node-b", "entry-secret-b", "2001:db8::30", "", "2001:db8::30", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 1).Error; err != nil {
|
||||
t.Fatalf("insert node b: %v", err)
|
||||
}
|
||||
nodeBID := mustLastInsertID(t, repo, "issue349-entry-node-b")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 1, "admin_user", "issue349-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, repo, "issue349-forward")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeAID, 32001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port a: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port, in_ip) VALUES(?, ?, ?, ?)`, forwardID, nodeBID, 32002, "2001:db8::20").Error; err != nil {
|
||||
t.Fatalf("insert forward_port b: %v", err)
|
||||
}
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
rows := mustContractSlice(t, out.Data, "forward list data")
|
||||
var target map[string]interface{}
|
||||
for _, row := range rows {
|
||||
item, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(item["id"]) == forwardID {
|
||||
target = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
|
||||
}
|
||||
|
||||
if got := contractValueAsString(target["inIp"]); got != "[2001:db8::10]:32001,[2001:db8::20]:32002" {
|
||||
t.Fatalf("expected bracketed IPv6 entry list, got %q", got)
|
||||
}
|
||||
if got := contractValueAsInt64(target["inPort"]); got != 32001 {
|
||||
t.Fatalf("expected first entry port 32001, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -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. 构建通过 + 全量测试通过
|
||||
|
||||
@@ -9,4 +9,4 @@ Sync all changes, bump version to `2.1.9-rc6`, create PR, merge, and publish tag
|
||||
- [x] Push branch to remote.
|
||||
- [x] Create Pull Request using `gh`.
|
||||
- [x] Merge Pull Request using `gh`.
|
||||
- [x] Create and push tag `v2.1.9-rc6`.
|
||||
- [x] Create and push tag `2.1.9-rc6`.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Plan 064: Release 2.1.9-rc7
|
||||
|
||||
Sync all changes, bump version to `2.1.9-rc7`, create PR, merge, and publish tag.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc7`) and today's date (`Tue Mar 24 2026`).
|
||||
- [x] Commit all changes to branch `chore/rc7-bump`.
|
||||
- [x] Push branch to remote.
|
||||
- [x] Create Pull Request using `gh`.
|
||||
- [x] Merge Pull Request using `gh`.
|
||||
- [x] Create and push tag `2.1.9-rc7`.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Fix Node Metrics PostgreSQL Type Encoding
|
||||
|
||||
## Objective
|
||||
Fix the PostgreSQL type encoding error (`failed to encode args[0]: unable to encode 5 into text format for text (OID 25)`) and `integer out of range` error when querying node metrics for time ranges greater than 1 hour.
|
||||
|
||||
## Tasks
|
||||
- [x] Identify the problematic downsampled SQL aggregation in `GetNodeMetrics`.
|
||||
- [x] Fix the `? AS node_id` placeholder which confused PostgreSQL's type inference by directly embedding the `nodeID` using `fmt.Sprintf("%d AS node_id")`.
|
||||
- [x] Change all `CAST(X AS INTEGER)` to `CAST(X AS BIGINT)` to prevent 32-bit integer overflow on Unix millisecond timestamps in PostgreSQL.
|
||||
- [x] Verify the build and tests pass.
|
||||
- [ ] Commit all changes, create a new branch, push, create a Pull Request, merge the PR into `main`, and publish a new tag `2.1.9-rc9`.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 066 - Issue 349 IPv6 Entry Format
|
||||
|
||||
## Goal
|
||||
- 修复规则入口 IPv6 地址在列表/复制场景下缺少方括号的问题,确保 API 返回与前端展示都能直接使用 `[IPv6]:port` 格式。
|
||||
|
||||
## Checklist
|
||||
- [x] 定位规则入口地址的生成链路,确认问题来自后端入口地址拼接格式。
|
||||
- [x] 修复 IPv6 入口地址拼接逻辑,统一输出可直接复制的标准格式。
|
||||
- [x] 增加回归验证,覆盖 `/api/v1/forward/list` 的 IPv6 入口地址格式。
|
||||
- [x] 运行相关测试并确认通过。
|
||||
- [ ] 提交修复分支、创建 PR,并合并到 `main`。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 067 - Issue #342: Allow Tunnel Edit with Offline Nodes
|
||||
|
||||
**Issue:** https://github.com/Sagit-chu/flvx/issues/342
|
||||
|
||||
## Problem
|
||||
When a node goes offline, users cannot edit tunnel configurations at all — including removing the faulty offline node. This creates a deadlock where users must wait for the offline node to recover or manually edit the database.
|
||||
|
||||
## Changes Required
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] 1. **`prepareTunnelCreateState`** (`mutations.go:2800`): Split the offline check into two modes:
|
||||
- **Create (excludeTunnelID == 0)**: Keep current behavior — reject any offline non-remote node.
|
||||
- **Update (excludeTunnelID > 0)**: Only reject **newly added** offline non-remote nodes. Allow existing offline nodes to remain (they'll be removed or kept). Query existing chain_tunnel records to determine which nodes are "old".
|
||||
|
||||
- [x] 2. **`syncForwardServicesWithWarnings`** (`control_plane.go:231`): When a node is offline (sendNodeCommand fails with "节点不在线"), skip it and add a warning instead of returning a hard error. This allows forward rule modifications to succeed partially.
|
||||
|
||||
- [x] 3. **`applyTunnelRuntime`** (`mutations.go:3190`): For non-remote local entry nodes, treat offline errors as deferrable (like remote nodes) so tunnel updates don't fail entirely when some nodes are offline.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] 4. **`validateTunnelForm`** (`tunnel/form.ts`): Change validation to only block adding NEW offline nodes. When editing, offline nodes that are being removed should not block submission. Add isEdit parameter to distinguish create vs. edit.
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getTunnelMetrics,
|
||||
getMonitorTunnelQuality,
|
||||
getMonitorTunnelQualityHistory,
|
||||
getConfigByName,
|
||||
} from "@/api";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
@@ -51,6 +52,9 @@ interface TunnelMonitorViewProps {
|
||||
}
|
||||
|
||||
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
|
||||
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
|
||||
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
|
||||
"monitorTunnelQualityEnabledChanged";
|
||||
|
||||
const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||
const date = new Date(ts);
|
||||
@@ -72,8 +76,6 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/** Render a colored latency value with appropriate visual cue */
|
||||
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
|
||||
if (loading) {
|
||||
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
|
||||
@@ -90,7 +92,6 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
|
||||
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
|
||||
}
|
||||
|
||||
/** Animated pulse dot for live status */
|
||||
function LiveDot() {
|
||||
return (
|
||||
<span className="relative flex h-2 w-2">
|
||||
@@ -173,8 +174,6 @@ function UptimeHistoryBar({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Module-level constants & memoized sub-components ────────────── */
|
||||
|
||||
const TIME_RANGE_OPTIONS = [
|
||||
{ key: String(15 * 60 * 1000), label: "15分钟" },
|
||||
{ key: String(60 * 60 * 1000), label: "1小时" },
|
||||
@@ -340,6 +339,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const initialHistoryFetched = useRef(false);
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const qualityTimerRef = useRef<number | null>(null);
|
||||
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
|
||||
useState(true);
|
||||
|
||||
// Detail view state
|
||||
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
|
||||
@@ -387,9 +388,25 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMonitorTunnelQualityEnabled = useCallback(async () => {
|
||||
try {
|
||||
const response = await getConfigByName(
|
||||
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
|
||||
);
|
||||
setMonitorTunnelQualityEnabled(
|
||||
typeof response.data?.value === "string"
|
||||
? response.data.value === "true"
|
||||
: true,
|
||||
);
|
||||
} catch {
|
||||
setMonitorTunnelQualityEnabled(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTunnels();
|
||||
}, [loadTunnels]);
|
||||
void loadMonitorTunnelQualityEnabled();
|
||||
}, [loadMonitorTunnelQualityEnabled, loadTunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -399,7 +416,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTunnels]);
|
||||
|
||||
// --- Initial history load ---
|
||||
useEffect(() => {
|
||||
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
|
||||
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
|
||||
if (typeof enabled === "boolean") {
|
||||
setMonitorTunnelQualityEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setQualityLoading(false);
|
||||
}
|
||||
} else {
|
||||
void loadMonitorTunnelQualityEnabled();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
|
||||
handleMonitorTunnelQualityEnabledChanged as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
|
||||
handleMonitorTunnelQualityEnabledChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [loadMonitorTunnelQualityEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tunnels.length > 0 && !initialHistoryFetched.current) {
|
||||
initialHistoryFetched.current = true;
|
||||
@@ -472,10 +514,22 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monitorTunnelQualityEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadQuality();
|
||||
}, [loadQuality]);
|
||||
}, [loadQuality, monitorTunnelQualityEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monitorTunnelQualityEnabled) {
|
||||
if (qualityTimerRef.current) {
|
||||
window.clearInterval(qualityTimerRef.current);
|
||||
qualityTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
qualityTimerRef.current = window.setInterval(() => {
|
||||
void loadQuality({ silent: true });
|
||||
}, QUALITY_POLL_INTERVAL);
|
||||
@@ -483,9 +537,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
return () => {
|
||||
if (qualityTimerRef.current) {
|
||||
window.clearInterval(qualityTimerRef.current);
|
||||
qualityTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loadQuality]);
|
||||
}, [loadQuality, monitorTunnelQualityEnabled]);
|
||||
|
||||
// --- Load quality history for detail chart ---
|
||||
const loadQualityHistory = useCallback(
|
||||
@@ -549,21 +604,35 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
useEffect(() => {
|
||||
if (detailTunnelId) {
|
||||
void loadQualityHistory(detailTunnelId);
|
||||
void loadTunnelMetrics(detailTunnelId);
|
||||
if (monitorTunnelQualityEnabled) {
|
||||
void loadQualityHistory(detailTunnelId);
|
||||
}
|
||||
}
|
||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||
}, [
|
||||
detailTunnelId,
|
||||
loadQualityHistory,
|
||||
loadTunnelMetrics,
|
||||
monitorTunnelQualityEnabled,
|
||||
]);
|
||||
|
||||
// Auto-refresh detail charts
|
||||
useEffect(() => {
|
||||
if (!detailTunnelId) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadQualityHistory(detailTunnelId, { silent: true });
|
||||
if (monitorTunnelQualityEnabled) {
|
||||
void loadQualityHistory(detailTunnelId, { silent: true });
|
||||
}
|
||||
void loadTunnelMetrics(detailTunnelId, { silent: true });
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||
}, [
|
||||
detailTunnelId,
|
||||
loadQualityHistory,
|
||||
loadTunnelMetrics,
|
||||
monitorTunnelQualityEnabled,
|
||||
]);
|
||||
|
||||
// Memoize chart data so React.memo sub-components see stable references
|
||||
const qualityChartData = useMemo(
|
||||
@@ -607,10 +676,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
|
||||
}, [qualityMap]);
|
||||
|
||||
// =====================
|
||||
// RENDER
|
||||
// =====================
|
||||
|
||||
if (accessDenied) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -628,7 +693,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
);
|
||||
}
|
||||
|
||||
// ===== DETAIL VIEW =====
|
||||
if (detailTunnelId && detailTunnel) {
|
||||
const quality = qualityMap[detailTunnelId];
|
||||
|
||||
@@ -693,8 +757,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
{/* Auto-probe status */}
|
||||
<div className="flex items-center gap-2 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>自动探测中(每秒测试,30秒上报)</span>
|
||||
{monitorTunnelQualityEnabled ? (
|
||||
<>
|
||||
<LiveDot />
|
||||
<span>自动探测中(每秒测试,30秒上报)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3.5 h-3.5 text-warning" />
|
||||
<span>实时隧道质量检测已关闭</span>
|
||||
</>
|
||||
)}
|
||||
{quality?.timestamp && (
|
||||
<span className="text-default-400">
|
||||
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
@@ -735,12 +808,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
|
||||
{lastQualityUpdate && (
|
||||
{lastQualityUpdate ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
|
||||
{monitorTunnelQualityEnabled ? (
|
||||
<>
|
||||
<LiveDot />
|
||||
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3.5 h-3.5 text-warning" />
|
||||
<span>实时质量检测已关闭 · 最近更新于 {lastQualityUpdate}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : !monitorTunnelQualityEnabled ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
||||
<WifiOff className="w-3.5 h-3.5 text-warning" />
|
||||
<span>实时质量检测已关闭</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ml-auto">
|
||||
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
@@ -812,11 +899,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
|
||||
) : quality?.timestamp ? (
|
||||
<span className="text-[11px] text-default-500 flex items-center gap-1">
|
||||
<LiveDot />
|
||||
{monitorTunnelQualityEnabled ? (
|
||||
<LiveDot />
|
||||
) : (
|
||||
<WifiOff className="w-3 h-3 text-warning" />
|
||||
)}
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-default-400">等待探测...</span>
|
||||
<span className="text-[11px] text-default-400">
|
||||
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
@@ -870,11 +963,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<TableCell>
|
||||
{quality?.timestamp ? (
|
||||
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
|
||||
<LiveDot />
|
||||
{monitorTunnelQualityEnabled ? (
|
||||
<LiveDot />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 text-warning" />
|
||||
)}
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-default-400">-</span>
|
||||
<span className="text-xs text-default-400">
|
||||
{monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -31,6 +31,12 @@ interface PanelAddress {
|
||||
}
|
||||
|
||||
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
|
||||
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
|
||||
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
|
||||
"monitorTunnelQualityEnabledChanged";
|
||||
|
||||
const parseBooleanConfig = (value: unknown, defaultValue: boolean) =>
|
||||
typeof value === "string" ? value === "true" : defaultValue;
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -43,6 +49,10 @@ export const SettingsPage = () => {
|
||||
const [forwardCompactMode, setForwardCompactMode] = useState(false);
|
||||
const [forwardCompactModeSaving, setForwardCompactModeSaving] =
|
||||
useState(false);
|
||||
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
|
||||
useState(true);
|
||||
const [monitorTunnelQualitySaving, setMonitorTunnelQualitySaving] =
|
||||
useState(false);
|
||||
|
||||
const admin = isAdmin();
|
||||
|
||||
@@ -50,9 +60,16 @@ export const SettingsPage = () => {
|
||||
setPanelAddresses(newAddress);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
|
||||
return () => {
|
||||
delete (window as any).setPanelAddresses;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载面板地址列表
|
||||
const loadPanelAddresses = async () => {
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
getPanelAddresses();
|
||||
};
|
||||
|
||||
@@ -72,7 +89,6 @@ export const SettingsPage = () => {
|
||||
|
||||
return;
|
||||
}
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
savePanelAddress(newName.trim(), newAddress.trim());
|
||||
setNewName("");
|
||||
setNewAddress("");
|
||||
@@ -81,14 +97,12 @@ export const SettingsPage = () => {
|
||||
|
||||
// 设置当前面板地址
|
||||
const setCurrentPanel = async (name: string) => {
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
setCurrentPanelAddress(name);
|
||||
reinitializeBaseURL();
|
||||
};
|
||||
|
||||
// 删除面板地址
|
||||
const handleDeletePanelAddress = async (name: string) => {
|
||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||
deletePanelAddress(name);
|
||||
reinitializeBaseURL();
|
||||
toast.success("删除成功");
|
||||
@@ -98,22 +112,27 @@ export const SettingsPage = () => {
|
||||
useEffect(() => {
|
||||
loadPanelAddresses();
|
||||
loadForwardCompactMode();
|
||||
loadMonitorTunnelQualityEnabled();
|
||||
}, []);
|
||||
|
||||
const loadForwardCompactMode = async () => {
|
||||
try {
|
||||
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
|
||||
const enabled =
|
||||
res.code === 0 &&
|
||||
typeof res.data?.value === "string" &&
|
||||
res.data.value === "true";
|
||||
|
||||
setForwardCompactMode(enabled);
|
||||
setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
|
||||
} catch {
|
||||
setForwardCompactMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMonitorTunnelQualityEnabled = async () => {
|
||||
try {
|
||||
const res = await getConfigByName(MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY);
|
||||
setMonitorTunnelQualityEnabled(parseBooleanConfig(res.data?.value, true));
|
||||
} catch {
|
||||
setMonitorTunnelQualityEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForwardCompactModeChange = async (enabled: boolean) => {
|
||||
if (!admin || forwardCompactModeSaving) {
|
||||
return;
|
||||
@@ -148,6 +167,40 @@ export const SettingsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMonitorTunnelQualityEnabledChange = async (enabled: boolean) => {
|
||||
if (!admin || monitorTunnelQualitySaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = monitorTunnelQualityEnabled;
|
||||
|
||||
setMonitorTunnelQualityEnabled(enabled);
|
||||
setMonitorTunnelQualitySaving(true);
|
||||
try {
|
||||
const response = await updateConfig(
|
||||
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(`实时隧道质量检测已${enabled ? "开启" : "关闭"}`);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, {
|
||||
detail: { enabled },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setMonitorTunnelQualityEnabled(previous);
|
||||
toast.error(response.msg || "保存隧道质量检测配置失败");
|
||||
}
|
||||
} catch {
|
||||
setMonitorTunnelQualityEnabled(previous);
|
||||
toast.error("保存隧道质量检测配置失败");
|
||||
} finally {
|
||||
setMonitorTunnelQualitySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||
setUpdateChannel(channel);
|
||||
setUpdateReleaseChannel(channel);
|
||||
@@ -216,28 +269,54 @@ export const SettingsPage = () => {
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
显示设置
|
||||
</h2>
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
规则页面精简模式
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
规则页面精简模式
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
color="primary"
|
||||
isDisabled={!admin || forwardCompactModeSaving}
|
||||
isSelected={forwardCompactMode}
|
||||
onValueChange={handleForwardCompactModeChange}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
color="primary"
|
||||
isDisabled={!admin || forwardCompactModeSaving}
|
||||
isSelected={forwardCompactMode}
|
||||
onValueChange={handleForwardCompactModeChange}
|
||||
/>
|
||||
{!admin && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
仅管理员可修改该全局配置。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
实时隧道质量检测
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
关闭后,前端停止自动刷新,后端停止实时隧道质量探测。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
color="primary"
|
||||
isDisabled={!admin || monitorTunnelQualitySaving}
|
||||
isSelected={monitorTunnelQualityEnabled}
|
||||
onValueChange={handleMonitorTunnelQualityEnabledChange}
|
||||
/>
|
||||
</div>
|
||||
{!admin && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
仅管理员可修改该全局配置。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!admin && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
仅管理员可修改该全局配置。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -418,7 +418,7 @@ export default function TunnelPage() {
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors = validateTunnelForm(form, nodes);
|
||||
const newErrors = validateTunnelForm(form, nodes, isEdit);
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const createTunnelFormDefaults = () => {
|
||||
export const validateTunnelForm = (
|
||||
form: TunnelFormInput,
|
||||
nodes: TunnelNodeInput[],
|
||||
isEdit = false,
|
||||
): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
@@ -44,7 +45,9 @@ export const validateTunnelForm = (
|
||||
|
||||
if (!form.inNodeId || form.inNodeId.length === 0) {
|
||||
errors.inNodeId = "请至少选择一个入口节点";
|
||||
} else {
|
||||
} else if (!isEdit) {
|
||||
// Only enforce online check for new tunnels. During edit the backend
|
||||
// allows existing offline nodes (user may be removing them).
|
||||
const offlineInNodes = form.inNodeId.filter((item) => {
|
||||
const node = nodes.find((n) => n.id === item.nodeId);
|
||||
|
||||
@@ -64,14 +67,16 @@ export const validateTunnelForm = (
|
||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||
errors.outNodeId = "请至少选择一个出口节点";
|
||||
} else {
|
||||
const offlineOutNodes = form.outNodeId.filter((item) => {
|
||||
const node = nodes.find((n) => n.id === item.nodeId);
|
||||
if (!isEdit) {
|
||||
const offlineOutNodes = form.outNodeId.filter((item) => {
|
||||
const node = nodes.find((n) => n.id === item.nodeId);
|
||||
|
||||
return node && node.status !== 1;
|
||||
});
|
||||
return node && node.status !== 1;
|
||||
});
|
||||
|
||||
if (offlineOutNodes.length > 0) {
|
||||
errors.outNodeId = "所有出口节点必须在线";
|
||||
if (offlineOutNodes.length > 0) {
|
||||
errors.outNodeId = "所有出口节点必须在线";
|
||||
}
|
||||
}
|
||||
|
||||
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
||||
|
||||
Reference in New Issue
Block a user