From d84301446131009a664298d54638ef1efb8564f2 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 00:58:37 +0200 Subject: [PATCH] refactor(backend): retire hysteria2 as a top-level protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hysteria v2 is not a separate xray protocol — it is plain "hysteria" with streamSettings.version = 2. The frontend already dropped hysteria2 from the protocol enum in 5a90f7e3; the backend was still carrying the literal as a compat alias. Removed: - model.Hysteria2 constant - model.IsHysteria helper (only callers were buildProxy + genHysteriaLink) - TestIsHysteria - "hysteria2" from the Inbound.Protocol validate oneof enum - All `case model.Hysteria, model.Hysteria2:` and `case "hysteria", "hysteria2":` branches across client.go, inbound.go, outbound.go, xray.go, port_conflict.go, xray/api.go, subService.go, subJsonService.go, subClashService.go - Stale #4081 comments Kept (correctly — these are client-side URI/config schemes that are independent of the xray protocol type): - hysteria2:// share-link URI in subService.genHysteriaLink - "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy - Comments referring to Hysteria v2 as a transport version Note: this change does not include a DB migration. Existing rows with protocol = 'hysteria2' will fall through to the default switch arms after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria' WHERE protocol = 'hysteria2'` is required for installs that still hold legacy data. --- database/model/model.go | 16 ++++------ database/model/model_test.go | 18 ------------ sub/subClashService.go | 5 ++-- sub/subJsonService.go | 2 +- sub/subService.go | 6 ++-- tools/openapigen/emit_zod.go | 10 ++++--- tools/openapigen/schema.go | 10 +++---- web/service/client.go | 29 +++++++------------ web/service/client_sync_multiprotocol_test.go | 2 +- web/service/inbound.go | 5 ++-- web/service/outbound.go | 6 ++-- web/service/port_conflict.go | 4 +-- web/service/port_conflict_test.go | 17 +++++------ web/service/xray.go | 2 +- xray/api.go | 2 +- 15 files changed, 52 insertions(+), 82 deletions(-) diff --git a/database/model/model.go b/database/model/model.go index 787e5572..642808ee 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -14,7 +14,11 @@ import ( // Protocol represents the protocol type for Xray inbounds. type Protocol string -// Protocol constants for different Xray inbound protocols +// Protocol constants for different Xray inbound protocols. +// Hysteria v2 is not a distinct protocol — it is plain "hysteria" +// with streamSettings.version = 2. The share-link URI scheme +// "hysteria2://" is independent of this and is still emitted by the +// link generator when the stream version is 2. const ( VMESS Protocol = "vmess" VLESS Protocol = "vless" @@ -25,16 +29,8 @@ const ( Mixed Protocol = "mixed" WireGuard Protocol = "wireguard" Hysteria Protocol = "hysteria" - Hysteria2 Protocol = "hysteria2" ) -// IsHysteria returns true for both "hysteria" and "hysteria2". -// Use instead of a bare ==model.Hysteria check: a v2 inbound stored -// with the literal v2 string would otherwise fall through (#4081). -func IsHysteria(p Protocol) bool { - return p == Hysteria || p == Hysteria2 -} - // User represents a user account in the 3x-ui panel. type User struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` @@ -60,7 +56,7 @@ type Inbound struct { // Xray configuration fields Listen string `json:"listen" form:"listen"` Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` - Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria hysteria2 http mixed tunnel"` + Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel"` Settings string `json:"settings" form:"settings"` StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` diff --git a/database/model/model_test.go b/database/model/model_test.go index 4938ca57..aa64605d 100644 --- a/database/model/model_test.go +++ b/database/model/model_test.go @@ -189,21 +189,3 @@ func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) { } } -func TestIsHysteria(t *testing.T) { - cases := []struct { - in Protocol - want bool - }{ - {Hysteria, true}, - {Hysteria2, true}, - {VLESS, false}, - {Shadowsocks, false}, - {Protocol(""), false}, - {Protocol("hysteria3"), false}, - } - for _, c := range cases { - if got := IsHysteria(c.in); got != c.want { - t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want) - } - } -} diff --git a/sub/subClashService.go b/sub/subClashService.go index a138faec..829bcd34 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -170,9 +170,8 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any { // Hysteria has its own transport + TLS model, applyTransport / - // applySecurity don't fit. IsHysteria also covers the literal - // "hysteria2" protocol string (#4081). - if model.IsHysteria(inbound.Protocol) { + // applySecurity don't fit. + if inbound.Protocol == model.Hysteria { return s.buildHysteriaProxy(inbound, client, extraRemark) } diff --git a/sub/subJsonService.go b/sub/subJsonService.go index e26b60ea..bde3e12b 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -221,7 +221,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client)) case "trojan", "shadowsocks": newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client)) - case "hysteria", "hysteria2": + case "hysteria": newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client)) } diff --git a/sub/subService.go b/sub/subService.go index 306d724f..cedad581 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -152,7 +152,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id JOIN clients ON clients.id = client_inbounds.client_id WHERE - inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2') + inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria') AND clients.sub_id = ? AND inbounds.enable = ? )`, subId, true).Find(&inbounds).Error if err != nil { @@ -279,7 +279,7 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string { return s.genTrojanLink(inbound, email) case "shadowsocks": return s.genShadowsocksLink(inbound, email) - case "hysteria", "hysteria2": + case "hysteria": return s.genHysteriaLink(inbound, email) } return "" @@ -492,7 +492,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st } func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string { - if !model.IsHysteria(inbound.Protocol) { + if inbound.Protocol != model.Hysteria { return "" } var stream map[string]any diff --git a/tools/openapigen/emit_zod.go b/tools/openapigen/emit_zod.go index afcb3181..a5ece391 100644 --- a/tools/openapigen/emit_zod.go +++ b/tools/openapigen/emit_zod.go @@ -103,15 +103,17 @@ func applyZodValidations(expr string, t TypeRef, rules []ValidateRule) string { expr += fmt.Sprintf(".lt(%s)", r.Param) } case "min": - if t.Kind == KindString { + switch t.Kind { + case KindString: expr += fmt.Sprintf(".min(%s)", r.Param) - } else if t.Kind == KindInt || t.Kind == KindNumber { + case KindInt, KindNumber: expr += fmt.Sprintf(".min(%s)", r.Param) } case "max": - if t.Kind == KindString { + switch t.Kind { + case KindString: expr += fmt.Sprintf(".max(%s)", r.Param) - } else if t.Kind == KindInt || t.Kind == KindNumber { + case KindInt, KindNumber: expr += fmt.Sprintf(".max(%s)", r.Param) } case "url": diff --git a/tools/openapigen/schema.go b/tools/openapigen/schema.go index 9ff6e279..60ca18d9 100644 --- a/tools/openapigen/schema.go +++ b/tools/openapigen/schema.go @@ -64,7 +64,7 @@ func parseStructTag(raw string) (json string, validate string, gormHasDash bool) json = tag.Get("json") validate = tag.Get("validate") if g := tag.Get("gorm"); g != "" { - for _, part := range strings.Split(g, ";") { + for part := range strings.SplitSeq(g, ";") { if strings.TrimSpace(part) == "-" { gormHasDash = true } @@ -95,17 +95,17 @@ func parseValidateTag(tag string) []ValidateRule { return nil } var rules []ValidateRule - for _, part := range strings.Split(tag, ",") { + for part := range strings.SplitSeq(tag, ",") { part = strings.TrimSpace(part) if part == "" { continue } - eq := strings.IndexByte(part, '=') - if eq < 0 { + before, after, ok := strings.Cut(part, "=") + if !ok { rules = append(rules, ValidateRule{Name: part}) continue } - rules = append(rules, ValidateRule{Name: part[:eq], Param: part[eq+1:]}) + rules = append(rules, ValidateRule{Name: before, Param: after}) } return rules } diff --git a/web/service/client.go b/web/service/client.go index 5dfcb6d7..2b42b6ab 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strings" "sync" @@ -70,7 +71,7 @@ func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string { return rec.Password case model.Shadowsocks: return rec.Email - case model.Hysteria, model.Hysteria2: + case model.Hysteria: return rec.Auth default: return rec.UUID @@ -478,7 +479,7 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) if c.Password == "" || !validShadowsocksClientKey(method, c.Password) { c.Password = randomShadowsocksClientKey(method) } - case model.Hysteria, model.Hysteria2: + case model.Hysteria: if c.Auth == "" { c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "") } @@ -1064,12 +1065,7 @@ func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool { if inboundId <= 0 { return true } - for _, id := range c.InboundIds { - if id == inboundId { - return true - } - } - return false + return slices.Contains(c.InboundIds, inboundId) } func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool { @@ -1284,10 +1280,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, skippedReasons[email] = "unlimited traffic" } } else { - next := rec.TotalGB + addBytes - if next < 0 { - next = 0 - } + next := max(rec.TotalGB+addBytes, 0) entry.applyTotal = true entry.newTotal = next } @@ -1410,7 +1403,7 @@ func (s *ClientService) bulkAdjustInboundClients( clientKey = "password" case model.Shadowsocks: clientKey = "email" - case model.Hysteria, model.Hysteria2: + case model.Hysteria: clientKey = "auth" } @@ -1690,7 +1683,7 @@ func (s *ClientService) bulkDelInboundClients( clientKey = "password" case model.Shadowsocks: clientKey = "email" - case model.Hysteria, model.Hysteria2: + case model.Hysteria: clientKey = "auth" } @@ -2105,7 +2098,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model if client.Email == "" { return false, common.NewError("empty client ID") } - case "hysteria", "hysteria2": + case "hysteria": if client.Auth == "" { return false, common.NewError("empty client ID") } @@ -2252,7 +2245,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo case "shadowsocks": oldClientId = oldClient.Email newClientId = clients[0].Email - case "hysteria", "hysteria2": + case "hysteria": oldClientId = oldClient.Auth newClientId = clients[0].Auth default: @@ -2274,7 +2267,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo lookupErr = database.GetDB().Where("password = ?", clientId).First(&rec).Error case "shadowsocks": lookupErr = database.GetDB().Where("email = ?", clientId).First(&rec).Error - case "hysteria", "hysteria2": + case "hysteria": lookupErr = database.GetDB().Where("auth = ?", clientId).First(&rec).Error default: lookupErr = database.GetDB().Where("uuid = ?", clientId).First(&rec).Error @@ -2512,7 +2505,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i client_key = "password" case "shadowsocks": client_key = "email" - case "hysteria", "hysteria2": + case "hysteria": client_key = "auth" } diff --git a/web/service/client_sync_multiprotocol_test.go b/web/service/client_sync_multiprotocol_test.go index cb4e1f37..8bfd1973 100644 --- a/web/service/client_sync_multiprotocol_test.go +++ b/web/service/client_sync_multiprotocol_test.go @@ -22,7 +22,7 @@ func TestSyncInbound_PreservesCredentialsAcrossProtocols(t *testing.T) { if err := db.Create(vlessInbound).Error; err != nil { t.Fatalf("create vless inbound: %v", err) } - hysteriaInbound := &model.Inbound{Tag: "hy-in", Enable: true, Port: 10002, Protocol: model.Hysteria2} + hysteriaInbound := &model.Inbound{Tag: "hy-in", Enable: true, Port: 10002, Protocol: model.Hysteria} if err := db.Create(hysteriaInbound).Error; err != nil { t.Fatalf("create hysteria inbound: %v", err) } diff --git a/web/service/inbound.go b/web/service/inbound.go index 9064fa70..64063157 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -452,7 +452,6 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { model.Trojan: true, model.Shadowsocks: true, model.Hysteria: true, - model.Hysteria2: true, } if !protocolsWithStream[inbound.Protocol] { @@ -528,7 +527,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if client.Email == "" { return inbound, false, common.NewError("empty client ID") } - case "hysteria", "hysteria2": + case "hysteria": if client.Auth == "" { return inbound, false, common.NewError("empty client ID") } @@ -2913,7 +2912,7 @@ func (s *InboundService) MigrationRequirements() { // Fix inbounds based problems var inbounds []*model.Inbound - err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria", "hysteria2"}).Find(&inbounds).Error + err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error if err != nil && err != gorm.ErrRecordNotFound { return } diff --git a/web/service/outbound.go b/web/service/outbound.go index 7a56dc7d..7e5a0b2e 100644 --- a/web/service/outbound.go +++ b/web/service/outbound.go @@ -298,9 +298,9 @@ func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint { return nil } - // Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the - // outer protocol or via streamSettings.network so trojan-with-hysteria2 - // transport gets probed over UDP too. kcp and quic are also UDP-based. + // Hysteria is QUIC/UDP — detect via the outer protocol or via + // streamSettings.network so a trojan-with-hysteria transport gets + // probed over UDP too. kcp and quic are also UDP-based. network := "tcp" if protocol == "hysteria" || protocol == "wireguard" { network = "udp" diff --git a/web/service/port_conflict.go b/web/service/port_conflict.go index 8d71082b..ea13e074 100644 --- a/web/service/port_conflict.go +++ b/web/service/port_conflict.go @@ -28,7 +28,7 @@ func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 } // the validator never gets looser than the old port-only check. // // the rules: -// - hysteria, hysteria2, wireguard: udp regardless of streamSettings +// - hysteria, wireguard: udp regardless of streamSettings // - streamSettings.network=kcp: udp // - shadowsocks: whatever settings.network says ("tcp" / "udp" / "tcp,udp") // - mixed (socks/http combo): tcp + udp when settings.udp is true @@ -36,7 +36,7 @@ func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 } func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits { // protocols that ignore streamSettings entirely. switch protocol { - case model.Hysteria, model.Hysteria2, model.WireGuard: + case model.Hysteria, model.WireGuard: return transportUDP } diff --git a/web/service/port_conflict_test.go b/web/service/port_conflict_test.go index 70f637d9..bb79d370 100644 --- a/web/service/port_conflict_test.go +++ b/web/service/port_conflict_test.go @@ -77,7 +77,6 @@ func TestInboundTransports(t *testing.T) { {"trojan grpc is tcp", model.Trojan, `{"network":"grpc"}`, ``, transportTCP}, {"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP}, - {"hysteria2 forced udp", model.Hysteria2, ``, ``, transportUDP}, {"wireguard forced udp", model.WireGuard, ``, ``, transportUDP}, {"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP}, @@ -122,7 +121,7 @@ func TestListenOverlaps(t *testing.T) { } // the actual case from #4103: tcp/443 vless reality and udp/443 -// hysteria2 must be allowed to coexist on the same port. +// hysteria must be allowed to coexist on the same port. func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "vless-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`) @@ -132,7 +131,7 @@ func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) { Tag: "hyst2-443-udp", Listen: "0.0.0.0", Port: 443, - Protocol: model.Hysteria2, + Protocol: model.Hysteria, } exist, err := svc.checkPortConflict(hyst2, 0) if err != nil { @@ -169,7 +168,7 @@ func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) { // conflict, since they fight for the same socket. func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) { setupConflictDB(t) - seedInboundConflict(t, "hyst2-443", "0.0.0.0", 443, model.Hysteria2, ``, ``) + seedInboundConflict(t, "hyst2-443", "0.0.0.0", 443, model.Hysteria, ``, ``) svc := &InboundService{} wg := &model.Inbound{ @@ -210,7 +209,7 @@ func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) { Tag: "hyst2-443", Listen: "0.0.0.0", Port: 443, - Protocol: model.Hysteria2, + Protocol: model.Hysteria, } if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || !exist { t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err) @@ -281,7 +280,7 @@ func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) { udp := &model.Inbound{ Listen: "0.0.0.0", Port: 443, - Protocol: model.Hysteria2, + Protocol: model.Hysteria, } got, err := svc.generateInboundTag(udp, 0) if err != nil { @@ -343,7 +342,7 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) { udp := &model.Inbound{ Listen: "1.2.3.4", Port: 443, - Protocol: model.Hysteria2, + Protocol: model.Hysteria, } got, err := svc.generateInboundTag(udp, 0) if err != nil { @@ -403,7 +402,7 @@ func TestCheckPortConflict_NodeScope(t *testing.T) { func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) { setupConflictDB(t) seedInboundConflictNode(t, "inbound-5000", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil) - seedInboundConflictNode(t, "inbound-5000-udp", "0.0.0.0", 5000, model.Hysteria2, ``, ``, nil) + seedInboundConflictNode(t, "inbound-5000-udp", "0.0.0.0", 5000, model.Hysteria, ``, ``, nil) svc := &InboundService{} pushed := &model.Inbound{ @@ -458,7 +457,7 @@ func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) { Tag: "inbound-5000-tcp", Listen: "0.0.0.0", Port: 5000, - Protocol: model.Hysteria2, + Protocol: model.Hysteria, StreamSettings: ``, Settings: ``, } diff --git a/web/service/xray.go b/web/service/xray.go index 00b17c55..a9dd326c 100644 --- a/web/service/xray.go +++ b/web/service/xray.go @@ -183,7 +183,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if c.Security != "" { entry["method"] = c.Security } - case model.Hysteria, model.Hysteria2: + case model.Hysteria: if c.Auth != "" { entry["auth"] = c.Auth } diff --git a/xray/api.go b/xray/api.go index 77964db2..781c5eab 100644 --- a/xray/api.go +++ b/xray/api.go @@ -233,7 +233,7 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an Email: userEmail, }) } - case "hysteria", "hysteria2": + case "hysteria": auth, err := getRequiredUserString(user, "auth") if err != nil { return err