Merge remote-tracking branch 'upstream/main' into sync-3.6.0

# Conflicts:
#	.github/workflows/claude-bot.yml
#	.github/workflows/release.yml
#	DockerInit.sh
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/hooks/useClients.ts
#	frontend/src/layouts/AppSidebar.tsx
#	frontend/src/main.tsx
#	internal/config/version
#	internal/database/model/model.go
#	internal/web/service/client_wireguard.go
#	internal/web/service/inbound.go
This commit is contained in:
Kuzz007
2026-08-01 21:59:29 +03:00
250 changed files with 12904 additions and 5650 deletions
@@ -0,0 +1,151 @@
package model
import (
"encoding/json"
"strings"
"testing"
)
func settingsVersion(t *testing.T, settings string) any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return parsed["version"]
}
func TestHealHysteriaVersion(t *testing.T) {
tests := []struct {
name string
settings string
wantChanged bool
wantVersion any
}{
{
name: "legacy v1 row",
settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
wantChanged: true,
wantVersion: float64(2),
},
{
name: "no version at all",
settings: `{"clients":[{"auth":"tok","email":"a@x"}]}`,
wantChanged: true,
wantVersion: float64(2),
},
{
name: "already v2",
settings: `{"version":2,"clients":[]}`,
wantChanged: false,
wantVersion: float64(2),
},
{
name: "version as a string",
settings: `{"version":"1","clients":[]}`,
wantChanged: true,
wantVersion: float64(2),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
healed, changed := HealHysteriaVersion(tt.settings)
if changed != tt.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tt.wantChanged)
}
if got := settingsVersion(t, healed); got != tt.wantVersion {
t.Fatalf("version = %#v, want %#v", got, tt.wantVersion)
}
})
}
}
func TestHealHysteriaVersionKeepsClients(t *testing.T) {
healed, changed := HealHysteriaVersion(`{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`)
if !changed {
t.Fatal("a v1 row must be healed")
}
if !strings.Contains(healed, `"auth": "tok"`) || !strings.Contains(healed, `"email": "a@x"`) {
t.Fatalf("healing dropped client data: %s", healed)
}
}
func TestHealHysteriaVersionLeavesUnparsableSettings(t *testing.T) {
const broken = `{"version":1,`
healed, changed := HealHysteriaVersion(broken)
if changed || healed != broken {
t.Fatalf("unparsable settings must be left alone, got changed=%v %q", changed, healed)
}
if healed, changed := HealHysteriaVersion(""); changed || healed != "" {
t.Fatalf("empty settings must be left alone, got changed=%v %q", changed, healed)
}
}
func TestHealHysteriaStreamVersion(t *testing.T) {
healed, changed := HealHysteriaStreamVersion(`{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`)
if !changed {
t.Fatal("a v1 transport must be healed")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(healed), &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
hysteria, _ := parsed["hysteriaSettings"].(map[string]any)
if hysteria["version"] != float64(2) {
t.Fatalf("version = %#v, want 2", hysteria["version"])
}
if hysteria["udpIdleTimeout"] != float64(60) {
t.Fatalf("healing dropped transport settings: %#v", hysteria)
}
}
func TestHealHysteriaStreamVersionWithoutHysteriaSettings(t *testing.T) {
const stream = `{"network":"tcp","tcpSettings":{}}`
healed, changed := HealHysteriaStreamVersion(stream)
if changed || healed != stream {
t.Fatalf("a stream without hysteriaSettings must be left alone, got changed=%v %q", changed, healed)
}
}
// TestGenXrayInboundConfigHealsHysteriaVersion is the regression for a stored
// v1 row: xray-core answers "version != 2" and rejects the whole config, so
// every other inbound on the server stays offline until the row is fixed.
func TestGenXrayInboundConfigHealsHysteriaVersion(t *testing.T) {
in := Inbound{
Protocol: Hysteria,
Port: 36715,
Listen: "127.0.0.1",
Tag: "in-hysteria",
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
}
cfg := in.GenXrayInboundConfig()
if got := settingsVersion(t, string(cfg.Settings)); got != float64(2) {
t.Fatalf("generated settings.version = %#v, want 2", got)
}
var stream map[string]any
if err := json.Unmarshal(cfg.StreamSettings, &stream); err != nil {
t.Fatalf("unmarshal generated streamSettings: %v", err)
}
hysteria, _ := stream["hysteriaSettings"].(map[string]any)
if hysteria["version"] != float64(2) {
t.Fatalf("generated hysteriaSettings.version = %#v, want 2", hysteria["version"])
}
if !strings.Contains(in.Settings, `"version":1`) {
t.Fatal("the stored row must keep its own value; only the generated config is healed")
}
}
func TestGenXrayInboundConfigLeavesOtherProtocolsAlone(t *testing.T) {
in := Inbound{
Protocol: VLESS,
Port: 443,
Tag: "in-vless",
Settings: `{"clients":[],"decryption":"none"}`,
}
if got := settingsVersion(t, string(in.GenXrayInboundConfig().Settings)); got != nil {
t.Fatalf("a non-hysteria inbound must not gain a version key, got %#v", got)
}
}
+65 -1
View File
@@ -55,6 +55,7 @@ type Inbound struct {
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
TrafficResetDay int `json:"trafficResetDay" form:"trafficResetDay" gorm:"default:1" validate:"omitempty,gte=1,lte=31" example:"1"` // Day of month for monthly traffic resets
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
@@ -230,6 +231,57 @@ func jsonStringFieldFromRaw(r json.RawMessage) string {
return string(trimmed)
}
// hysteriaConfigVersion is the only hysteria version xray-core builds. Both
// the protocol settings and the transport settings answer anything else with
// "version != 2", and that error rejects the whole config — every other
// inbound on the server goes down with it, not just the hysteria one.
const hysteriaConfigVersion = 2
// HealHysteriaVersion pins a hysteria inbound's settings.version to the
// version xray-core accepts. Rows written before the panel settled on v2, or
// through the API and the raw JSON editor, can still carry the legacy 1 or no
// version at all, either of which stops the core from starting.
func HealHysteriaVersion(settings string) (string, bool) {
return healVersionField(settings, nil)
}
// HealHysteriaStreamVersion does the same for the transport half,
// streamSettings.hysteriaSettings.version, which xray-core validates
// separately. An absent hysteriaSettings object is left alone.
func HealHysteriaStreamVersion(streamSettings string) (string, bool) {
return healVersionField(streamSettings, []string{"hysteriaSettings"})
}
// healVersionField rewrites the "version" key of the object reached by path to
// hysteriaConfigVersion, reporting whether anything changed. A path that does
// not resolve to an object leaves the input untouched.
func healVersionField(raw string, path []string) (string, bool) {
if raw == "" {
return raw, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return raw, false
}
target := parsed
for _, key := range path {
next, ok := target[key].(map[string]any)
if !ok {
return raw, false
}
target = next
}
if version, ok := target["version"].(float64); ok && version == hysteriaConfigVersion {
return raw, false
}
target["version"] = hysteriaConfigVersion
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return raw, false
}
return string(out), true
}
// StripInboundXhttpClientFields removes xHTTP knobs that belong on the
// client dialer and subscription share-link extras only. xray-core's XHTTP
// inbound listener does not consume them; the panel still stores them on
@@ -299,11 +351,20 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
if converted, ok := WireguardClientsToPeers(settings); ok {
settings = converted
}
case Hysteria:
if healed, ok := HealHysteriaVersion(settings); ok {
settings = healed
}
}
streamSettings := i.StreamSettings
if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
streamSettings = stripped
}
if i.Protocol == Hysteria {
if healed, ok := HealHysteriaStreamVersion(streamSettings); ok {
streamSettings = healed
}
}
return &xray.InboundConfig{
Listen: json_util.RawMessage(listen),
Port: i.Port,
@@ -847,12 +908,15 @@ type ClientRecord struct {
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
Enable bool `json:"enable" gorm:"default:true"`
TgID int64 `json:"tgId" gorm:"column:tg_id"`
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
Comment string `json:"comment"`
Reset int `json:"reset" gorm:"default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
// deleting; orphans from any other cause stay at zero and are never reaped.
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
}
func (ClientRecord) TableName() string { return "clients" }