diff --git a/sub/subClashService.go b/sub/subClashService.go index c0871a89..a138faec 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -4,6 +4,7 @@ import ( "fmt" "maps" "strings" + "time" "github.com/goccy/go-json" yaml "github.com/goccy/go-yaml" @@ -63,12 +64,13 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e return "", "", nil } + now := time.Now().UnixMilli() for index, clientTraffic := range clientTraffics { if index == 0 { traffic.Up = clientTraffic.Up traffic.Down = clientTraffic.Down traffic.Total = clientTraffic.Total - traffic.ExpiryTime = subscriptionExpiryFromClient(clientTraffic.ExpiryTime) + traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) } else { traffic.Up += clientTraffic.Up traffic.Down += clientTraffic.Down @@ -77,7 +79,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e } else { traffic.Total += clientTraffic.Total } - normalized := subscriptionExpiryFromClient(clientTraffic.ExpiryTime) + normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) if normalized != traffic.ExpiryTime { traffic.ExpiryTime = 0 } diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 29dbd987..e26b60ea 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" "strings" + "time" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" @@ -125,12 +126,13 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err } // Prepare statistics + now := time.Now().UnixMilli() for index, clientTraffic := range clientTraffics { if index == 0 { traffic.Up = clientTraffic.Up traffic.Down = clientTraffic.Down traffic.Total = clientTraffic.Total - traffic.ExpiryTime = subscriptionExpiryFromClient(clientTraffic.ExpiryTime) + traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) } else { traffic.Up += clientTraffic.Up traffic.Down += clientTraffic.Down @@ -139,7 +141,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err } else { traffic.Total += clientTraffic.Total } - normalized := subscriptionExpiryFromClient(clientTraffic.ExpiryTime) + normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) if normalized != traffic.ExpiryTime { traffic.ExpiryTime = 0 } diff --git a/sub/subService.go b/sub/subService.go index 706ef3b8..306d724f 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -108,13 +108,13 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C } } - // Prepare statistics + now := time.Now().UnixMilli() for index, clientTraffic := range clientTraffics { if index == 0 { traffic.Up = clientTraffic.Up traffic.Down = clientTraffic.Down traffic.Total = clientTraffic.Total - traffic.ExpiryTime = subscriptionExpiryFromClient(clientTraffic.ExpiryTime) + traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) } else { traffic.Up += clientTraffic.Up traffic.Down += clientTraffic.Down @@ -123,7 +123,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C } else { traffic.Total += clientTraffic.Total } - normalized := subscriptionExpiryFromClient(clientTraffic.ExpiryTime) + normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) if normalized != traffic.ExpiryTime { traffic.ExpiryTime = 0 } @@ -133,12 +133,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C return result, lastOnline, traffic, nil } -func subscriptionExpiryFromClient(expiryTime int64) int64 { +func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 { if expiryTime > 0 { return expiryTime } if expiryTime < 0 { - return time.Now().UnixMilli() + (-expiryTime) + return nowMs + (-expiryTime) } return 0 } diff --git a/sub/subService_test.go b/sub/subService_test.go index 32f013fd..d87a198c 100644 --- a/sub/subService_test.go +++ b/sub/subService_test.go @@ -5,24 +5,24 @@ import ( "encoding/json" "strings" "testing" - "time" "github.com/mhsanaei/3x-ui/v3/database/model" ) func TestSubscriptionExpiryFromClient(t *testing.T) { - if got := subscriptionExpiryFromClient(0); got != 0 { + const now = int64(1_700_000_000_000) + const oneDayMs = int64(86_400_000) + if got := subscriptionExpiryFromClient(now, 0); got != 0 { t.Fatalf("zero expiry should stay zero, got %d", got) } - if got := subscriptionExpiryFromClient(1_700_000_000_000); got != 1_700_000_000_000 { + if got := subscriptionExpiryFromClient(now, 1_700_000_000_000); got != 1_700_000_000_000 { t.Fatalf("positive expiry should pass through, got %d", got) } - const oneDayMs = int64(86_400_000) - before := time.Now().UnixMilli() - got := subscriptionExpiryFromClient(-oneDayMs) - after := time.Now().UnixMilli() - if got < before+oneDayMs || got > after+oneDayMs { - t.Fatalf("delayed-start expiry should land ~1 day from now, got %d (window %d..%d)", got, before+oneDayMs, after+oneDayMs) + if got := subscriptionExpiryFromClient(now, -oneDayMs); got != now+oneDayMs { + t.Fatalf("delayed-start expiry should be now+|value|, got %d, want %d", got, now+oneDayMs) + } + if a, b := subscriptionExpiryFromClient(now, -oneDayMs), subscriptionExpiryFromClient(now, -oneDayMs); a != b { + t.Fatalf("same now+value should be deterministic across calls, got %d vs %d (#4545 review)", a, b) } } diff --git a/web/service/client.go b/web/service/client.go index b25854d6..0bdf6cc2 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -222,9 +222,7 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model. if incoming.Auth != "" { row.Auth = incoming.Auth } - if incoming.Flow != "" { - row.Flow = incoming.Flow - } + row.Flow = incoming.Flow if incoming.Security != "" { row.Security = incoming.Security } diff --git a/web/service/client_sync_multiprotocol_test.go b/web/service/client_sync_multiprotocol_test.go index 335c7e82..cb4e1f37 100644 --- a/web/service/client_sync_multiprotocol_test.go +++ b/web/service/client_sync_multiprotocol_test.go @@ -31,8 +31,9 @@ func TestSyncInbound_PreservesCredentialsAcrossProtocols(t *testing.T) { const sharedEmail = "shared@example.com" const wantUUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001" const wantAuth = "h2-auth-token" + const wantFlow = "xtls-rprx-vision" - vlessClient := model.Client{Email: sharedEmail, ID: wantUUID, Enable: true, Flow: "xtls-rprx-vision"} + vlessClient := model.Client{Email: sharedEmail, ID: wantUUID, Enable: true, Flow: wantFlow} if err := svc.SyncInbound(nil, vlessInbound.Id, []model.Client{vlessClient}); err != nil { t.Fatalf("vless SyncInbound: %v", err) } @@ -52,7 +53,61 @@ func TestSyncInbound_PreservesCredentialsAcrossProtocols(t *testing.T) { if row.Auth != wantAuth { t.Errorf("Auth not persisted: got %q, want %q", row.Auth, wantAuth) } - if row.Flow != "xtls-rprx-vision" { - t.Errorf("Flow was clobbered by Hysteria sync: got %q, want xtls-rprx-vision", row.Flow) + + vlessList, err := svc.ListForInbound(nil, vlessInbound.Id) + if err != nil { + t.Fatalf("ListForInbound(vless): %v", err) + } + if len(vlessList) != 1 || vlessList[0].Flow != wantFlow { + t.Errorf("VLESS inbound should still report flow=%q via FlowOverride, got %#v", wantFlow, vlessList) + } + + hysteriaList, err := svc.ListForInbound(nil, hysteriaInbound.Id) + if err != nil { + t.Fatalf("ListForInbound(hysteria): %v", err) + } + if len(hysteriaList) != 1 || hysteriaList[0].Flow != "" { + t.Errorf("Hysteria inbound should report empty flow, got %#v", hysteriaList) + } +} + +func TestSyncInbound_AllowsClearingFlow(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + + vless := &model.Inbound{Tag: "vless-in", Enable: true, Port: 10003, Protocol: model.VLESS} + if err := db.Create(vless).Error; err != nil { + t.Fatalf("create vless inbound: %v", err) + } + + svc := ClientService{} + const email = "alice@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c002" + + withFlow := model.Client{Email: email, ID: uid, Enable: true, Flow: "xtls-rprx-vision"} + if err := svc.SyncInbound(nil, vless.Id, []model.Client{withFlow}); err != nil { + t.Fatalf("vless SyncInbound (set flow): %v", err) + } + + cleared := model.Client{Email: email, ID: uid, Enable: true, Flow: ""} + if err := svc.SyncInbound(nil, vless.Id, []model.Client{cleared}); err != nil { + t.Fatalf("vless SyncInbound (clear flow): %v", err) + } + + list, err := svc.ListForInbound(nil, vless.Id) + if err != nil { + t.Fatalf("ListForInbound: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 client, got %d", len(list)) + } + if list[0].Flow != "" { + t.Errorf("flow should be clearable on the owning inbound, got %q (Copilot review on #4545)", list[0].Flow) } }