Browse Source

refactor(backend): retire hysteria2 as a top-level protocol

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.
MHSanaei 18 hours ago
parent
commit
d843014461

+ 6 - 10
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"`

+ 0 - 18
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)
-		}
-	}
-}

+ 2 - 3
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)
 	}
 

+ 1 - 1
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))
 		}
 

+ 3 - 3
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

+ 6 - 4
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":

+ 5 - 5
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
 }

+ 11 - 18
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"
 	}
 

+ 1 - 1
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)
 	}

+ 2 - 3
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
 	}

+ 3 - 3
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"

+ 2 - 2
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
 	}
 

+ 8 - 9
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:       ``,
 	}

+ 1 - 1
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
 				}

+ 1 - 1
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