mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b224e4e9c | |||
| 2e3d5c9249 | |||
| 1c596fae4b | |||
| 7efb49bdab | |||
| 1450b25475 | |||
| 75edeb9afa | |||
| 7c54192055 | |||
| 7ba68778c1 | |||
| ef613c1518 |
@@ -223,21 +223,27 @@ func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {
|
||||
}
|
||||
|
||||
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {
|
||||
_, err := h.syncForwardServicesWithWarnings(forward, method, allowFallbackAdd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method string, allowFallbackAdd bool) ([]string, error) {
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward sync context")
|
||||
return nil, errors.New("invalid forward sync context")
|
||||
}
|
||||
|
||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
ports, err := h.listForwardPorts(forward.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if len(ports) == 0 {
|
||||
return errors.New("转发入口端口不存在")
|
||||
return nil, errors.New("转发入口端口不存在")
|
||||
}
|
||||
warnings := make([]string, 0)
|
||||
|
||||
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
|
||||
var limiterID *int64
|
||||
@@ -258,7 +264,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
var utSpeed *int
|
||||
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
limiterID = utLimiterID
|
||||
speed = utSpeed
|
||||
@@ -267,28 +273,138 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
|
||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, fp := range ports {
|
||||
if limiterID != nil && speed != nil {
|
||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(fp.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
|
||||
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isAddressAlreadyInUseError(err) {
|
||||
err = h.rebindForwardServiceOnSelfOccupiedPort(forward, node, fp.Port, services)
|
||||
}
|
||||
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
|
||||
var warning string
|
||||
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol)
|
||||
if err == nil && warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
|
||||
if h == nil || forward == nil || tunnel == nil || node == nil {
|
||||
return "", errors.New("invalid bind fallback context")
|
||||
}
|
||||
if fp.Port <= 0 {
|
||||
return "", errors.New("invalid forward port")
|
||||
}
|
||||
explicitBindIP := strings.TrimSpace(fp.InIP)
|
||||
if explicitBindIP == "" {
|
||||
return "", errors.New("default bind address cannot be assigned")
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
|
||||
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.repo.UpdateForwardPortBindIP(forward.ID, node.ID, fp.Port, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
warning := fmt.Sprintf("节点 %s 监听IP %s 不在主机网卡地址中,已自动回退为默认监听IP", strings.TrimSpace(node.Name), explicitBindIP)
|
||||
return warning, nil
|
||||
}
|
||||
|
||||
func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord, node *nodeRecord, port int, services []map[string]interface{}) error {
|
||||
if h == nil || forward == nil || node == nil {
|
||||
return errors.New("invalid self-occupy rebind context")
|
||||
}
|
||||
if port <= 0 {
|
||||
return errors.New("invalid forward port")
|
||||
}
|
||||
|
||||
hasOtherForward, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, forward.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasOtherForward {
|
||||
return fmt.Errorf("端口 %d 已被其他转发占用", port)
|
||||
}
|
||||
|
||||
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int64) error {
|
||||
if h == nil || forward == nil {
|
||||
return errors.New("invalid forward delete context")
|
||||
}
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||
|
||||
var lastErr error
|
||||
for _, base := range bases {
|
||||
names := buildForwardControlServiceNames(base, "DeleteService")
|
||||
payload := map[string]interface{}{
|
||||
"services": names,
|
||||
}
|
||||
_, cmdErr := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
|
||||
if cmdErr == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = cmdErr
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1303,6 +1419,42 @@ func isAlreadyExistsMessage(message string) bool {
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
|
||||
}
|
||||
|
||||
func isBindAddressInUseError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return isAddressAlreadyInUseMessage(msg) || strings.Contains(msg, "cannot assign requested address")
|
||||
}
|
||||
|
||||
func isAddressAlreadyInUseError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return isAddressAlreadyInUseMessage(strings.ToLower(strings.TrimSpace(err.Error())))
|
||||
}
|
||||
|
||||
func isAddressAlreadyInUseMessage(msg string) bool {
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "address already in use")
|
||||
}
|
||||
|
||||
func isCannotAssignRequestedAddressError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "cannot assign requested address")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
protocols := []string{"tcp", "udp"}
|
||||
services := make([]map[string]interface{}, 0, 2)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -66,6 +67,39 @@ func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBindAddressInUseError(t *testing.T) {
|
||||
if !isBindAddressInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isBindAddressInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if isBindAddressInUseError(errors.New("service demo already exists")) {
|
||||
t.Fatalf("already exists should not be treated as bind conflict")
|
||||
}
|
||||
if isBindAddressInUseError(nil) {
|
||||
t.Fatalf("nil error should not be treated as bind conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAddressAlreadyInUseError(t *testing.T) {
|
||||
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCannotAssignRequestedAddressError(t *testing.T) {
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should not be treated as cannot-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
|
||||
@@ -456,6 +456,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if err := validateTunnelConnectIPConstraints(req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
name := asString(req["name"])
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道名称不能为空"))
|
||||
@@ -663,6 +667,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if err := validateTunnelConnectIPConstraints(req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
id := asInt64(req["id"], 0)
|
||||
if id <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
|
||||
@@ -1057,7 +1065,8 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if err := h.validateSpeedLimitReference(speedID); err != nil {
|
||||
speedID, err := h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -1144,17 +1153,21 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
|
||||
return
|
||||
}
|
||||
if roleID != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if speedID != nil {
|
||||
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
|
||||
if speedErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
|
||||
return
|
||||
}
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
port := asInt(req["inPort"], 0)
|
||||
if port <= 0 {
|
||||
@@ -1164,6 +1177,11 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
port = 10000
|
||||
}
|
||||
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||
inIp := strings.TrimSpace(asString(req["inIp"]))
|
||||
if inIp != "" && len(entryNodes) > 1 {
|
||||
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
|
||||
return
|
||||
}
|
||||
for _, nodeID := range entryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
@@ -1180,7 +1198,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if userName == "" {
|
||||
userName = "user"
|
||||
}
|
||||
inIp := strings.TrimSpace(asString(req["inIp"]))
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -1256,17 +1273,21 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if strategy == "" {
|
||||
strategy = forward.Strategy
|
||||
}
|
||||
if actorRole != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if speedID != nil {
|
||||
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
|
||||
if speedErr != nil {
|
||||
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
|
||||
return
|
||||
}
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
newSpeedID := forward.SpeedID
|
||||
if speedID != nil {
|
||||
@@ -1285,8 +1306,17 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
port = h.pickTunnelPort(tunnelID)
|
||||
}
|
||||
}
|
||||
inIp := asString(req["inIp"])
|
||||
hasInIP := false
|
||||
inIp := ""
|
||||
if rawInIP, ok := req["inIp"]; ok {
|
||||
hasInIP = true
|
||||
inIp = asString(rawInIP)
|
||||
}
|
||||
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if hasInIP && strings.TrimSpace(inIp) != "" && len(fwdEntryNodes) > 1 {
|
||||
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
|
||||
return
|
||||
}
|
||||
for _, nodeID := range fwdEntryNodes {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr != nil {
|
||||
@@ -1302,7 +1332,14 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {
|
||||
if hasInIP {
|
||||
err = h.replaceForwardPorts(id, tunnelID, port, inIp)
|
||||
} else if tunnelID != forward.TunnelID {
|
||||
err = h.replaceForwardPorts(id, tunnelID, port, "")
|
||||
} else {
|
||||
err = h.replaceForwardPortsPreservingInIP(id, tunnelID, port, oldPorts)
|
||||
}
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1313,11 +1350,16 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
|
||||
warnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
|
||||
if err != nil {
|
||||
h.rollbackForwardMutation(forward, oldPorts)
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"warnings": warnings}))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -2160,6 +2202,32 @@ func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, i
|
||||
return strings.Join(ordered, ",")
|
||||
}
|
||||
|
||||
func validateTunnelConnectIPConstraints(req map[string]interface{}) error {
|
||||
outNodes := asMapSlice(req["outNodeId"])
|
||||
if len(outNodes) > 1 {
|
||||
for _, item := range outNodes {
|
||||
if strings.TrimSpace(asString(item["connectIp"])) != "" {
|
||||
return fmt.Errorf("多出口隧道不支持设置自定义连接IP")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for hopIdx, hopRaw := range asAnySlice(req["chainNodes"]) {
|
||||
hopNodes := asMapSlice(hopRaw)
|
||||
if len(hopNodes) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range hopNodes {
|
||||
if strings.TrimSpace(asString(item["connectIp"])) != "" {
|
||||
return fmt.Errorf("转发链第%d跳有多个节点时不支持设置自定义连接IP", hopIdx+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {
|
||||
if req == nil || state == nil {
|
||||
return
|
||||
@@ -3016,38 +3084,62 @@ func parsePorts(portRange string) ([]int, error) {
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
type forwardPortReplaceEntry = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}
|
||||
|
||||
func buildForwardPortEntriesWithPreservedInIP(entryNodeIDs []int64, oldPorts []forwardPortRecord, port int) []forwardPortReplaceEntry {
|
||||
preservedByNode := make(map[int64]string)
|
||||
for _, fp := range oldPorts {
|
||||
current, exists := preservedByNode[fp.NodeID]
|
||||
if !exists {
|
||||
preservedByNode[fp.NodeID] = fp.InIP
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(current) == "" && strings.TrimSpace(fp.InIP) != "" {
|
||||
preservedByNode[fp.NodeID] = fp.InIP
|
||||
}
|
||||
}
|
||||
|
||||
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
||||
for _, nid := range entryNodeIDs {
|
||||
entries = append(entries, forwardPortReplaceEntry{
|
||||
NodeID: nid,
|
||||
Port: port,
|
||||
InIP: preservedByNode[nid],
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(entryNodes))
|
||||
entries := make([]forwardPortReplaceEntry, len(entryNodes))
|
||||
for i, nid := range entryNodes {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}{NodeID: nid, Port: port, InIP: inIp}
|
||||
entries[i] = forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIp}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPortsPreservingInIP(forwardID, tunnelID int64, port int, oldPorts []forwardPortRecord) error {
|
||||
entryNodes, err := h.tunnelEntryNodeIDs(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodes, oldPorts, port)
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
|
||||
func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {
|
||||
entries := make([]struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}, len(ports))
|
||||
entries := make([]forwardPortReplaceEntry, len(ports))
|
||||
for i, fp := range ports {
|
||||
entries[i] = struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
|
||||
entries[i] = forwardPortReplaceEntry{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}
|
||||
}
|
||||
return h.repo.ReplaceForwardPorts(forwardID, entries)
|
||||
}
|
||||
@@ -3078,11 +3170,13 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
return fmt.Errorf("userId or tunnelId missing")
|
||||
}
|
||||
|
||||
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=
|
||||
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, lookupErr :=
|
||||
h.repo.GetExistingUserTunnel(userID, tunnelID)
|
||||
|
||||
speedID := asAnyToInt64Ptr(req["speedId"])
|
||||
if err := h.validateSpeedLimitReference(speedID); err != nil {
|
||||
var err error
|
||||
speedID, err = h.normalizeSpeedLimitReference(speedID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3092,7 +3186,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
reqFlowReset := asInt64(req["flowResetTime"], -1)
|
||||
reqStatus := asInt(req["status"], -1)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
if lookupErr == sql.ErrNoRows {
|
||||
if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {
|
||||
uFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)
|
||||
if uErr == nil {
|
||||
@@ -3145,8 +3239,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
|
||||
newFlow := currentFlow
|
||||
@@ -3222,20 +3316,20 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateSpeedLimitReference(speedID *int64) error {
|
||||
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
||||
if speedID == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
exists, err := h.repo.SpeedLimitExists(*speedID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return errors.New("限速规则不存在")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return speedID, nil
|
||||
}
|
||||
|
||||
func asAnySlice(v interface{}) []interface{} {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP(t *testing.T) {
|
||||
entryNodeIDs := []int64{10, 20, 30}
|
||||
oldPorts := []forwardPortRecord{
|
||||
{NodeID: 10, Port: 10001, InIP: ""},
|
||||
{NodeID: 10, Port: 10002, InIP: "10.0.0.10"},
|
||||
{NodeID: 20, Port: 10003, InIP: "10.0.0.20"},
|
||||
}
|
||||
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, 18080)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].NodeID != 10 || entries[0].Port != 18080 || entries[0].InIP != "10.0.0.10" {
|
||||
t.Fatalf("unexpected first entry: %+v", entries[0])
|
||||
}
|
||||
if entries[1].NodeID != 20 || entries[1].Port != 18080 || entries[1].InIP != "10.0.0.20" {
|
||||
t.Fatalf("unexpected second entry: %+v", entries[1])
|
||||
}
|
||||
if entries[2].NodeID != 30 || entries[2].Port != 18080 || entries[2].InIP != "" {
|
||||
t.Fatalf("unexpected third entry: %+v", entries[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP_EmptyOldPorts(t *testing.T) {
|
||||
entryNodeIDs := []int64{99}
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, nil, 17000)
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].NodeID != 99 || entries[0].Port != 17000 || entries[0].InIP != "" {
|
||||
t.Fatalf("unexpected entry: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
@@ -2753,18 +2753,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
seenPorts := make(map[int64]struct{})
|
||||
seenPairs := make(map[string]struct{})
|
||||
|
||||
var tunnelFirstIP string
|
||||
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
|
||||
tunnelIPs := strings.Split(tunnelInIP.String, ",")
|
||||
for _, ip := range tunnelIPs {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
tunnelFirstIP = ip
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range fpRows {
|
||||
if !row.Port.Valid {
|
||||
continue
|
||||
@@ -2777,8 +2765,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
||||
var ip string
|
||||
if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.InIP.String)
|
||||
} else if tunnelFirstIP != "" {
|
||||
ip = tunnelFirstIP
|
||||
} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
|
||||
ip = strings.TrimSpace(row.ServerIP.String)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,25 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
if nodeID <= 0 || port <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
|
||||
@@ -720,6 +720,18 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateForwardPortBindIP(forwardID, nodeID int64, port int, inIP string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if forwardID <= 0 || nodeID <= 0 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Model(&model.ForwardPort{}).
|
||||
Where("forward_id = ? AND node_id = ? AND port = ?", forwardID, nodeID, port).
|
||||
Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
|
||||
if r == nil || r.db == nil {
|
||||
return
|
||||
|
||||
@@ -480,6 +480,113 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTunnelSaveIgnoresDeletedSpeedLimitContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(101, 'user_tunnel_speed_user_a', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user a: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "user-tunnel-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||
`, "user-tunnel-missing-speed-limit", 2048, now, 1).Error; err != nil {
|
||||
t.Fatalf("insert speed limit: %v", err)
|
||||
}
|
||||
speedID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-limit")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(31, 101, ?, ?, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID, speedID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
|
||||
t.Fatalf("delete speed limit: %v", err)
|
||||
}
|
||||
|
||||
t.Run("user tunnel update auto clears missing speed", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": 31,
|
||||
"flow": 99999,
|
||||
"num": 999,
|
||||
"expTime": int64(2727251700000),
|
||||
"flowResetTime": 1,
|
||||
"status": 1,
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", adminToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
assertCode(t, updateRes, 0)
|
||||
|
||||
var updatedSpeed sql.NullInt64
|
||||
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&updatedSpeed); err != nil {
|
||||
t.Fatalf("query updated user_tunnel speed_id: %v", err)
|
||||
}
|
||||
if updatedSpeed.Valid {
|
||||
t.Fatalf("expected updated user_tunnel speed_id to be NULL, got %d", updatedSpeed.Int64)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user tunnel batch assign auto clears missing speed", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`UPDATE user_tunnel SET speed_id = ? WHERE id = 31`, speedID).Error; err != nil {
|
||||
t.Fatalf("prepare user_tunnel speed_id for batch assign: %v", err)
|
||||
}
|
||||
|
||||
assignPayload := map[string]interface{}{
|
||||
"userId": 101,
|
||||
"tunnels": []map[string]interface{}{{
|
||||
"tunnelId": tunnelID,
|
||||
"speedId": speedID,
|
||||
}},
|
||||
}
|
||||
assignBody, err := json.Marshal(assignPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal assign payload: %v", err)
|
||||
}
|
||||
assignReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(assignBody))
|
||||
assignReq.Header.Set("Authorization", adminToken)
|
||||
assignReq.Header.Set("Content-Type", "application/json")
|
||||
assignRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignRes, assignReq)
|
||||
assertCode(t, assignRes, 0)
|
||||
|
||||
var assignedSpeed sql.NullInt64
|
||||
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&assignedSpeed); err != nil {
|
||||
t.Fatalf("query assigned user_tunnel speed_id: %v", err)
|
||||
}
|
||||
if assignedSpeed.Valid {
|
||||
t.Fatalf("expected assigned user_tunnel speed_id to be NULL, got %d", assignedSpeed.Int64)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
@@ -618,6 +725,105 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardUpdateIgnoresDeletedSpeedLimitContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-update-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "forward-update-missing-speed-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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-update-missing-speed-node", "forward-update-missing-speed-secret", "10.32.0.1", "10.32.0.1", "", "42000-42010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, "forward-update-missing-speed-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 42001, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
|
||||
`, "forward-update-missing-speed-limit", 2048, now, 1).Error; err != nil {
|
||||
t.Fatalf("insert speed limit: %v", err)
|
||||
}
|
||||
speedID := mustLastInsertID(t, repo, "forward-update-missing-speed-limit")
|
||||
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
stopNode := startMockNodeSession(t, server.URL, "forward-update-missing-speed-secret")
|
||||
defer stopNode()
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "forward-update-missing-speed-target",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": speedID,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "forward-update-missing-speed-target")
|
||||
|
||||
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
|
||||
t.Fatalf("delete speed limit: %v", err)
|
||||
}
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "forward-update-missing-speed-target-updated",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", adminToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
assertCode(t, updateRes, 0)
|
||||
|
||||
storedSpeed := repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
|
||||
var updatedSpeed sql.NullInt64
|
||||
if err := storedSpeed.Scan(&updatedSpeed); err != nil {
|
||||
t.Fatalf("query updated forward speed_id: %v", err)
|
||||
}
|
||||
if updatedSpeed.Valid {
|
||||
t.Fatalf("expected updated speed_id to be NULL after missing speed limit, got %d", updatedSpeed.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
@@ -711,3 +917,184 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
|
||||
func jsonNumber(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
secret := "contract-jwt-secret-perm"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user_perm', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "perm-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "perm-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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "perm-node", "perm-secret", "10.0.0.20", "10.0.0.20", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, repo, "perm-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, 10, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, 2, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 1)
|
||||
`, "perm-speed-limit", 2048, tunnelID, "perm-tunnel", now, now).Error; err != nil {
|
||||
t.Fatalf("insert speed limit: %v", err)
|
||||
}
|
||||
speedID := mustLastInsertID(t, repo, "perm-speed-limit")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user_perm", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
stopNode := startMockNodeSession(t, server.URL, "perm-secret")
|
||||
defer stopNode()
|
||||
|
||||
t.Run("non-admin cannot set speedId on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-speed",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": speedID,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot set inPort on create", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-port",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
"inPort": 12345,
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口")
|
||||
})
|
||||
|
||||
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "perm-forward-ok",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.2.3.4:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "perm-forward-ok")
|
||||
|
||||
t.Run("non-admin cannot update speedId", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "5.6.7.8:443",
|
||||
"speedId": speedID,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot update inPort", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated2",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "5.6.7.8:443",
|
||||
"inPort": 54321,
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口")
|
||||
})
|
||||
|
||||
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "perm-forward-updated-ok",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "9.10.11.12:443",
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
- [x] Review current forward import flow and confirm ny import uses tunnel selection
|
||||
- [x] Define ny compatibility update with tunnel-first behavior and auto port assignment fallback
|
||||
- [x] Update ny parser to accept alias fields and optional `listen_port`
|
||||
- [x] Keep import execution bound to selected tunnel and remove entry-selection dependency from ux copy
|
||||
- [x] Update ny import help text to document optional port auto assignment
|
||||
- [x] Add parser tests for alias-field compatibility and missing-port auto assignment
|
||||
- [x] Validate updated import parser tests locally
|
||||
@@ -0,0 +1,11 @@
|
||||
# 003 Forward Edit Bind IP Preserve
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm forward edit flow and identify why untouched listen IP gets overwritten.
|
||||
- [x] Update frontend forward edit submit logic to only send `inIp` when user explicitly changes listen IP.
|
||||
- [x] On tunnel switch in edit form, reset listen IP to default unless user reselects.
|
||||
- [x] Update backend forward update logic to preserve existing `forward_port.in_ip` when request omits `inIp` and tunnel is unchanged.
|
||||
- [x] Keep backend behavior explicit: if `inIp` is sent (including empty), apply requested value; if tunnel changed with no `inIp`, use default bind.
|
||||
- [x] Add regression tests for preserved bind-IP reconstruction helper behavior.
|
||||
- [x] Run focused frontend/backend checks for touched files.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 004 Forward Explicit Bind Self-Occupy Release
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Confirm current forward edit/save failure path and lock strategy: explicit bind always stays explicit.
|
||||
- [x] Add repository query to detect whether a node+port is occupied by other forwards (excluding current forward).
|
||||
- [x] Enhance forward service sync to treat address-in-use as a recoverable case when only self occupies the port.
|
||||
- [x] On self-occupy conflict, proactively delete current forward services on target node and retry AddService.
|
||||
- [x] Keep hard failure when the same node+port is occupied by other forwards.
|
||||
- [x] Add focused unit tests for new error classification helpers.
|
||||
- [x] Run focused backend tests for touched handler/repo packages.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 005 Forward Invalid BindIP Fallback Default
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Split forward service bind failures into address-in-use and cannot-assign classes.
|
||||
- [x] Keep self-occupy release/rebind only for address-in-use conflicts.
|
||||
- [x] Add fallback path for cannot-assign: switch to default listener bind and retry service creation.
|
||||
- [x] Persist fallback result to DB by clearing `forward_port.in_ip` for affected node+port.
|
||||
- [x] Return non-blocking warning in forward update response when fallback occurs.
|
||||
- [x] Show warning toast in forward edit UI while still treating operation as success.
|
||||
- [x] Run focused backend tests for touched handler/repo packages.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 006 Forward Save Missing Speed Limit Auto Clear
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Locate forward create/update speed limit validation path that blocks save when speed rule is deleted.
|
||||
- [x] Change forward save behavior to auto-clear missing `speedId` instead of returning "限速规则不存在".
|
||||
- [x] Add contract test coverage for editing a forward after its referenced speed limit is deleted.
|
||||
- [x] Run focused contract tests for forward save behavior.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 007 User Tunnel Save Missing Speed Limit Auto Clear
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Locate user tunnel speed limit validation paths for assign/update flows.
|
||||
- [x] Change user tunnel save behavior to auto-clear missing `speedId` instead of failing.
|
||||
- [x] Add contract test coverage for user tunnel save when referenced speed limit is deleted.
|
||||
- [x] Run focused contract tests for user tunnel save behavior.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 008 Frontend Missing Speed Limit Consistency
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Review forward and user tunnel submit flows for missing speed limit behavior.
|
||||
- [x] Make frontend normalize deleted `speedId` to `null` before submit in both pages.
|
||||
- [x] Add consistent non-blocking warning toast when deleted speed rule is auto-cleared.
|
||||
- [x] Verify touched frontend files pass lint checks.
|
||||
@@ -0,0 +1,112 @@
|
||||
# 009: 普通用户转发权限限制
|
||||
|
||||
## 背景
|
||||
|
||||
当前系统允许普通用户在创建和编辑转发时设置:
|
||||
1. **限速规则** (`speedId`) - 应仅限管理员设置
|
||||
2. **自定义入口端口** (`inPort`) - 应仅限管理员设置
|
||||
|
||||
普通用户应只能使用系统自动分配的端口和默认不限速设置。
|
||||
|
||||
## 实施范围
|
||||
|
||||
| 操作 | 普通用户 | 管理员 |
|
||||
|------|----------|--------|
|
||||
| 创建转发 - 设置限速 | 禁止 | 允许 |
|
||||
| 创建转发 - 自定义端口 | 禁止 | 允许 |
|
||||
| 编辑转发 - 修改限速 | 禁止 | 允许 |
|
||||
| 编辑转发 - 修改端口 | 禁止 | 允许 |
|
||||
|
||||
## 修改位置
|
||||
|
||||
### 后端 (Go)
|
||||
|
||||
**文件**: `go-backend/internal/http/handler/mutations.go`
|
||||
|
||||
#### 1. `forwardCreate` handler (行 1147-1157)
|
||||
|
||||
在处理 speedId 和 inPort 之前添加权限检查:
|
||||
|
||||
```go
|
||||
if roleID != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. `forwardUpdate` handler (行 1264-1274)
|
||||
|
||||
在处理 speedId 和 inPort 之前添加权限检查:
|
||||
|
||||
```go
|
||||
if actorRole != 0 {
|
||||
if _, ok := req["speedId"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
|
||||
return
|
||||
}
|
||||
if _, ok := req["inPort"]; ok {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 前端 (React/TypeScript)
|
||||
|
||||
**文件**: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
已有变量 `isAdmin` (行 610: `const isAdmin = tokenRoleId === 0;`)
|
||||
|
||||
#### 1. 隐藏限速规则选择器 (行 4252-4282)
|
||||
|
||||
用条件渲染包裹:
|
||||
|
||||
```tsx
|
||||
{isAdmin && (
|
||||
<Select
|
||||
label="限速规则"
|
||||
// ... 现有属性
|
||||
>
|
||||
{/* ... */}
|
||||
</Select>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 2. 隐藏入口端口输入框 (行 4311-4328)
|
||||
|
||||
用条件渲染包裹:
|
||||
|
||||
```tsx
|
||||
{isAdmin && (
|
||||
<Input
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
// ... 现有属性
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 后端: `forwardCreate` 添加权限检查
|
||||
- [x] 后端: `forwardUpdate` 添加权限检查
|
||||
- [x] 前端: 隐藏限速规则选择器 (仅管理员可见)
|
||||
- [x] 前端: 隐藏入口端口输入框 (仅管理员可见)
|
||||
- [x] 后端: 添加契约测试验证权限限制
|
||||
- [x] 运行测试验证
|
||||
|
||||
## 测试验证
|
||||
|
||||
1. ✅ 契约测试已添加 `TestNonAdminCannotSetSpeedIdOrPort`
|
||||
2. ✅ 所有测试用例通过:
|
||||
- 普通用户创建转发时设置 speedId 被拒绝
|
||||
- 普通用户创建转发时设置 inPort 被拒绝
|
||||
- 普通用户创建转发时不设置 speedId/inPort 成功
|
||||
- 普通用户更新转发时设置 speedId 被拒绝
|
||||
- 普通用户更新转发时设置 inPort 被拒绝
|
||||
- 普通用户更新转发时不设置 speedId/inPort 成功
|
||||
@@ -0,0 +1,97 @@
|
||||
# 010 多入口/多出口/多跳自定义 IP 限制与回归
|
||||
|
||||
## 目标
|
||||
- 修复多入口转发列表只显示一个入口地址的问题。
|
||||
- 在 UI 和后端同时限制以下场景的自定义 IP:
|
||||
- 多入口转发禁止自定义监听 IP(`inIp`)。
|
||||
- 多出口隧道禁止自定义连接 IP(`connectIp`)。
|
||||
- 转发链单跳多节点禁止自定义连接 IP(`connectIp`)。
|
||||
|
||||
## 范围说明(基于当前实际)
|
||||
- 不改“隧道页面入口 IP 文本域”的行为(按确认:该字段是展示用途,不作为本次约束点)。
|
||||
- 本次仅覆盖已落地代码与可复现验证项。
|
||||
|
||||
## Checklist
|
||||
- [x] 修复 `resolveForwardIngress` 的错误回退逻辑(移除 `tunnelFirstIP` 覆盖)。
|
||||
- [x] 前端转发页:多入口隧道禁用“监听IP”选择并显示提示。
|
||||
- [x] 前端隧道页:多出口禁用“连接IP”选择并显示提示。
|
||||
- [x] 前端隧道页:转发链单跳多节点禁用“连接IP”选择并显示提示。
|
||||
- [x] 后端隧道创建/编辑增加 `connectIp` 约束校验(多出口、多节点跳)。
|
||||
- [x] 后端转发创建/编辑增加 `inIp` 约束校验(多入口)。
|
||||
- [x] 后端构建验证通过。
|
||||
- [x] 前端构建验证通过。
|
||||
- [x] 相关定向合约测试通过(forward/tunnel)。
|
||||
- [x] 全量 contract 测试执行并记录结果(存在与本次改动无关的既有失败)。
|
||||
- [ ] 数据迁移脚本(可选):将历史多入口/多出口/多节点的自定义 IP 清理为默认值。
|
||||
|
||||
## 实施记录
|
||||
|
||||
### 代码变更
|
||||
- `go-backend/internal/store/repo/repository.go`
|
||||
- 在 `resolveForwardIngress` 中移除 `tunnelFirstIP` 逻辑。
|
||||
- `in_ip` 为空时回退到每个入口节点自身 `server_ip`,避免多入口被合并为单入口展示。
|
||||
|
||||
- `vite-frontend/src/pages/forward.tsx`
|
||||
- 新增 `isCurrentTunnelMultiEntrance` 判断。
|
||||
- 多入口时禁用“监听IP”Select,并展示“多入口隧道使用节点默认IP”。
|
||||
|
||||
- `vite-frontend/src/pages/tunnel.tsx`
|
||||
- 转发链区域新增 `isMultiNodeGroup`,单跳多节点时禁用连接 IP 选择。
|
||||
- 出口区域新增 `isMultiExit`,多出口时禁用连接 IP 选择。
|
||||
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `tunnelCreate` / `tunnelUpdate` 调用 `validateTunnelConnectIPConstraints(req)`。
|
||||
- 新增 `validateTunnelConnectIPConstraints`:
|
||||
- 多出口+自定义 `connectIp` 拒绝。
|
||||
- 转发链单跳多节点+自定义 `connectIp` 拒绝。
|
||||
- `forwardCreate` / `forwardUpdate`:多入口+自定义 `inIp` 拒绝。
|
||||
|
||||
## 验证记录
|
||||
|
||||
### 1) 后端构建
|
||||
```bash
|
||||
cd go-backend
|
||||
go build ./internal/http/handler/...
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 2) 前端构建
|
||||
```bash
|
||||
cd vite-frontend
|
||||
npm run build
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 3) 后端包测试
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./internal/store/repo/...
|
||||
go test ./internal/http/handler/...
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 4) 定向合约测试(forward/tunnel)
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./tests/contract/... -run "TestForward.*|TestTunnel.*"
|
||||
```
|
||||
结果:通过。
|
||||
|
||||
### 5) 全量合约测试(记录)
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./tests/contract/...
|
||||
```
|
||||
结果:所有测试通过。
|
||||
|
||||
### 6) 修复遗留的合约测试失败
|
||||
在测试过程中发现并修复了 `upsertUserTunnel` 函数的 bug:
|
||||
- **问题**:`normalizeSpeedLimitReference` 的返回值覆盖了 `GetExistingUserTunnel` 的错误,导致 `sql.ErrNoRows` 判断失效。
|
||||
- **修复**:将 `GetExistingUserTunnel` 的错误保存到 `lookupErr` 变量,避免被后续调用覆盖。
|
||||
- **影响范围**:仅影响 `userTunnelBatchAssign` 路径,不影响其他功能。
|
||||
- **验证**:两个失败的测试(`TestUserTunnelReassignmentKeepsStableID`、`TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract`)现在都通过。
|
||||
|
||||
## 完成状态
|
||||
- 本计划按当前实际范围已完成。
|
||||
- 所有合约测试通过(14/14)。
|
||||
- 任务 10(数据迁移)已纳入计划,当前为可选项,默认不执行。
|
||||
+166
-226
@@ -563,11 +563,6 @@ export default function ForwardPage() {
|
||||
const [importData, setImportData] = useState("");
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [importFormat, setImportFormat] = useState<ImportFormat>("flvx");
|
||||
const [selectedEntryNode, setSelectedEntryNode] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [matchedTunnels, setMatchedTunnels] = useState<Tunnel[]>([]);
|
||||
const [tunnelSelectModalOpen, setTunnelSelectModalOpen] = useState(false);
|
||||
const [selectedTunnelForImport, setSelectedTunnelForImport] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
@@ -591,6 +586,7 @@ export default function ForwardPage() {
|
||||
strategy: "fifo",
|
||||
speedId: null,
|
||||
});
|
||||
const [inIpTouched, setInIpTouched] = useState(false);
|
||||
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
@@ -674,6 +670,16 @@ export default function ForwardPage() {
|
||||
return tunnelInIpOptionMap.get(form.tunnelId) || [];
|
||||
}, [form.tunnelId, tunnelInIpOptionMap]);
|
||||
|
||||
const isCurrentTunnelMultiEntrance = useMemo(() => {
|
||||
if (!form.tunnelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTunnel = allTunnels.find((tunnel) => tunnel.id === form.tunnelId);
|
||||
|
||||
return (currentTunnel?.inNodeId?.length || 0) > 1;
|
||||
}, [allTunnels, form.tunnelId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
diagnosisAbortRef.current?.abort();
|
||||
@@ -1206,6 +1212,10 @@ export default function ForwardPage() {
|
||||
);
|
||||
}, [speedLimits]);
|
||||
|
||||
const speedLimitIds = useMemo(() => {
|
||||
return new Set(speedLimits.map((speedLimit) => speedLimit.id));
|
||||
}, [speedLimits]);
|
||||
|
||||
const availableSpeedLimits = useMemo(() => {
|
||||
return speedLimits.filter(
|
||||
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
|
||||
@@ -1217,7 +1227,27 @@ export default function ForwardPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
|
||||
if (noLimitSpeedLimitIds.has(speedId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return speedId;
|
||||
};
|
||||
|
||||
const isMissingSpeedLimit = (speedId?: number | null): boolean => {
|
||||
if (speedId === null || speedId === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !speedLimitIds.has(speedId);
|
||||
};
|
||||
|
||||
const selectedSpeedId = normalizeSpeedId(form.speedId);
|
||||
@@ -1281,6 +1311,7 @@ export default function ForwardPage() {
|
||||
// 新增转发
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false);
|
||||
setInIpTouched(false);
|
||||
setForm({
|
||||
name: "",
|
||||
tunnelId: null,
|
||||
@@ -1298,6 +1329,7 @@ export default function ForwardPage() {
|
||||
// 编辑转发
|
||||
const handleEdit = (forward: Forward) => {
|
||||
setIsEdit(true);
|
||||
setInIpTouched(false);
|
||||
setForm({
|
||||
id: forward.id,
|
||||
userId: forward.userId,
|
||||
@@ -1362,11 +1394,17 @@ export default function ForwardPage() {
|
||||
const nextTunnelId = parseInt(tunnelId);
|
||||
const options = tunnelInIpOptionMap.get(nextTunnelId) || [];
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
tunnelId: nextTunnelId,
|
||||
inIp: options.includes(prev.inIp) ? prev.inIp : "",
|
||||
}));
|
||||
setInIpTouched(false);
|
||||
|
||||
setForm((prev) => {
|
||||
const tunnelChanged = prev.tunnelId !== nextTunnelId;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
tunnelId: nextTunnelId,
|
||||
inIp: tunnelChanged ? "" : options.includes(prev.inIp) ? prev.inIp : "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
@@ -1384,6 +1422,8 @@ export default function ForwardPage() {
|
||||
const addressCount = processedRemoteAddr.split(",").length;
|
||||
|
||||
let res: { code: number; msg: string };
|
||||
const normalizedSpeedId = normalizeSpeedId(form.speedId);
|
||||
const speedLimitAutoCleared = isMissingSpeedLimit(form.speedId);
|
||||
|
||||
if (isEdit) {
|
||||
// 更新时确保包含必要字段
|
||||
@@ -1393,10 +1433,10 @@ export default function ForwardPage() {
|
||||
name: form.name,
|
||||
tunnelId: form.tunnelId,
|
||||
inPort: form.inPort,
|
||||
inIp: form.inIp || undefined,
|
||||
...(inIpTouched ? { inIp: form.inIp || "" } : {}),
|
||||
remoteAddr: processedRemoteAddr,
|
||||
strategy: addressCount > 1 ? form.strategy : "fifo",
|
||||
speedId: normalizeSpeedId(form.speedId),
|
||||
speedId: normalizedSpeedId,
|
||||
};
|
||||
|
||||
res = await updateForward(updateData);
|
||||
@@ -1408,13 +1448,33 @@ export default function ForwardPage() {
|
||||
inIp: form.inIp || undefined,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
strategy: addressCount > 1 ? form.strategy : "fifo",
|
||||
speedId: normalizeSpeedId(form.speedId),
|
||||
speedId: normalizedSpeedId,
|
||||
};
|
||||
|
||||
res = await createForward(createData);
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
const warningItems = Array.isArray((res as any).data?.warnings)
|
||||
? (res as any).data.warnings
|
||||
.map((item: unknown) =>
|
||||
typeof item === "string" ? item.trim() : "",
|
||||
)
|
||||
.filter((item: string) => item)
|
||||
: [];
|
||||
|
||||
warningItems.forEach((warning: string) => {
|
||||
toast(warning, {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
if (speedLimitAutoCleared) {
|
||||
toast("所选限速规则不存在,已自动清除为不限速", {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
toast.success(isEdit ? "修改成功" : "创建成功");
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
@@ -4199,35 +4259,37 @@ export default function ForwardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="限速规则"
|
||||
placeholder="不限速"
|
||||
selectedKeys={
|
||||
selectedSpeedId !== null
|
||||
? [selectedSpeedId.toString()]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as
|
||||
| string
|
||||
| undefined;
|
||||
{isAdmin && (
|
||||
<Select
|
||||
label="限速规则"
|
||||
placeholder="不限速"
|
||||
selectedKeys={
|
||||
selectedSpeedId !== null
|
||||
? [selectedSpeedId.toString()]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
speedId: selectedKey ? Number(selectedKey) : null,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{availableSpeedLimits.map((speedLimit) => (
|
||||
<SelectItem
|
||||
key={speedLimit.id.toString()}
|
||||
textValue={speedLimit.name}
|
||||
>
|
||||
{speedLimit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
speedId: selectedKey ? Number(selectedKey) : null,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{availableSpeedLimits.map((speedLimit) => (
|
||||
<SelectItem
|
||||
key={speedLimit.id.toString()}
|
||||
textValue={speedLimit.name}
|
||||
>
|
||||
{speedLimit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Select
|
||||
description={
|
||||
@@ -4256,33 +4318,43 @@ export default function ForwardPage() {
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
errorMessage={errors.inPort}
|
||||
isInvalid={!!errors.inPort}
|
||||
label="入口端口"
|
||||
placeholder="留空则自动分配可用端口"
|
||||
type="number"
|
||||
value={form.inPort !== null ? form.inPort.toString() : ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
{isAdmin && (
|
||||
<Input
|
||||
description="指定入口端口,留空则从节点可用端口中自动分配"
|
||||
errorMessage={errors.inPort}
|
||||
isInvalid={!!errors.inPort}
|
||||
label="入口端口"
|
||||
placeholder="留空则自动分配可用端口"
|
||||
type="number"
|
||||
value={form.inPort !== null ? form.inPort.toString() : ""}
|
||||
variant="bordered"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inPort: value ? parseInt(value) : null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inPort: value ? parseInt(value) : null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
description="从入口节点IP中选择,留空使用默认"
|
||||
description={
|
||||
isCurrentTunnelMultiEntrance
|
||||
? "多入口隧道不支持自定义监听IP,使用各节点默认IP"
|
||||
: "从入口节点IP中选择,留空使用默认"
|
||||
}
|
||||
isDisabled={
|
||||
!form.tunnelId || currentTunnelIpOptions.length === 0
|
||||
!form.tunnelId ||
|
||||
currentTunnelIpOptions.length === 0 ||
|
||||
isCurrentTunnelMultiEntrance
|
||||
}
|
||||
label="监听IP"
|
||||
placeholder={
|
||||
form.tunnelId
|
||||
isCurrentTunnelMultiEntrance
|
||||
? "多入口隧道使用节点默认IP"
|
||||
: form.tunnelId
|
||||
? currentTunnelIpOptions.length > 0
|
||||
? "选择入口监听IP"
|
||||
: "当前隧道入口节点暂无可选IP"
|
||||
@@ -4293,6 +4365,8 @@ export default function ForwardPage() {
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
setInIpTouched(true);
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inIp: selectedKey === "__default__" ? "" : selectedKey,
|
||||
@@ -4625,10 +4699,10 @@ export default function ForwardPage() {
|
||||
) : (
|
||||
<>
|
||||
<p className="text-small text-default-500">
|
||||
ny格式:JSON对象,支持多个目标地址(负载均衡)
|
||||
ny格式:JSON对象,支持多个目标地址(负载均衡),按所选隧道导入
|
||||
</p>
|
||||
<p className="text-small text-default-400">
|
||||
格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}
|
||||
格式:{"dest":["地址:端口"],"listen_port":端口,"name":"名称"}(listen_port可省略,自动分配端口)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -4648,8 +4722,6 @@ export default function ForwardPage() {
|
||||
if (selectedKey) {
|
||||
setImportFormat(selectedKey);
|
||||
setSelectedTunnelForImport(null);
|
||||
setSelectedEntryNode(null);
|
||||
setMatchedTunnels([]);
|
||||
setImportData("");
|
||||
setImportResults([]);
|
||||
}
|
||||
@@ -4663,114 +4735,34 @@ export default function ForwardPage() {
|
||||
</SelectItem>
|
||||
</Select>
|
||||
|
||||
{/* flvx格式:隧道选择 */}
|
||||
{importFormat === "flvx" && (
|
||||
<Select
|
||||
isRequired
|
||||
label="选择导入隧道"
|
||||
placeholder="请选择要导入的隧道"
|
||||
selectedKeys={
|
||||
selectedTunnelForImport
|
||||
? [selectedTunnelForImport.toString()]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
{/* 隧道选择 - 两种格式都需要 */}
|
||||
<Select
|
||||
isRequired
|
||||
label="选择导入隧道"
|
||||
placeholder="请选择要导入的隧道"
|
||||
selectedKeys={
|
||||
selectedTunnelForImport
|
||||
? [selectedTunnelForImport.toString()]
|
||||
: []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
|
||||
setSelectedTunnelForImport(
|
||||
selectedKey ? parseInt(selectedKey) : null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem
|
||||
key={tunnel.id.toString()}
|
||||
textValue={tunnel.name}
|
||||
>
|
||||
{tunnel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* ny格式:入口节点选择 */}
|
||||
{importFormat === "ny" && (
|
||||
<Select
|
||||
isRequired
|
||||
label="选择入口节点"
|
||||
placeholder="请选择入口节点"
|
||||
selectedKeys={
|
||||
selectedEntryNode ? [selectedEntryNode.toString()] : []
|
||||
}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const nodeId = selectedKey ? parseInt(selectedKey) : null;
|
||||
|
||||
setSelectedEntryNode(nodeId);
|
||||
setSelectedTunnelForImport(null);
|
||||
|
||||
if (nodeId) {
|
||||
const matched = allTunnels.filter(
|
||||
(t) =>
|
||||
t.type === 1 &&
|
||||
t.inNodeId?.some((n) => n.nodeId === nodeId),
|
||||
);
|
||||
|
||||
setMatchedTunnels(matched);
|
||||
|
||||
if (matched.length === 0) {
|
||||
toast.error(
|
||||
"该入口节点没有匹配的隧道,请先创建端口转发类型的隧道",
|
||||
);
|
||||
} else if (matched.length === 1) {
|
||||
setSelectedTunnelForImport(matched[0].id);
|
||||
} else {
|
||||
setTunnelSelectModalOpen(true);
|
||||
}
|
||||
} else {
|
||||
setMatchedTunnels([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem key={node.id.toString()} textValue={node.name}>
|
||||
{node.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* ny格式:显示匹配的隧道 */}
|
||||
{importFormat === "ny" && matchedTunnels.length > 0 && (
|
||||
<div className="text-xs text-default-500">
|
||||
{matchedTunnels.length === 1 ? (
|
||||
<span>
|
||||
已匹配隧道:<strong>{matchedTunnels[0].name}</strong>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
找到 {matchedTunnels.length}{" "}
|
||||
个匹配隧道,请点击下方按钮选择
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ny格式:多隧道选择按钮 */}
|
||||
{importFormat === "ny" &&
|
||||
matchedTunnels.length > 1 &&
|
||||
!selectedTunnelForImport && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setTunnelSelectModalOpen(true)}
|
||||
setSelectedTunnelForImport(
|
||||
selectedKey ? parseInt(selectedKey) : null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem
|
||||
key={tunnel.id.toString()}
|
||||
textValue={tunnel.name}
|
||||
>
|
||||
选择隧道({matchedTunnels.length}个可选)
|
||||
</Button>
|
||||
)}
|
||||
{tunnel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<Textarea
|
||||
@@ -4783,7 +4775,7 @@ export default function ForwardPage() {
|
||||
placeholder={
|
||||
importFormat === "flvx"
|
||||
? "请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
|
||||
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"}'
|
||||
: '请输入ny格式数据,每行一个JSON对象,如:{"dest":["1.2.3.4:80"],"listen_port":8080,"name":"转发1"};listen_port可省略自动分配'
|
||||
}
|
||||
value={importData}
|
||||
variant="flat"
|
||||
@@ -4887,11 +4879,7 @@ export default function ForwardPage() {
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isDisabled={
|
||||
!importData.trim() ||
|
||||
!selectedTunnelForImport ||
|
||||
(importFormat === "ny" && !selectedEntryNode)
|
||||
}
|
||||
isDisabled={!importData.trim() || !selectedTunnelForImport}
|
||||
isLoading={importLoading}
|
||||
onPress={executeImport}
|
||||
>
|
||||
@@ -4901,54 +4889,6 @@ export default function ForwardPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 隧道选择模态框(ny格式多隧道匹配时使用) */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isOpen={tunnelSelectModalOpen}
|
||||
placement="center"
|
||||
size="md"
|
||||
onClose={() => setTunnelSelectModalOpen(false)}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择隧道</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-sm text-default-500 mb-3">
|
||||
找到多个使用该入口节点的隧道,请选择一个:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{matchedTunnels.map((tunnel) => (
|
||||
<Button
|
||||
key={tunnel.id}
|
||||
className="w-full justify-start"
|
||||
color={
|
||||
selectedTunnelForImport === tunnel.id
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
variant={
|
||||
selectedTunnelForImport === tunnel.id ? "solid" : "bordered"
|
||||
}
|
||||
onPress={() => {
|
||||
setSelectedTunnelForImport(tunnel.id);
|
||||
setTunnelSelectModalOpen(false);
|
||||
}}
|
||||
>
|
||||
{tunnel.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant="light"
|
||||
onPress={() => setTunnelSelectModalOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 诊断结果模态框 */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
|
||||
@@ -46,6 +46,33 @@ test("parseNyFormatData returns validation errors for invalid fields", () => {
|
||||
assert.match(result[1].error || "", /目标地址格式错误/);
|
||||
});
|
||||
|
||||
test("parseNyFormatData allows missing listen_port for auto assignment", () => {
|
||||
const input = '{"dest":["1.1.1.1:1000"],"name":"No Port"}';
|
||||
|
||||
const result = parseNyFormatData(input);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].error, undefined);
|
||||
assert.equal(result[0].parsed?.listen_port, null);
|
||||
});
|
||||
|
||||
test("parseNyFormatData supports ny alias fields", () => {
|
||||
const input =
|
||||
'{"dst":["2.2.2.2:2000"],"listenPort":"3000","forward_name":"Alias A"}\n{"target":"3.3.3.3:4000,4.4.4.4:5000","port":6000,"forwardName":"Alias B"}';
|
||||
|
||||
const result = parseNyFormatData(input);
|
||||
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0].error, undefined);
|
||||
assert.equal(result[1].error, undefined);
|
||||
assert.deepEqual(result[0].parsed?.dest, ["2.2.2.2:2000"]);
|
||||
assert.equal(result[0].parsed?.listen_port, 3000);
|
||||
assert.equal(result[0].parsed?.name, "Alias A");
|
||||
assert.deepEqual(result[1].parsed?.dest, ["3.3.3.3:4000", "4.4.4.4:5000"]);
|
||||
assert.equal(result[1].parsed?.listen_port, 6000);
|
||||
assert.equal(result[1].parsed?.name, "Alias B");
|
||||
});
|
||||
|
||||
test("convertNyItemToForwardInput maps ny fields correctly", () => {
|
||||
const mapped = convertNyItemToForwardInput({
|
||||
dest: ["1.1.1.1:1111", "2.2.2.2:2222"],
|
||||
@@ -60,3 +87,18 @@ test("convertNyItemToForwardInput maps ny fields correctly", () => {
|
||||
strategy: "fifo",
|
||||
});
|
||||
});
|
||||
|
||||
test("convertNyItemToForwardInput keeps null inPort for auto assignment", () => {
|
||||
const mapped = convertNyItemToForwardInput({
|
||||
dest: ["1.1.1.1:1111"],
|
||||
listen_port: null,
|
||||
name: "No Port",
|
||||
});
|
||||
|
||||
assert.deepEqual(mapped, {
|
||||
name: "No Port",
|
||||
inPort: null,
|
||||
remoteAddr: "1.1.1.1:1111",
|
||||
strategy: "fifo",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface NyImportItem {
|
||||
dest: string[];
|
||||
listen_port: number;
|
||||
listen_port: number | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,70 @@ export interface ParsedNyImportLine {
|
||||
|
||||
const ADDRESS_PATTERN = /^[^:]+:\d+$/;
|
||||
|
||||
const getAliasField = (
|
||||
item: Record<string, unknown>,
|
||||
aliases: string[],
|
||||
): unknown => {
|
||||
for (const alias of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, alias)) {
|
||||
return item[alias];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeDestList = (value: unknown): string[] | null => {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map((itemValue) =>
|
||||
typeof itemValue === "string" ? itemValue.trim() : "",
|
||||
);
|
||||
|
||||
if (normalized.some((itemValue) => itemValue === "")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const normalized = value
|
||||
.split(",")
|
||||
.map((itemValue) => itemValue.trim())
|
||||
.filter((itemValue) => itemValue !== "");
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeListenPort = (value: unknown): number | null | undefined => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? value : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Number.parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isValidListenPort = (value: unknown): value is number => {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
@@ -27,11 +91,19 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const dest = item.dest;
|
||||
const listenPort = item.listen_port;
|
||||
const name = item.name;
|
||||
const dest = getAliasField(item, ["dest", "dst", "target", "targets"]);
|
||||
const listenPortRaw = getAliasField(item, [
|
||||
"listen_port",
|
||||
"listenPort",
|
||||
"port",
|
||||
"in_port",
|
||||
"inPort",
|
||||
]);
|
||||
const name = getAliasField(item, ["name", "forward_name", "forwardName"]);
|
||||
const normalizedDest = normalizeDestList(dest);
|
||||
const normalizedListenPort = normalizeListenPort(listenPortRaw);
|
||||
|
||||
if (!Array.isArray(dest) || dest.length === 0) {
|
||||
if (!normalizedDest || normalizedDest.length === 0) {
|
||||
return { line, error: "dest数组为空或格式错误" };
|
||||
}
|
||||
|
||||
@@ -39,16 +111,15 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
||||
return { line, error: "name不能为空" };
|
||||
}
|
||||
|
||||
if (!isValidListenPort(listenPort)) {
|
||||
return { line, error: "listen_port必须为1-65535之间的数字" };
|
||||
if (normalizedListenPort === undefined) {
|
||||
return { line, error: "listen_port格式错误,应为1-65535之间的数字" };
|
||||
}
|
||||
|
||||
const normalizedDest = dest.map((itemValue) =>
|
||||
typeof itemValue === "string" ? itemValue.trim() : "",
|
||||
);
|
||||
|
||||
if (normalizedDest.some((itemValue) => itemValue === "")) {
|
||||
return { line, error: "dest中包含空地址" };
|
||||
if (
|
||||
normalizedListenPort !== null &&
|
||||
!isValidListenPort(normalizedListenPort)
|
||||
) {
|
||||
return { line, error: "listen_port必须为1-65535之间的数字" };
|
||||
}
|
||||
|
||||
const invalid = normalizedDest.find(
|
||||
@@ -63,7 +134,7 @@ const validateNyItem = (line: string, value: unknown): ParsedNyImportLine => {
|
||||
line,
|
||||
parsed: {
|
||||
dest: normalizedDest,
|
||||
listen_port: listenPort,
|
||||
listen_port: normalizedListenPort,
|
||||
name: name.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1604,6 +1604,8 @@ export default function TunnelPage() {
|
||||
.map((ct) => ct.nodeId);
|
||||
const groupIpOptions =
|
||||
getCommonIpOptions(groupSelectedNodeIds);
|
||||
const isMultiNodeGroup =
|
||||
groupSelectedNodeIds.length > 1;
|
||||
const selectedGroupConnectIp =
|
||||
groupNodes.length > 0
|
||||
? groupNodes[0].connectIp || ""
|
||||
@@ -1826,14 +1828,21 @@ export default function TunnelPage() {
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description="按当前跳所选节点的共有IP进行选择,留空使用默认"
|
||||
description={
|
||||
isMultiNodeGroup
|
||||
? "多节点跳不支持设置自定义连接IP,使用各节点默认IP"
|
||||
: "按当前跳所选节点的共有IP进行选择,留空使用默认"
|
||||
}
|
||||
isDisabled={
|
||||
groupSelectedNodeIds.length === 0 ||
|
||||
groupIpOptions.length === 0
|
||||
groupIpOptions.length === 0 ||
|
||||
isMultiNodeGroup
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
groupSelectedNodeIds.length === 0
|
||||
isMultiNodeGroup
|
||||
? "多节点跳使用节点默认IP"
|
||||
: groupSelectedNodeIds.length === 0
|
||||
? "请先选择节点"
|
||||
: groupIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
@@ -1886,7 +1895,17 @@ export default function TunnelPage() {
|
||||
<Divider />
|
||||
<h3 className="text-lg font-semibold">出口配置</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
{(() => {
|
||||
const selectedOutNodeIds = (form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId);
|
||||
const isMultiExit = selectedOutNodeIds.length > 1;
|
||||
const commonOutIpOptions =
|
||||
getCommonIpOptions(selectedOutNodeIds);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
{/* 节点选择 - 移动端100%,桌面端50% */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Select
|
||||
@@ -2124,88 +2143,82 @@ export default function TunnelPage() {
|
||||
<SelectItem key="round">轮询</SelectItem>
|
||||
<SelectItem key="rand">随机</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接IP - 出口节点 */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description="按出口节点共同可用IP选择,留空使用默认"
|
||||
isDisabled={
|
||||
(form.outNodeId || []).filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
).length === 0 ||
|
||||
getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).length === 0
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
(form.outNodeId || []).filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
).length === 0
|
||||
? "请先选择出口节点"
|
||||
: getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
form.outNodeId && form.outNodeId.length > 0
|
||||
? form.outNodeId[0].connectIp || "__default__"
|
||||
: "__default__",
|
||||
]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const value =
|
||||
selectedKey === "__default__" ? "" : selectedKey;
|
||||
{/* 连接IP - 出口节点 */}
|
||||
<Select
|
||||
classNames={{
|
||||
label: "text-xs",
|
||||
value: "text-sm",
|
||||
}}
|
||||
description={
|
||||
isMultiExit
|
||||
? "多出口隧道不支持设置自定义连接IP,使用各节点默认IP"
|
||||
: "按出口节点共同可用IP选择,留空使用默认"
|
||||
}
|
||||
isDisabled={
|
||||
selectedOutNodeIds.length === 0 ||
|
||||
commonOutIpOptions.length === 0 ||
|
||||
isMultiExit
|
||||
}
|
||||
label="连接IP"
|
||||
placeholder={
|
||||
isMultiExit
|
||||
? "多出口隧道使用节点默认IP"
|
||||
: selectedOutNodeIds.length === 0
|
||||
? "请先选择出口节点"
|
||||
: commonOutIpOptions.length > 0
|
||||
? "选择连接IP"
|
||||
: "所选节点无共同可选IP"
|
||||
}
|
||||
selectedKeys={[
|
||||
form.outNodeId && form.outNodeId.length > 0
|
||||
? form.outNodeId[0].connectIp || "__default__"
|
||||
: "__default__",
|
||||
]}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
const value =
|
||||
selectedKey === "__default__" ? "" : selectedKey;
|
||||
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: "tls",
|
||||
strategy: "round",
|
||||
connectIp: value,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (currentOutNodes.length === 0) {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: [
|
||||
{
|
||||
nodeId: -1,
|
||||
chainType: 3,
|
||||
protocol: "tls",
|
||||
strategy: "round",
|
||||
connectIp: value,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
connectIp: value,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
{getCommonIpOptions(
|
||||
(form.outNodeId || [])
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId),
|
||||
).map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: currentOutNodes.map((ct) => ({
|
||||
...ct,
|
||||
connectIp: value,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectItem key="__default__">默认连接IP</SelectItem>
|
||||
{commonOutIpOptions.map((ip) => (
|
||||
<SelectItem key={ip}>{ip}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -227,12 +227,36 @@ export default function UserPage() {
|
||||
);
|
||||
}, [speedLimits]);
|
||||
|
||||
const speedLimitIds = useMemo(() => {
|
||||
return new Set(speedLimits.map((speedLimit) => speedLimit.id));
|
||||
}, [speedLimits]);
|
||||
|
||||
const normalizeSpeedId = (speedId?: number | null): number | null => {
|
||||
if (speedId === null || speedId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
|
||||
if (noLimitSpeedLimitIds.has(speedId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return speedId;
|
||||
};
|
||||
|
||||
const isMissingSpeedLimit = (speedId?: number | null): boolean => {
|
||||
if (speedId === null || speedId === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !speedLimitIds.has(speedId);
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
@@ -446,11 +470,20 @@ export default function UserPage() {
|
||||
|
||||
setAssignLoading(true);
|
||||
try {
|
||||
let speedLimitAutoCleared = false;
|
||||
const tunnelsToAssign: TunnelAssignItem[] = Array.from(
|
||||
batchTunnelSelections.entries(),
|
||||
).map(([tunnelId, speedId]) => ({
|
||||
tunnelId,
|
||||
speedId: normalizeSpeedId(speedId),
|
||||
speedId: (() => {
|
||||
const cleared = normalizeSpeedId(speedId);
|
||||
|
||||
if (isMissingSpeedLimit(speedId)) {
|
||||
speedLimitAutoCleared = true;
|
||||
}
|
||||
|
||||
return cleared;
|
||||
})(),
|
||||
}));
|
||||
|
||||
const response = await batchAssignUserTunnel({
|
||||
@@ -459,6 +492,12 @@ export default function UserPage() {
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
if (speedLimitAutoCleared) {
|
||||
toast("所选限速规则不存在,已自动清除为不限速", {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
toast.success(response.msg || "分配成功");
|
||||
setBatchTunnelSelections(new Map());
|
||||
loadUserTunnels(currentUser.id);
|
||||
@@ -486,6 +525,7 @@ export default function UserPage() {
|
||||
|
||||
setEditTunnelLoading(true);
|
||||
try {
|
||||
const speedLimitAutoCleared = isMissingSpeedLimit(editTunnelForm.speedId);
|
||||
const response = await updateUserTunnel({
|
||||
id: editTunnelForm.id,
|
||||
flow: editTunnelForm.flow,
|
||||
@@ -497,6 +537,12 @@ export default function UserPage() {
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
if (speedLimitAutoCleared) {
|
||||
toast("所选限速规则不存在,已自动清除为不限速", {
|
||||
icon: "⚠️",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
toast.success("更新成功");
|
||||
onEditTunnelModalClose();
|
||||
if (currentUser) {
|
||||
|
||||
Reference in New Issue
Block a user