mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat: auto-fallback to default bind IP when invalid bind address detected
When a forward service is updated with a bind IP that doesn't exist on the host network interfaces, the system now automatically falls back to the default bind address (listening on all interfaces) instead of failing. - Added syncForwardServicesWithWarnings to collect fallback warnings - Implemented fallbackForwardPortToDefaultBind for graceful degradation - Added UpdateForwardPortBindIP repository method to persist fallback - Enhanced error detection for 'cannot assign requested address' errors - Frontend displays warning toasts when fallback occurs - Added comprehensive unit tests for new error detection functions
This commit is contained in:
@@ -223,21 +223,27 @@ func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) 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 {
|
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)
|
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
ports, err := h.listForwardPorts(forward.ID)
|
ports, err := h.listForwardPorts(forward.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(ports) == 0 {
|
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
|
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
|
||||||
var limiterID *int64
|
var limiterID *int64
|
||||||
@@ -258,7 +264,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
|||||||
var utSpeed *int
|
var utSpeed *int
|
||||||
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
limiterID = utLimiterID
|
limiterID = utLimiterID
|
||||||
speed = utSpeed
|
speed = utSpeed
|
||||||
@@ -267,33 +273,69 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
|||||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
|
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
|
||||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fp := range ports {
|
for _, fp := range ports {
|
||||||
if limiterID != nil && speed != nil {
|
if limiterID != nil && speed != nil {
|
||||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node, err := h.getNodeRecord(fp.NodeID)
|
node, err := h.getNodeRecord(fp.NodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
|
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
|
||||||
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
_, 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)
|
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 {
|
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 {
|
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 == "" {
|
if msg == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.Contains(msg, "address already in use") {
|
return isAddressAlreadyInUseMessage(msg) || strings.Contains(msg, "cannot assign requested address")
|
||||||
return true
|
}
|
||||||
|
|
||||||
|
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")
|
return strings.Contains(msg, "cannot assign requested address")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||||
|
|||||||
@@ -1325,11 +1325,16 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
|
warnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
|
||||||
|
if err != nil {
|
||||||
h.rollbackForwardMutation(forward, oldPorts)
|
h.rollbackForwardMutation(forward, oldPorts)
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(warnings) > 0 {
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{"warnings": warnings}))
|
||||||
|
return
|
||||||
|
}
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
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 {
|
if r == nil || r.db == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1419,6 +1419,18 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.code === 0) {
|
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 ? "修改成功" : "创建成功");
|
toast.success(isEdit ? "修改成功" : "创建成功");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
loadData();
|
loadData();
|
||||||
|
|||||||
Reference in New Issue
Block a user