mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 19:55:04 +03:00
fix(inbounds): reject incompatible VLESS fallbacks and prevent Xray crashes (#7003)
Xray refuses a VLESS inbound with server decryption whenever fallbacks is non-nil, even an empty list, so an API or imported inbound with decryption and fallbacks: [] took every inbound down. Empty fallback arrays are now normalized away on add, update, config build and node push; non-empty ones are refused with decryption, enabling decryption over stored fallback rules is refused, and a legacy conflicting row holds back the running core instead of stopping it. The form, API docs and locales follow. Closes #7001.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
func TestVlessFallbackHTTPAndImportValidation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
folder := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", folder)
|
||||
dbtest.InitDB(t, filepath.Join(folder, "x-ui.db"))
|
||||
previous := runtime.GetManager()
|
||||
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
|
||||
t.Cleanup(func() { runtime.SetManager(previous) })
|
||||
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} {
|
||||
if err := database.GetDB().Create(&model.ApiToken{Name: scope, Token: crypto.HashTokenSHA256(scope), Enabled: true, Scope: scope}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
router := gin.New()
|
||||
auth := &APIController{}
|
||||
group := router.Group("/panel/api")
|
||||
group.Use(auth.checkAPIAuth, auth.enforceTokenScope)
|
||||
NewInboundController(group.Group("/inbounds"))
|
||||
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} {
|
||||
for _, route := range []string{"add", "update", "import"} {
|
||||
if scope == model.ApiScopeNodeSync && route == "import" {
|
||||
continue
|
||||
}
|
||||
for _, invalid := range []bool{false, true} {
|
||||
t.Run(scope+"/"+route+"/"+strconv.FormatBool(invalid), func(t *testing.T) {
|
||||
raw := `[]`
|
||||
if invalid {
|
||||
raw = `[{"dest":8080}]`
|
||||
}
|
||||
inbound := &model.Inbound{Protocol: model.VLESS, Port: 32110, Settings: `{"clients":[],"decryption":"mlkem768x25519plus.native.600s.` + base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + `","fallbacks":` + raw + `}`, StreamSettings: `{"network":"tcp","security":"none"}`}
|
||||
db := database.GetDB()
|
||||
if err := db.Where("1 = 1").Delete(&model.Inbound{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "/panel/api/inbounds/" + route
|
||||
if route == "update" {
|
||||
stored := *inbound
|
||||
stored.Settings = `{"clients":[],"decryption":"none"}`
|
||||
if err := db.Create(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inbound.Id = stored.Id
|
||||
path += "/" + strconv.Itoa(stored.Id)
|
||||
}
|
||||
form := url.Values{"protocol": {"vless"}, "port": {"32110"}, "settings": {inbound.Settings}, "streamSettings": {inbound.StreamSettings}}
|
||||
if route == "import" {
|
||||
data, _ := json.Marshal(inbound)
|
||||
form = url.Values{"data": {string(data)}}
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Authorization", "Bearer "+scope)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Success == invalid {
|
||||
t.Fatalf("unexpected response: %s", response.Body)
|
||||
}
|
||||
var rows []model.Inbound
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if invalid {
|
||||
if !strings.Contains(strings.ToLower(result.Msg), "fallback") {
|
||||
t.Fatalf("missing error: %s", response.Body)
|
||||
}
|
||||
if route == "update" {
|
||||
if len(rows) != 1 || rows[0].Settings != `{"clients":[],"decryption":"none"}` {
|
||||
t.Fatalf("failed update persisted: %+v", rows)
|
||||
}
|
||||
} else if len(rows) != 0 {
|
||||
t.Fatal("failed add/import persisted")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("saved rows: %d", len(rows))
|
||||
}
|
||||
var core conf.VLessInboundConfig
|
||||
if err := json.Unmarshal([]byte(rows[0].Settings), &core); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.Build(); err != nil {
|
||||
t.Fatalf("saved API config rejected by Xray: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,16 @@ func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback
|
||||
|
||||
// SetByMaster replaces the master's entire fallback list atomically.
|
||||
func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error {
|
||||
db := database.GetDB()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
return runSerializedTx(func(tx *gorm.DB) error {
|
||||
if len(items) > 0 {
|
||||
var master model.Inbound
|
||||
if err := tx.First(&master, masterId).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !inboundCanHostFallbacks(&master) {
|
||||
return fmt.Errorf("inbound %d cannot host fallbacks: use TCP with TLS/REALITY and disable VLESS decryption first", masterId)
|
||||
}
|
||||
}
|
||||
if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1297,6 +1297,9 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
defer InvalidateSubData()
|
||||
inbound.Id = 0
|
||||
if err := normalizeVlessFallbacks(inbound); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := normalizeTuicSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -2055,6 +2058,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
// Restore the stored NodeID before any host-scoped check so a node inbound
|
||||
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
|
||||
inbound.NodeID = oldInbound.NodeID
|
||||
if err := normalizeVlessFallbacks(inbound); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := normalizeTuicSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -2126,6 +2132,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
if err := s.validateUpdatedInboundClients(inbound, stored); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateVlessManagedFallbacks(tx, inbound); err != nil {
|
||||
return err
|
||||
}
|
||||
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
|
||||
if cErr != nil {
|
||||
return cErr
|
||||
@@ -2433,12 +2442,18 @@ func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inb
|
||||
}
|
||||
|
||||
built := *inbound
|
||||
if err := normalizeVlessFallbacks(&built); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateVlessManagedFallbacks(tx, &built); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !inboundCanHostFallbacks(inbound) {
|
||||
if !inboundCanHostFallbacks(&built) {
|
||||
return &built, nil
|
||||
}
|
||||
fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
|
||||
@@ -140,6 +140,9 @@ func inboundCanHostFallbacks(ib *model.Inbound) bool {
|
||||
if ib.Protocol != model.VLESS && ib.Protocol != model.Trojan {
|
||||
return false
|
||||
}
|
||||
if ib.Protocol == model.VLESS && vlessDecryptionEnabled(ib.Settings) {
|
||||
return false
|
||||
}
|
||||
return streamSupportsFallbacks(ib.StreamSettings)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func vlessDecryptionEnabled(settings string) bool {
|
||||
var s struct {
|
||||
Decryption string `json:"decryption"`
|
||||
}
|
||||
return json.Unmarshal([]byte(settings), &s) == nil && vlessEncValueSet(s.Decryption)
|
||||
}
|
||||
|
||||
// normalizeVlessFallbacks also protects API/import callers that bypass the form adapter.
|
||||
func normalizeVlessFallbacks(inbound *model.Inbound) error {
|
||||
if inbound.Protocol != model.VLESS {
|
||||
return nil
|
||||
}
|
||||
var settings map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
return fmt.Errorf("inbound %d: invalid VLESS settings: %w", inbound.Id, err)
|
||||
}
|
||||
raw, exists := settings["fallbacks"]
|
||||
if !exists || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
var fallbacks []*conf.VLessInboundFallback
|
||||
if err := json.Unmarshal(raw, &fallbacks); err != nil {
|
||||
return fmt.Errorf("inbound %d: invalid VLESS fallbacks: %w", inbound.Id, err)
|
||||
}
|
||||
if len(fallbacks) == 0 {
|
||||
delete(settings, "fallbacks")
|
||||
normalized, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbound.Settings = string(normalized)
|
||||
return nil
|
||||
}
|
||||
if vlessDecryptionEnabled(inbound.Settings) {
|
||||
return vlessFallbackConflict(inbound.Id)
|
||||
}
|
||||
for _, fallback := range fallbacks {
|
||||
if fallback == nil {
|
||||
return fmt.Errorf("inbound %d: VLESS fallback entries must be objects", inbound.Id)
|
||||
}
|
||||
}
|
||||
candidate := conf.VLessInboundConfig{Decryption: "none", Fallbacks: fallbacks}
|
||||
if _, err := candidate.Build(); err != nil {
|
||||
return fmt.Errorf("inbound %d: invalid VLESS fallbacks: %w", inbound.Id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vlessFallbackConflict(id int) error {
|
||||
return fmt.Errorf("inbound %d: VLESS fallbacks cannot be used with active server decryption; remove fallback rules before enabling decryption", id)
|
||||
}
|
||||
|
||||
func validateVlessManagedFallbacks(tx *gorm.DB, inbound *model.Inbound) error {
|
||||
if inbound.Protocol != model.VLESS || !vlessDecryptionEnabled(inbound.Settings) || inbound.Id == 0 {
|
||||
return nil
|
||||
}
|
||||
fallbacks, err := (&FallbackService{}).BuildFallbacksJSON(tx, inbound.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fallbacks) > 0 {
|
||||
return vlessFallbackConflict(inbound.Id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func fallbackTestDecryption() string {
|
||||
return "mlkem768x25519plus.native.600s." + base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
}
|
||||
|
||||
func TestVlessFallbackCRUDProtectsCoreConfig(t *testing.T) {
|
||||
for _, operation := range []string{"add", "update"} {
|
||||
for _, tc := range []struct {
|
||||
name, fallback string
|
||||
rejected bool
|
||||
}{
|
||||
{"empty", `,"fallbacks":[]`, false},
|
||||
{"null", `,"fallbacks":null`, false},
|
||||
{"absent", "", false},
|
||||
{"conflicting", `,"fallbacks":[{"dest":8080}]`, true},
|
||||
{"malformed object", `,"fallbacks":{}`, true},
|
||||
{"malformed entry", `,"fallbacks":[null]`, true},
|
||||
} {
|
||||
t.Run(operation+"/"+tc.name, func(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
useTestRuntimeManager(t).SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
||||
inbound := &model.Inbound{UserId: 1, Port: 32101, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp","security":"none"}`}
|
||||
if operation == "update" {
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
inbound.Settings = `{"clients":[],"decryption":"` + fallbackTestDecryption() + `","encryption":"link-key","testseed":[1,2,3,4]` + tc.fallback + `}`
|
||||
svc := &InboundService{}
|
||||
var err error
|
||||
if operation == "add" {
|
||||
_, _, err = svc.AddInbound(inbound)
|
||||
} else {
|
||||
_, _, err = svc.UpdateInbound(inbound)
|
||||
}
|
||||
if tc.rejected {
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") {
|
||||
t.Fatalf("error = %v, want an actionable fallback validation error", err)
|
||||
}
|
||||
var rows []model.Inbound
|
||||
if err := database.GetDB().Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operation == "add" && len(rows) != 0 || operation == "update" && (len(rows) != 1 || rows[0].Settings != `{"clients":[],"decryption":"none"}`) {
|
||||
t.Fatalf("invalid request changed the database: %+v", rows)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored model.Inbound
|
||||
if err := database.GetDB().First(&stored, inbound.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var core conf.VLessInboundConfig
|
||||
if err := json.Unmarshal([]byte(stored.Settings), &core); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.Build(); err != nil {
|
||||
t.Fatalf("saved configuration rejected by Xray: %v; settings=%s", err, stored.Settings)
|
||||
}
|
||||
var preserved map[string]json.RawMessage
|
||||
_ = json.Unmarshal([]byte(stored.Settings), &preserved)
|
||||
var seed []int
|
||||
_ = json.Unmarshal(preserved["testseed"], &seed)
|
||||
if !reflect.DeepEqual(seed, []int{1, 2, 3, 4}) || core.Decryption != fallbackTestDecryption() {
|
||||
t.Fatalf("unrelated VLESS settings changed: %s", stored.Settings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVlessManagedFallbackConflictDoesNotChangeRulesOrInbound(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
testVlessManagedFallbackConflict(t)
|
||||
}
|
||||
|
||||
func TestVlessManagedFallbackConflictPostgres(t *testing.T) {
|
||||
durablePostgresDB(t)
|
||||
testVlessManagedFallbackConflict(t)
|
||||
}
|
||||
|
||||
func testVlessManagedFallbackConflict(t *testing.T) {
|
||||
t.Helper()
|
||||
useTestRuntimeManager(t).SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
||||
db := database.GetDB()
|
||||
master := &model.Inbound{UserId: 1, Port: 32102, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp","security":"tls"}`}
|
||||
if err := db.Create(master).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Where("master_id = ?", master.Id).Delete(&model.InboundFallback{}).Error
|
||||
_ = db.Delete(master).Error
|
||||
})
|
||||
original := model.InboundFallback{MasterId: master.Id, Dest: "8080"}
|
||||
if err := db.Create(&original).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update := *master
|
||||
update.Settings = `{"clients":[],"decryption":"` + fallbackTestDecryption() + `"}`
|
||||
if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") {
|
||||
t.Fatalf("UpdateInbound error = %v, want fallback conflict", err)
|
||||
}
|
||||
var stored model.Inbound
|
||||
if err := db.First(&stored, master.Id).Error; err != nil || stored.Settings != master.Settings {
|
||||
t.Fatalf("rejected encryption update changed inbound: %s, %v", stored.Settings, err)
|
||||
}
|
||||
// Stand in for a legacy/imported conflict without going through CRUD.
|
||||
if err := db.Model(master).Update("settings", update.Settings).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &FallbackService{}
|
||||
if err := svc.SetByMaster(master.Id, []FallbackInput{{Dest: "9090"}}); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") {
|
||||
t.Fatalf("SetByMaster error = %v, want incompatible master", err)
|
||||
}
|
||||
rows, err := svc.GetByMaster(master.Id)
|
||||
if err != nil || len(rows) != 1 || rows[0].Dest != "8080" || rows[0].Id != original.Id {
|
||||
t.Fatalf("rejected replacement changed fallback rules: %+v, %v", rows, err)
|
||||
}
|
||||
if err := svc.SetByMaster(master.Id, nil); err != nil {
|
||||
t.Fatalf("removing conflicting rules must remain possible: %v", err)
|
||||
}
|
||||
if _, _, err := (&InboundService{}).UpdateInbound(&update); err != nil {
|
||||
t.Fatalf("encryption must work after explicit rule removal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVlessLegacyFallbacksAreValidatedOnEveryRuntimePath(t *testing.T) {
|
||||
for _, managed := range []bool{false, true} {
|
||||
for _, nonempty := range []bool{false, true} {
|
||||
if managed && !nonempty {
|
||||
continue
|
||||
}
|
||||
for _, path := range []string{"node", "local", "config"} {
|
||||
t.Run(path+"/"+map[bool]string{false: "empty", true: "nonempty"}[nonempty]+map[bool]string{false: "raw", true: "managed"}[managed], func(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
fb := `[]`
|
||||
if nonempty && !managed {
|
||||
fb = `[{"dest":8080}]`
|
||||
}
|
||||
ib := &model.Inbound{UserId: 1, Enable: true, Tag: "legacy", Port: 32103, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"` + fallbackTestDecryption() + `","fallbacks":` + fb + `}`, StreamSettings: `{"network":"tcp","security":"none"}`}
|
||||
db := database.GetDB()
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if managed {
|
||||
if err := db.Create(&model.InboundFallback{MasterId: ib.Id, Dest: "8080"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
svc := &InboundService{}
|
||||
var built *model.Inbound
|
||||
var err error
|
||||
switch path {
|
||||
case "node":
|
||||
built, err = svc.buildInboundForNodePush(db, ib)
|
||||
case "local":
|
||||
built, err = svc.buildInboundForLocalRuntime(db, ib)
|
||||
case "config":
|
||||
cfg, cfgErr := (&XrayService{}).GetXrayConfig()
|
||||
err = cfgErr
|
||||
if err == nil {
|
||||
for _, inbound := range cfg.InboundConfigs {
|
||||
if inbound.Tag == "legacy" {
|
||||
built = &model.Inbound{Settings: string(inbound.Settings)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if nonempty {
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "fallback") {
|
||||
t.Fatalf("legacy conflict error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || built == nil {
|
||||
t.Fatalf("legacy empty repair = %v, %v", built, err)
|
||||
}
|
||||
var core conf.VLessInboundConfig
|
||||
if err := json.Unmarshal([]byte(built.Settings), &core); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.Build(); err != nil {
|
||||
t.Fatalf("legacy config still crashes Xray: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVlessFallbackNormalizationPreservesSupportedConfigurations(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
protocol model.Protocol
|
||||
settings string
|
||||
reject bool
|
||||
}{
|
||||
{"plain VLESS", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[{"dest":8080}]}`, false},
|
||||
{"outbound encryption only", model.VLESS, `{"clients":[],"decryption":"none","encryption":"link-only-key","fallbacks":[{"dest":8080}]}`, false},
|
||||
{"Trojan", model.Trojan, `{"clients":[],"fallbacks":[{"dest":8080}]}`, false},
|
||||
{"malformed plain VLESS", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[null]}`, true},
|
||||
{"invalid fallback destination", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[{"dest":{}}]}`, true},
|
||||
{"invalid fallback path", model.VLESS, `{"clients":[],"decryption":"none","fallbacks":[{"dest":8080,"path":"invalid"}]}`, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inbound := &model.Inbound{Protocol: tc.protocol, Settings: tc.settings, StreamSettings: `{"network":"tcp","security":"tls"}`}
|
||||
err := normalizeVlessFallbacks(inbound)
|
||||
if (err != nil) != tc.reject {
|
||||
t.Fatalf("validation error: %v", err)
|
||||
}
|
||||
if inbound.Settings != tc.settings {
|
||||
t.Fatalf("normalizer modified supported/rejected settings: %s", inbound.Settings)
|
||||
}
|
||||
if tc.reject {
|
||||
return
|
||||
}
|
||||
if !inboundCanHostFallbacks(inbound) {
|
||||
t.Fatal("supported master became ineligible")
|
||||
}
|
||||
var buildErr error
|
||||
if tc.protocol == model.VLESS {
|
||||
var core conf.VLessInboundConfig
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &core)
|
||||
_, buildErr = core.Build()
|
||||
} else {
|
||||
var core conf.TrojanServerConfig
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &core)
|
||||
_, buildErr = core.Build()
|
||||
}
|
||||
if buildErr != nil {
|
||||
t.Fatal(buildErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
inbound := &model.Inbound{Protocol: model.VLESS, Settings: `{"decryption":"` + fallbackTestDecryption() + `","fallbacks":[],"unknown":9007199254740993}`}
|
||||
if err := normalizeVlessFallbacks(inbound); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
once := inbound.Settings
|
||||
if err := normalizeVlessFallbacks(inbound); err != nil || once != inbound.Settings {
|
||||
t.Fatalf("not idempotent: %s; %v", inbound.Settings, err)
|
||||
}
|
||||
if !strings.Contains(inbound.Settings, "9007199254740993") {
|
||||
t.Fatal("normalization rounded an unrelated number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVlessInvalidCandidateKeepsHealthyCore(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
process, _ := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds})
|
||||
inbound := &model.Inbound{Enable: true, Protocol: model.VLESS, Port: 32119, Settings: `{"clients":[],"decryption":"` + fallbackTestDecryption() + `","fallbacks":[{"dest":8080}]}`, StreamSettings: `{"network":"tcp","security":"none"}`}
|
||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, force := range []bool{false, true} {
|
||||
err := (&XrayService{}).RestartXray(force)
|
||||
if err == nil || !strings.Contains(err.Error(), "fallback") {
|
||||
t.Fatalf("restart error: %v", err)
|
||||
}
|
||||
if currentXrayProcess() != process || !process.IsRunning() {
|
||||
t.Fatal("invalid config replaced healthy Xray")
|
||||
}
|
||||
if _, err := liveBalancerInfo(process, "b1"); err != nil {
|
||||
t.Fatalf("healthy core stopped serving its API: %v", err)
|
||||
}
|
||||
if !strings.Contains((&XrayService{}).GetHeldBackConfig(), "fallback") {
|
||||
t.Fatal("refused config is invisible in status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVlessDecryptionAndManagedFallbackWritesSerialize(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
useTestRuntimeManager(t).SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
||||
master := &model.Inbound{UserId: 1, Protocol: model.VLESS, Port: 32120, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp","security":"tls"}`}
|
||||
if err := database.GetDB().Create(master).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update := *master
|
||||
update.Settings = `{"clients":[],"decryption":"` + fallbackTestDecryption() + `"}`
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
go func() { <-start; _, _, err := (&InboundService{}).UpdateInbound(&update); results <- err }()
|
||||
go func() {
|
||||
<-start
|
||||
results <- (&FallbackService{}).SetByMaster(master.Id, []FallbackInput{{Dest: "8080"}})
|
||||
}()
|
||||
close(start)
|
||||
first, second := <-results, <-results
|
||||
if (first == nil) == (second == nil) {
|
||||
t.Fatalf("exactly one conflicting write must succeed: %v; %v", first, second)
|
||||
}
|
||||
var stored model.Inbound
|
||||
if err := database.GetDB().First(&stored, master.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateVlessManagedFallbacks(nil, &stored); err != nil {
|
||||
t.Fatalf("concurrent writes persisted incompatible state: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,12 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG || inbound.Protocol == model.TUIC {
|
||||
continue
|
||||
}
|
||||
if err := normalizeVlessFallbacks(inbound); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateVlessManagedFallbacks(nil, inbound); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
flowAllowed := !inbound.DisableFlow &&
|
||||
@@ -1503,6 +1509,9 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
|
||||
xrayConfig, err := s.GetXrayConfig()
|
||||
if err != nil {
|
||||
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
||||
xrayState.holdBack(fmt.Sprintf("config refused: %v", err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "اختر inbound",
|
||||
"matchAny": "أي",
|
||||
"destPlaceholder": "تلقائي (listen:port للفرع)",
|
||||
"needsTls": "تصبح الـ Fallbacks متاحة بعد اختيار TLS أو Reality في تبويب الأمان (فقط VLESS/Trojan عبر RAW)."
|
||||
"needsTls": "تصبح الـ Fallbacks متاحة بعد اختيار TLS أو Reality في تبويب الأمان (فقط VLESS/Trojan عبر RAW).",
|
||||
"decryptionConflict": "لا يمكن استخدام fallbacks مع فك تشفير VLESS النشط على الخادم. احذف قواعد fallback واحفظها قبل تفعيل فك التشفير، أو عطّل فك التشفير.",
|
||||
"saveFailed": "تعذر تحميل قواعد fallback أو حفظها. بقيت المسودة مفتوحة؛ أعد المحاولة بعد مراجعة الخطأ."
|
||||
},
|
||||
"protocol": "بروتوكول",
|
||||
"port": "المنفذ",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Pick an inbound",
|
||||
"matchAny": "any",
|
||||
"destPlaceholder": "auto (child listen:port)",
|
||||
"needsTls": "Fallbacks become available once Security is set to TLS or Reality on the Security tab (VLESS/Trojan over RAW only)."
|
||||
"needsTls": "Fallbacks become available once Security is set to TLS or Reality on the Security tab (VLESS/Trojan over RAW only).",
|
||||
"decryptionConflict": "VLESS fallbacks cannot be used with active server decryption. Remove and save fallback rules before enabling VLESS decryption, or disable decryption.",
|
||||
"saveFailed": "Could not load or save fallback rules. Your draft remains open; retry after checking the error."
|
||||
},
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Selecciona un inbound",
|
||||
"matchAny": "cualquiera",
|
||||
"destPlaceholder": "automático (listen:puerto del hijo)",
|
||||
"needsTls": "Los fallbacks estarán disponibles al seleccionar TLS o Reality en la pestaña de Seguridad (solo VLESS/Trojan sobre RAW)."
|
||||
"needsTls": "Los fallbacks estarán disponibles al seleccionar TLS o Reality en la pestaña de Seguridad (solo VLESS/Trojan sobre RAW).",
|
||||
"decryptionConflict": "Los fallbacks no son compatibles con el descifrado VLESS activo del servidor. Elimina y guarda las reglas antes de activarlo, o desactiva el descifrado.",
|
||||
"saveFailed": "No se pudieron cargar o guardar las reglas de fallback. El borrador sigue abierto; revisa el error y vuelve a intentarlo."
|
||||
},
|
||||
"protocol": "Protocolo",
|
||||
"port": "Puerto",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "یک اینباند انتخاب کنید",
|
||||
"matchAny": "همه",
|
||||
"destPlaceholder": "خودکار (listen:port فرزند)",
|
||||
"needsTls": "فالبکها پس از انتخاب TLS یا Reality در برگهی امنیت در دسترس میشوند (فقط VLESS/Trojan روی RAW)."
|
||||
"needsTls": "فالبکها پس از انتخاب TLS یا Reality در برگهی امنیت در دسترس میشوند (فقط VLESS/Trojan روی RAW).",
|
||||
"decryptionConflict": "Fallback با decryption فعال VLESS سازگار نیست. پیش از فعالکردن decryption، قوانین fallback را حذف و ذخیره کنید یا decryption را غیرفعال کنید.",
|
||||
"saveFailed": "بارگیری یا ذخیره قوانین fallback انجام نشد. تغییرات فرم حفظ شدهاند؛ پس از بررسی خطا دوباره تلاش کنید."
|
||||
},
|
||||
"protocol": "پروتکل",
|
||||
"port": "پورت",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Pilih inbound",
|
||||
"matchAny": "apa pun",
|
||||
"destPlaceholder": "otomatis (listen:port child)",
|
||||
"needsTls": "Fallback tersedia setelah memilih TLS atau Reality di tab Keamanan (hanya VLESS/Trojan melalui RAW)."
|
||||
"needsTls": "Fallback tersedia setelah memilih TLS atau Reality di tab Keamanan (hanya VLESS/Trojan melalui RAW).",
|
||||
"decryptionConflict": "Fallback tidak dapat digunakan dengan dekripsi VLESS server yang aktif. Hapus dan simpan aturan fallback sebelum mengaktifkan dekripsi, atau nonaktifkan dekripsi.",
|
||||
"saveFailed": "Aturan fallback gagal dimuat atau disimpan. Draf tetap terbuka; periksa kesalahan lalu coba lagi."
|
||||
},
|
||||
"protocol": "Protokol",
|
||||
"port": "Port",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "インバウンドを選択",
|
||||
"matchAny": "任意",
|
||||
"destPlaceholder": "自動(子の listen:port)",
|
||||
"needsTls": "フォールバックは、セキュリティタブで TLS または Reality を選択すると設定できます(RAW 上の VLESS/Trojan のみ)。"
|
||||
"needsTls": "フォールバックは、セキュリティタブで TLS または Reality を選択すると設定できます(RAW 上の VLESS/Trojan のみ)。",
|
||||
"decryptionConflict": "VLESS のサーバー復号が有効な場合、フォールバックは使用できません。復号を有効にする前にフォールバックルールを削除して保存するか、復号を無効にしてください。",
|
||||
"saveFailed": "フォールバックルールを読み込みまたは保存できませんでした。下書きは開いたままです。エラーを確認して再試行してください。"
|
||||
},
|
||||
"protocol": "プロトコル",
|
||||
"port": "ポート",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Escolha um inbound",
|
||||
"matchAny": "qualquer",
|
||||
"destPlaceholder": "automático (listen:porta do filho)",
|
||||
"needsTls": "Os fallbacks ficam disponíveis após selecionar TLS ou Reality na aba Segurança (apenas VLESS/Trojan sobre RAW)."
|
||||
"needsTls": "Os fallbacks ficam disponíveis após selecionar TLS ou Reality na aba Segurança (apenas VLESS/Trojan sobre RAW).",
|
||||
"decryptionConflict": "Fallbacks não são compatíveis com a descriptografia VLESS ativa no servidor. Remova e salve as regras antes de ativá-la ou desative a descriptografia.",
|
||||
"saveFailed": "Não foi possível carregar ou salvar as regras de fallback. O rascunho continua aberto; verifique o erro e tente novamente."
|
||||
},
|
||||
"protocol": "Protocolo",
|
||||
"port": "Porta",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Выберите инбаунд",
|
||||
"matchAny": "любой",
|
||||
"destPlaceholder": "авто (listen:порт дочернего)",
|
||||
"needsTls": "Fallbacks станут доступны после выбора TLS или Reality на вкладке «Безопасность» (только VLESS/Trojan поверх RAW)."
|
||||
"needsTls": "Fallbacks станут доступны после выбора TLS или Reality на вкладке «Безопасность» (только VLESS/Trojan поверх RAW).",
|
||||
"decryptionConflict": "Fallback несовместим с активной серверной расшифровкой VLESS. Перед её включением удалите и сохраните правила fallback либо отключите расшифровку.",
|
||||
"saveFailed": "Не удалось загрузить или сохранить правила fallback. Черновик остаётся открытым; проверьте ошибку и повторите попытку."
|
||||
},
|
||||
"protocol": "Протокол",
|
||||
"port": "Порт",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Vyberte vstup",
|
||||
"matchAny": "ľubovoľné",
|
||||
"destPlaceholder": "automaticky (adresa:port vnoreného vstupu)",
|
||||
"needsTls": "Náhradné ciele sú dostupné po výbere TLS alebo Reality na karte Bezpečnosť (iba VLESS/Trojan cez RAW)."
|
||||
"needsTls": "Náhradné ciele sú dostupné po výbere TLS alebo Reality na karte Bezpečnosť (iba VLESS/Trojan cez RAW).",
|
||||
"decryptionConflict": "Fallback nemožno použiť s aktívnym serverovým dešifrovaním VLESS. Pred jeho zapnutím odstráňte a uložte pravidlá fallback alebo vypnite dešifrovanie.",
|
||||
"saveFailed": "Pravidlá fallback sa nepodarilo načítať alebo uložiť. Koncept zostáva otvorený; skontrolujte chybu a skúste to znova."
|
||||
},
|
||||
"protocol": "Protokol",
|
||||
"port": "Port",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Bir Gelen Bağlantı Seç",
|
||||
"matchAny": "herhangi",
|
||||
"destPlaceholder": "otomatik (child listen:port)",
|
||||
"needsTls": "Geri düşüşler (fallback), Güvenlik sekmesinde TLS veya Reality seçildiğinde kullanılabilir olur (yalnızca RAW üzerinde VLESS/Trojan)."
|
||||
"needsTls": "Geri düşüşler (fallback), Güvenlik sekmesinde TLS veya Reality seçildiğinde kullanılabilir olur (yalnızca RAW üzerinde VLESS/Trojan).",
|
||||
"decryptionConflict": "Fallback, etkin sunucu tarafı VLESS şifre çözme ile kullanılamaz. Şifre çözmeyi etkinleştirmeden önce fallback kurallarını silip kaydedin veya şifre çözmeyi kapatın.",
|
||||
"saveFailed": "Fallback kuralları yüklenemedi veya kaydedilemedi. Taslak açık kaldı; hatayı kontrol edip tekrar deneyin."
|
||||
},
|
||||
"protocol": "Protokol",
|
||||
"port": "Port",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Оберіть інбаунд",
|
||||
"matchAny": "будь-який",
|
||||
"destPlaceholder": "авто (listen:порт дочірнього)",
|
||||
"needsTls": "Fallbacks стануть доступні після вибору TLS або Reality на вкладці «Безпека» (лише VLESS/Trojan поверх RAW)."
|
||||
"needsTls": "Fallbacks стануть доступні після вибору TLS або Reality на вкладці «Безпека» (лише VLESS/Trojan поверх RAW).",
|
||||
"decryptionConflict": "Fallback несумісний з активним серверним розшифруванням VLESS. Перед його ввімкненням видаліть і збережіть правила fallback або вимкніть розшифрування.",
|
||||
"saveFailed": "Не вдалося завантажити або зберегти правила fallback. Чернетка залишається відкритою; перевірте помилку та повторіть спробу."
|
||||
},
|
||||
"protocol": "Протокол",
|
||||
"port": "Порт",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "Chọn một inbound",
|
||||
"matchAny": "bất kỳ",
|
||||
"destPlaceholder": "tự động (listen:port của child)",
|
||||
"needsTls": "Fallback khả dụng sau khi chọn TLS hoặc Reality trong thẻ Bảo mật (chỉ VLESS/Trojan trên RAW)."
|
||||
"needsTls": "Fallback khả dụng sau khi chọn TLS hoặc Reality trong thẻ Bảo mật (chỉ VLESS/Trojan trên RAW).",
|
||||
"decryptionConflict": "Không thể dùng fallback khi bật giải mã VLESS phía máy chủ. Hãy xóa và lưu các quy tắc fallback trước khi bật giải mã, hoặc tắt giải mã.",
|
||||
"saveFailed": "Không thể tải hoặc lưu quy tắc fallback. Bản nháp vẫn mở; hãy kiểm tra lỗi rồi thử lại."
|
||||
},
|
||||
"protocol": "Giao thức",
|
||||
"port": "Cổng",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "选择一个入站",
|
||||
"matchAny": "任意",
|
||||
"destPlaceholder": "自动(子入站 listen:port)",
|
||||
"needsTls": "在“安全”标签页选择 TLS 或 Reality 后即可配置回落(仅限 RAW 上的 VLESS/Trojan)。"
|
||||
"needsTls": "在“安全”标签页选择 TLS 或 Reality 后即可配置回落(仅限 RAW 上的 VLESS/Trojan)。",
|
||||
"decryptionConflict": "VLESS 服务端解密启用时不能使用回落。请先删除并保存回落规则再启用解密,或关闭解密。",
|
||||
"saveFailed": "无法加载或保存回落规则。草稿保持打开;请检查错误后重试。"
|
||||
},
|
||||
"protocol": "协议",
|
||||
"port": "端口",
|
||||
|
||||
@@ -312,7 +312,9 @@
|
||||
"pickInbound": "選擇一個入站",
|
||||
"matchAny": "任何",
|
||||
"destPlaceholder": "自動(子入站 listen:port)",
|
||||
"needsTls": "在「安全」分頁選擇 TLS 或 Reality 後即可設定回落(僅限 RAW 上的 VLESS/Trojan)。"
|
||||
"needsTls": "在「安全」分頁選擇 TLS 或 Reality 後即可設定回落(僅限 RAW 上的 VLESS/Trojan)。",
|
||||
"decryptionConflict": "VLESS 伺服器端解密啟用時無法使用回落。請先刪除並儲存回落規則再啟用解密,或關閉解密。",
|
||||
"saveFailed": "無法載入或儲存回落規則。草稿保持開啟;請檢查錯誤後重試。"
|
||||
},
|
||||
"protocol": "協議",
|
||||
"port": "連接埠",
|
||||
|
||||
Reference in New Issue
Block a user