diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index b4ed183..47bfe8a 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -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,33 +273,69 @@ 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 && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isBindAddressInUseError(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 fmt.Errorf("节点 %s 下发失败: %w", node.Name, err) + return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err) } } - return nil + 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 { @@ -1385,8 +1427,30 @@ func isBindAddressInUseError(err error) bool { if msg == "" { return false } - if strings.Contains(msg, "address already in use") { - return true + 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") } diff --git a/go-backend/internal/http/handler/control_plane_test.go b/go-backend/internal/http/handler/control_plane_test.go index 02d8586..40bef60 100644 --- a/go-backend/internal/http/handler/control_plane_test.go +++ b/go-backend/internal/http/handler/control_plane_test.go @@ -82,6 +82,24 @@ func TestIsBindAddressInUseError(t *testing.T) { } } +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: "[::]"} diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 112e093..fc36b3b 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -1325,11 +1325,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()) } diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 76cc6bc..ad29f59 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -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 diff --git a/plans/005-forward-invalid-bindip-fallback-default.md b/plans/005-forward-invalid-bindip-fallback-default.md new file mode 100644 index 0000000..f5367b5 --- /dev/null +++ b/plans/005-forward-invalid-bindip-fallback-default.md @@ -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. diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index cafa436..7c3dfb8 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1419,6 +1419,18 @@ export default function ForwardPage() { } 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, + }); + }); toast.success(isEdit ? "修改成功" : "创建成功"); setModalOpen(false); loadData();