diff --git a/go-backend/MIGRATION_PLAN.md b/go-backend/MIGRATION_PLAN.md index 5a9fe10..baf3c8e 100644 --- a/go-backend/MIGRATION_PLAN.md +++ b/go-backend/MIGRATION_PLAN.md @@ -90,7 +90,7 @@ | 表名 | Model | 特殊处理 | |------|-------|----------| | `user` | `User` | `TableName()` 返回 `"user"` (PG 保留字) | -| `forward` | `Forward` | | +| `forward` | `Forward` | 增加 `proxy_protocol` 字段 | | `forward_port` | `ForwardPort` | | | `node` | `Node` | | | `speed_limit` | `SpeedLimit` | | diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index e4f8d1e..9cc5479 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -1705,6 +1705,12 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel if cLimiterName != "" { service["climiter"] = cLimiterName } + if forward.ProxyProtocol > 0 { + if service["metadata"] == nil { + service["metadata"] = map[string]interface{}{} + } + service["metadata"].(map[string]interface{})["proxyProtocol"] = forward.ProxyProtocol + } if protocol == "udp" { listenerMetadata := map[string]interface{}{ "keepAlive": true, @@ -1716,7 +1722,10 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID) } if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" { - service["metadata"] = map[string]interface{}{"interface": node.InterfaceName} + if service["metadata"] == nil { + service["metadata"] = map[string]interface{}{} + } + service["metadata"].(map[string]interface{})["interface"] = node.InterfaceName } if limiterID != nil && *limiterID > 0 { service["limiter"] = strconv.FormatInt(*limiterID, 10) diff --git a/go-backend/internal/http/handler/forward_proxy_protocol_test.go b/go-backend/internal/http/handler/forward_proxy_protocol_test.go new file mode 100644 index 0000000..dacaa17 --- /dev/null +++ b/go-backend/internal/http/handler/forward_proxy_protocol_test.go @@ -0,0 +1,98 @@ +package handler + +import ( + "testing" + "time" + + "go-backend/internal/store/model" + "go-backend/internal/store/repo" +) + +func TestBuildForwardServiceConfigsPreservesProxyProtocolWithInterfaceMetadata(t *testing.T) { + forward := &forwardRecord{ + ID: 1, + UserID: 2, + TunnelID: 3, + RemoteAddr: "1.1.1.1:443", + Strategy: "fifo", + ProxyProtocol: 2, + } + tunnel := &tunnelRecord{Type: 1} + node := &nodeRecord{ + InterfaceName: "eth0", + TCPListenAddr: "0.0.0.0", + UDPListenAddr: "0.0.0.0", + } + + services := buildForwardServiceConfigs("1_2_3", forward, tunnel, node, 4001, "", nil, "") + if len(services) != 2 { + t.Fatalf("expected 2 services, got %d", len(services)) + } + + for _, service := range services { + metadata, ok := service["metadata"].(map[string]interface{}) + if !ok { + t.Fatalf("expected metadata map, got %T", service["metadata"]) + } + if metadata["interface"] != "eth0" { + t.Fatalf("expected interface metadata eth0, got %v", metadata["interface"]) + } + if metadata["proxyProtocol"] != 2 { + t.Fatalf("expected proxyProtocol 2, got %v", metadata["proxyProtocol"]) + } + } +} + +func TestRollbackForwardMutationRestoresProxyProtocol(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + if err := r.DB().Create(&model.Forward{ + UserID: 2, + UserName: "rollback-user", + Name: "rollback-forward", + TunnelID: 3, + RemoteAddr: "9.9.9.9:443", + Strategy: "fifo", + CreatedTime: now, + UpdatedTime: now, + Status: 1, + ProxyProtocol: 2, + }).Error; err != nil { + t.Fatalf("create forward: %v", err) + } + + forwardID := mustLastInsertID(t, r, "rollback-forward") + if err := r.DB().Model(&model.Forward{}).Where("id = ?", forwardID).Updates(map[string]interface{}{ + "name": "changed-forward", + "proxy_protocol": 0, + "updated_time": now + 1, + }).Error; err != nil { + t.Fatalf("mutate forward: %v", err) + } + + h := &Handler{repo: r} + h.rollbackForwardMutation(&forwardRecord{ + ID: forwardID, + UserID: 2, + UserName: "rollback-user", + Name: "rollback-forward", + TunnelID: 3, + RemoteAddr: "9.9.9.9:443", + Strategy: "fifo", + Status: 1, + ProxyProtocol: 2, + }, nil) + + var proxyProtocol int + if err := r.DB().Raw("SELECT proxy_protocol FROM forward WHERE id = ?", forwardID).Row().Scan(&proxyProtocol); err != nil { + t.Fatalf("query proxy_protocol: %v", err) + } + if proxyProtocol != 2 { + t.Fatalf("expected proxyProtocol restored to 2, got %d", proxyProtocol) + } +} diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 9324737..2c5f112 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -1728,7 +1728,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { } if roleID != 0 { if err := IsSafeRemoteAddr(remoteAddr); err != nil { - response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络")) + response.WriteJSON(w, response.Err(403, err.Error())) return } if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil { @@ -1780,7 +1780,9 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { userName = "user" } maxConn := asInt(req["maxConn"], 0) - forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID), maxConn) + proxyProtocol := asInt(req["proxyProtocol"], 0) + + forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID), maxConn, proxyProtocol) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return @@ -1853,7 +1855,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { } if actorRole != 0 { if err := IsSafeRemoteAddr(remoteAddr); err != nil { - response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络")) + response.WriteJSON(w, response.Err(403, err.Error())) return } } @@ -1932,8 +1934,9 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { } now := time.Now().UnixMilli() maxConn := asInt(req["maxConn"], forward.MaxConn) + proxyProtocol := asInt(req["proxyProtocol"], forward.ProxyProtocol) - if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID, maxConn); err != nil { + if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID, maxConn, proxyProtocol); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } @@ -4085,7 +4088,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts [] h.repo.RollbackForwardFields( oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name, oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status, - oldForward.SpeedID, oldForward.MaxConn, + oldForward.SpeedID, oldForward.MaxConn, oldForward.ProxyProtocol, time.Now().UnixMilli(), ) diff --git a/go-backend/internal/http/handler/security_utils.go b/go-backend/internal/http/handler/security_utils.go index 7132fa7..3516729 100644 --- a/go-backend/internal/http/handler/security_utils.go +++ b/go-backend/internal/http/handler/security_utils.go @@ -11,11 +11,37 @@ var DisableSafeRemoteAddrCheckForTesting = false // IsSafeRemoteAddr checks if a given address is safe to connect to (prevents SSRF/Open Proxy). // It resolves domains to IPs to prevent DNS rebinding attacks pointing to internal networks. +// Supports multiple addresses separated by commas or newlines (one per line). func IsSafeRemoteAddr(addr string) error { if DisableSafeRemoteAddrCheckForTesting { return nil } + for _, part := range splitRemoteParts(addr) { + if err := checkSingleRemoteAddr(part); err != nil { + return err + } + } + return nil +} + +// splitRemoteParts splits a multi-address string by commas and newlines. +func splitRemoteParts(addr string) []string { + addr = strings.ReplaceAll(addr, "\n", ",") + addr = strings.ReplaceAll(addr, "\r", ",") + parts := strings.Split(addr, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +// checkSingleRemoteAddr validates a single address. +func checkSingleRemoteAddr(addr string) error { host, _, err := net.SplitHostPort(addr) if err != nil { if strings.Contains(err.Error(), "missing port in address") { @@ -27,12 +53,12 @@ func IsSafeRemoteAddr(addr string) error { ips, err := net.LookupIP(host) if err != nil { - return fmt.Errorf("could not resolve address: %v", err) + return fmt.Errorf("could not resolve address %q: %v", addr, err) } for _, ip := range ips { if ip.IsLoopback() || ip.IsPrivate() { - return fmt.Errorf("address resolves to internal IP: %s", ip.String()) + return fmt.Errorf("address %q resolves to internal IP: %s", addr, ip.String()) } } diff --git a/go-backend/internal/http/handler/upgrade.go b/go-backend/internal/http/handler/upgrade.go index ebde530..bf203f1 100644 --- a/go-backend/internal/http/handler/upgrade.go +++ b/go-backend/internal/http/handler/upgrade.go @@ -13,6 +13,13 @@ import ( "go-backend/internal/http/response" ) +// failedForward tracks a forward that failed redeployment, for retry. +type failedForward struct { + id int64 + forward *forwardRecord + err error +} + const ( githubRepo = "Sagit-chu/flvx" githubAPIBase = "https://api.github.com" @@ -390,6 +397,9 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool { func (h *Handler) onNodeOnline(nodeID int64) { h.consumeNodePendingUpgradeRedeploy(nodeID) + // Always redeploy rules on reconnection, not just for pending upgrade nodes. + // This handles cases where the node restarted and lost its in-memory config + // before persistence had time to flush, or if the panel also restarted. h.redeployNodeRuntimeAfterUpgrade(nodeID) } @@ -405,6 +415,7 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) { return } + // First pass: deploy everything tunnelFailed := make(map[int64]struct{}) for _, tunnelID := range tunnelIDs { if err := h.redeployTunnelAndForwards(tunnelID); err != nil { @@ -413,6 +424,9 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) { } } + // Collect forwards that failed independently (not skipped due to tunnel failure) + var failedForwards []failedForward + for _, forwardID := range forwardIDs { forward, getErr := h.getForwardRecord(forwardID) if getErr != nil || forward == nil { @@ -422,7 +436,86 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) { continue } if err := h.syncForwardServices(forward, "UpdateService", true); err != nil { + failedForwards = append(failedForwards, failedForward{id: forwardID, forward: forward, err: err}) fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err) } } + + // Retry failed items with exponential backoff (max 3 attempts) + h.retryFailedRedeploys(nodeID, tunnelFailed, failedForwards) +} + +// isRetryableError returns true if the error looks transient and worth retrying. +func isRetryableError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + // Skip non-retryable errors: not-found, already-exists, validation errors + if strings.Contains(msg, "not found") || strings.Contains(msg, "不存在") { + return false + } + if strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") { + return false + } + // Everything else (timeout, connection lost, port in use, etc.) is retryable + return true +} + +// retryFailedRedeploys retries failed tunnels and forwards with exponential backoff. +func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]struct{}, failedForwards []failedForward) { + if len(tunnelFailed) == 0 && len(failedForwards) == 0 { + return + } + + const maxRetries = 3 + baseDelay := time.Second + + for attempt := 1; attempt <= maxRetries; attempt++ { + delay := baseDelay * time.Duration(1< { const isH5 = useH5Mode(); - const navigate = useNavigate(); - const [authenticated, setAuthenticated] = useState(() => isLoggedIn()); - - useEffect(() => { - const handleSessionChange = () => { - const loggedIn = isLoggedIn(); - - setAuthenticated(loggedIn); - - if (!loggedIn) { - navigate("/", { replace: true }); - } - }; - - window.addEventListener(SESSION_UPDATED_EVENT, handleSessionChange); - - return () => { - window.removeEventListener(SESSION_UPDATED_EVENT, handleSessionChange); - }; - }, [navigate]); - - useEffect(() => { - if (!authenticated) { - navigate("/", { replace: true }); - } - }, [authenticated, navigate]); + const authenticated = isLoggedIn(); if (!authenticated) { return ; @@ -103,6 +78,22 @@ const LoginRoute = () => { function App() { const location = useLocation(); + const navigate = useNavigate(); + + // 全局登录状态监听,当检测到未登录且不在首页时,跳转到首页 + useEffect(() => { + const handleSessionUpdate = () => { + if (!isLoggedIn() && location.pathname !== "/") { + navigate("/", { replace: true }); + } + }; + + window.addEventListener(SESSION_UPDATED_EVENT, handleSessionUpdate); + + return () => { + window.removeEventListener(SESSION_UPDATED_EVENT, handleSessionUpdate); + }; + }, [location.pathname, navigate]); // 处理自定义背景图片 useEffect(() => { diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index dc31da5..cd3c5f0 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -71,6 +71,8 @@ export interface ForwardApiItem { userId?: number; tunnelId?: number; speedId?: number | null; + maxConn?: number; + proxyProtocol?: number; inx?: number; [key: string]: unknown; } @@ -201,7 +203,7 @@ export interface UserPackageInfoApiData { num: number; expTime?: string; flowResetTime?: number; - maxConn?: number; + maxConn?: number; [key: string]: unknown; }; tunnelPermissions: UserTunnelPermissionApiItem[]; @@ -370,6 +372,8 @@ export interface ForwardMutationPayload { remoteAddr?: string; strategy?: string; speedId?: number | null; + maxConn?: number; + proxyProtocol?: number; } export interface SpeedLimitMutationPayload { diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index ee7d7d8..ed68d58 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -241,7 +241,6 @@ export default function AdminLayout({ // 退出登录 const handleLogout = () => { safeLogout(); - navigate("/"); }; // 切换移动端菜单 diff --git a/vite-frontend/src/pages/change-password.tsx b/vite-frontend/src/pages/change-password.tsx index 2d62009..4de6dd9 100644 --- a/vite-frontend/src/pages/change-password.tsx +++ b/vite-frontend/src/pages/change-password.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -26,7 +25,6 @@ export default function ChangePasswordPage() { }); const [loading, setLoading] = useState(false); const [errors, setErrors] = useState>({}); - const navigate = useNavigate(); const validateForm = (): boolean => { const newErrors: Partial = {}; @@ -98,7 +96,6 @@ export default function ChangePasswordPage() { const logout = () => { safeLogout(); - navigate("/"); }; const handleKeyPress = (e: React.KeyboardEvent) => { diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx index 5c6c12c..5b63697 100644 --- a/vite-frontend/src/pages/dashboard.tsx +++ b/vite-frontend/src/pages/dashboard.tsx @@ -50,7 +50,6 @@ export default function DashboardPage() { const handleLogout = () => { safeLogout(); toast.success("已退出登录"); - navigate("/"); }; const { diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 4cb23ef..d2f981b 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -125,6 +125,7 @@ interface Forward { userId?: number; inx?: number; speedId?: number | null; + proxyProtocol?: number; } interface Tunnel { @@ -160,6 +161,7 @@ interface ForwardForm { strategy: string; speedId: number | null; maxConn?: number; + proxyProtocol?: number; } interface ForwardUserGroup { @@ -576,6 +578,11 @@ const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => { typeof forward.speedId === "number" || forward.speedId === null ? forward.speedId : undefined, + maxConn: typeof forward.maxConn === "number" ? forward.maxConn : undefined, + proxyProtocol: + typeof forward.proxyProtocol === "number" + ? forward.proxyProtocol + : undefined, serviceRunning: forward.status === 1, })); }; @@ -1310,6 +1317,7 @@ export default function ForwardPage() { strategy: "fifo", speedId: null, maxConn: 0, + proxyProtocol: 0, }); const [inIpTouched, setInIpTouched] = useState(false); @@ -2097,6 +2105,7 @@ export default function ForwardPage() { interfaceName: "", strategy: "fifo", speedId: null, + proxyProtocol: 0, }); setErrors({}); setModalOpen(true); @@ -2117,7 +2126,8 @@ export default function ForwardPage() { interfaceName: forward.interfaceName || "", strategy: forward.strategy || "fifo", speedId: normalizeSpeedId(forward.speedId), - maxConn: forward.maxConn || 0, + maxConn: forward.maxConn ?? 0, + proxyProtocol: forward.proxyProtocol ?? 0, }); setErrors({}); setModalOpen(true); @@ -2247,6 +2257,7 @@ export default function ForwardPage() { strategy: addressCount > 1 ? form.strategy : "fifo", speedId: normalizedSpeedId, maxConn: form.maxConn, + proxyProtocol: form.proxyProtocol, }; res = await updateForward(updateData); @@ -2260,11 +2271,11 @@ export default function ForwardPage() { strategy: addressCount > 1 ? form.strategy : "fifo", speedId: normalizedSpeedId, maxConn: form.maxConn, + proxyProtocol: form.proxyProtocol, }; res = await createForward(createData); } - if (res.code === 0) { const warningItems = Array.isArray((res as any).data?.warnings) ? (res as any).data.warnings @@ -4742,8 +4753,6 @@ export default function ForwardPage() { } /> - - )} - + 高级设置} + title={ + + 高级设置 + + } >
{ - const value = Math.max(Number(e.target.value) || 0, 0); + const value = Math.max( + Number(e.target.value) || 0, + 0, + ); + setForm((prev) => ({ ...prev, maxConn: value })); }} - description="此设置优先于用户的全局连接数限制。0 表示不限制。" - variant="bordered" /> + {isAdmin && ( { const value = Math.max(Number(e.target.value) || 0, 0); + setUserForm((prev) => ({ ...prev, maxConn: value })); }} /> diff --git a/vite-frontend/src/types/index.ts b/vite-frontend/src/types/index.ts index 6784911..3e07e15 100644 --- a/vite-frontend/src/types/index.ts +++ b/vite-frontend/src/types/index.ts @@ -24,6 +24,7 @@ export interface User { monthlyUsedBytes?: number; disabledByQuota?: number; quotaDisabledAt?: number; + maxConn?: number; } export interface UserGroup { diff --git a/vite-frontend/src/utils/logout.ts b/vite-frontend/src/utils/logout.ts index 857da32..397f40e 100644 --- a/vite-frontend/src/utils/logout.ts +++ b/vite-frontend/src/utils/logout.ts @@ -2,9 +2,8 @@ import { clearSession } from "@/utils/session"; /** * 安全退出登录函数 - * 清除登录相关数据,并强制刷新跳转到首页 + * 清除登录相关数据 */ export const safeLogout = () => { clearSession(); - window.location.href = "/"; };