精简版P9(4b): 删除本地核心整条路径(进程管理/buildXrayConfig本地分支/local-only注入/本地核心接口与任务)

- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
This commit is contained in:
Muse
2026-10-21 18:43:55 +00:00
parent f6d652019d
commit 1035d00fe4
54 changed files with 310 additions and 5963 deletions
+18
View File
@@ -941,7 +941,22 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
// It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance.
// Returns the created inbound, whether Xray needs restart, and any error.
// errRetiredProtocol rejects MTProto/AmneziaWG inbounds: both protocols
// were served by sidecars of the panel's own local core, which the lite
// master no longer runs (P9(4b)). Existing rows stay in the DB and can
// still be deleted, but they can no longer be created or edited.
func errRetiredProtocol(protocol model.Protocol) error {
if protocol == model.MTProto || protocol == model.AmneziaWG {
return common.NewErrorf("%s inbounds are retired in the lite master (no local core); delete the inbound instead", protocol)
}
return nil
}
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
if err := errRetiredProtocol(inbound.Protocol); err != nil {
return inbound, false, err
}
inbound.Id = 0
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
// Normalize streamSettings based on protocol
@@ -1466,6 +1481,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
}
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
if err := errRetiredProtocol(inbound.Protocol); err != nil {
return inbound, false, err
}
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
@@ -177,7 +177,7 @@ func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) {
}
}
// GetXrayConfig must heal a row that already carries finalmask+reality in the
// The agent config build must heal a row that already carries finalmask+reality in the
// DB (saved before this guard existed - an upgrade, a node sync, a restored
// backup, or a direct DB edit) rather than handing xray-core a config that
// panics it on the first connection. Bypasses AddInbound/UpdateInbound
@@ -187,11 +187,20 @@ func TestGetXrayConfig_HealsFinalMaskRealityCombo(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "in-44302-tcp", "0.0.0.0", 44302, model.VLESS,
realityFinalMaskStream, `{"clients":[]}`)
// Home the row on an agent node: the only config build left is the
// per-agent one.
node := &model.Node{Name: "heal-node", Address: "10.0.0.9", Port: 2053, ApiToken: "t"}
if err := database.GetDB().Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "in-44302-tcp").Update("node_id", node.Id).Error; err != nil {
t.Fatalf("home inbound: %v", err)
}
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(node.Id, "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
for i := range cfg.InboundConfigs {
@@ -1,99 +0,0 @@
package service
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
func mtgConfigPath(t *testing.T, inboundId int) string {
t.Helper()
return filepath.Join(os.Getenv("XUI_BIN_FOLDER"), "mtproto", fmt.Sprintf("mtg-%d.toml", inboundId))
}
func readMtgConfig(t *testing.T, inboundId int) string {
t.Helper()
data, err := os.ReadFile(mtgConfigPath(t, inboundId))
if err != nil {
t.Fatalf("read mtg config: %v", err)
}
return string(data)
}
func TestUpdateInboundMtprotoUnchangedDoesNotRestart(t *testing.T) {
setupConflictDB(t)
pidFile := installFakeMtg(t)
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }}))
t.Cleanup(func() { runtime.SetManager(nil) })
seedInboundConflict(t, "mt-apply", "", 46101, model.MTProto,
"",
`{"clients":[`+
`{"email":"mtga","secret":"`+mtprotoTestSecretA+`","enable":true},`+
`{"email":"mtgb","secret":"`+mtprotoTestSecretB+`","enable":true}]}`)
seeded := loadInboundByTag(t, "mt-apply")
seedClientTraffic(t, seeded.Id, "mtga", true)
seedClientTraffic(t, seeded.Id, "mtgb", true)
svc := &InboundService{}
primed, ok := mtproto.InstanceFromInbound(seeded)
if !ok {
t.Fatal("seed inbound must produce an mtg instance")
}
if err := mtproto.GetManager().Ensure(primed); err != nil {
t.Fatalf("prime mtg: %v", err)
}
t.Cleanup(func() { mtproto.GetManager().Remove(seeded.Id) })
waitForSpawns(t, pidFile, 1)
primedConfig := readMtgConfig(t, seeded.Id)
saveAndAssertKept := func(t *testing.T, mutate func(*model.Inbound)) {
t.Helper()
update := *loadInboundByTag(t, "mt-apply")
mutate(&update)
_, needRestart, err := svc.UpdateInbound(&update)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if needRestart {
t.Fatal("an mtproto-only edit must not request an xray restart")
}
assertNoNewSpawns(t, pidFile, 1)
if got := readMtgConfig(t, seeded.Id); got != primedConfig {
t.Fatalf("config rewritten on a no-op edit:\nbefore:\n%s\nafter:\n%s", primedConfig, got)
}
}
t.Run("unchangedSaveKeepsProcess", func(t *testing.T) {
saveAndAssertKept(t, func(*model.Inbound) {})
})
t.Run("remarkOnlyEditKeepsProcess", func(t *testing.T) {
saveAndAssertKept(t, func(ib *model.Inbound) { ib.Remark = "renamed while users stay connected" })
})
t.Run("rekeyedSecretRestartsProcess", func(t *testing.T) {
update := *loadInboundByTag(t, "mt-apply")
update.Settings = strings.Replace(update.Settings, mtprotoTestSecretA, mtprotoTestSecretD, 1)
if !strings.Contains(update.Settings, mtprotoTestSecretD) {
t.Fatal("fixture must contain the re-keyed secret")
}
_, needRestart, err := svc.UpdateInbound(&update)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if needRestart {
t.Fatal("an mtproto secret change must not request an xray restart")
}
waitForSpawns(t, pidFile, 2)
if got := readMtgConfig(t, seeded.Id); !strings.Contains(got, mtprotoTestSecretD) {
t.Fatalf("restarted config must carry the new secret:\n%s", got)
}
})
}
@@ -1,67 +0,0 @@
package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
func TestClientCrudMtprotoAppliesImmediately(t *testing.T) {
setupConflictDB(t)
pidFile := installFakeMtg(t)
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }}))
t.Cleanup(func() { runtime.SetManager(nil) })
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
created, _, err := inboundSvc.AddInbound(&model.Inbound{
Enable: true,
Listen: "",
Port: 46201,
Protocol: model.MTProto,
Settings: `{"clients":[{"email":"first","secret":"` + mtprotoTestSecretA + `","enable":true}]}`,
})
if err != nil {
t.Fatalf("AddInbound: %v", err)
}
t.Cleanup(func() { mtproto.GetManager().Remove(created.Id) })
waitForSpawns(t, pidFile, 1)
t.Run("add client rewrites the served config", func(t *testing.T) {
payload := &model.Inbound{
Id: created.Id,
Settings: `{"clients":[{"email":"second","secret":"` + mtprotoTestSecretB + `","enable":true}]}`,
}
needRestart, err := clientSvc.AddInboundClient(inboundSvc, payload)
if err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
if needRestart {
t.Fatal("adding an mtproto client must not request an xray restart")
}
cfg := readMtgConfig(t, created.Id)
if !strings.Contains(cfg, `"second" = "`+mtprotoTestSecretB+`"`) {
t.Fatalf("new client must be in the served config:\n%s", cfg)
}
if !strings.Contains(cfg, `"first" = "`+mtprotoTestSecretA+`"`) {
t.Fatalf("existing client must remain served:\n%s", cfg)
}
})
t.Run("delete client drops it from the served config", func(t *testing.T) {
if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "second", false, true); err != nil {
t.Fatalf("DelInboundClientByEmail: %v", err)
}
cfg := readMtgConfig(t, created.Id)
if strings.Contains(cfg, mtprotoTestSecretB) {
t.Fatalf("deleted client must leave the served config:\n%s", cfg)
}
if !strings.Contains(cfg, mtprotoTestSecretA) {
t.Fatalf("surviving client must stay served:\n%s", cfg)
}
})
}
@@ -1,72 +0,0 @@
package service
import (
"errors"
"testing"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
func TestUpdateInboundLocalMtprotoDefersPushUntilCommit(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
fake := &fakeNodeRuntime{}
mgr.SetLocalRuntimeOverride(fake)
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
seedInboundConflict(t, "mt-txfail", "", 46150, model.MTProto, "",
`{"clients":[{"email":"mtx","secret":"`+mtprotoTestSecretA+`","enable":true}]}`)
seeded := loadInboundByTag(t, "mt-txfail")
seedClientTraffic(t, seeded.Id, "mtx", true)
db := database.GetDB()
const cbName = "b1-05:fail-inbound-update"
if err := db.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "inbounds" {
tx.AddError(errors.New("injected transaction failure"))
}
}); err != nil {
t.Fatalf("register callback: %v", err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove(cbName) })
update := *loadInboundByTag(t, "mt-txfail")
update.Remark = "edited"
if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil {
t.Fatal("UpdateInbound: expected the injected transaction failure")
}
if n := fake.updateInbound.Load(); n != 0 {
t.Fatalf("the MTProto sidecar push ran %d time(s) inside the failed transaction; it must be deferred until the commit succeeds", n)
}
}
func TestSetInboundEnableRoutedMtprotoRequestsRestart(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
seedInboundConflict(t, "mt-route", "", 46160, model.MTProto, "",
`{"clients":[{"email":"mtr","secret":"`+mtprotoTestSecretA+`","enable":true}],"routeThroughXray":true,"routeXrayPort":12345}`)
seeded := loadInboundByTag(t, "mt-route")
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", seeded.Id).Update("enable", false).Error; err != nil {
t.Fatalf("force disable: %v", err)
}
needRestart, err := (&InboundService{}).SetInboundEnable(seeded.Id, true)
if err != nil {
t.Fatalf("SetInboundEnable: %v", err)
}
if !needRestart {
t.Fatal("re-enabling a routed MTProto inbound must request an xray restart to re-inject the egress bridge")
}
}
+8 -75
View File
@@ -439,7 +439,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
nodeRow.Id = nodeID
unmanagedTag := unmanagedTagPredicate(&nodeRow)
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
originGuidFor := func(snapIb *model.Inbound) string {
if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid {
return snapIb.OriginNodeGuid
@@ -1268,34 +1267,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
}
if process := currentXrayProcess(); process != nil {
tree := snap.OnlineTree
switch {
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
// Old-build node (no GUID tree): key its flat online list under its
// own effective identity so attribution still works for that branch.
tree = map[string][]string{selfKey: snap.OnlineEmails}
case guidShared && len(tree) > 0:
// Newer cloned node: its own clients arrive keyed under the shared
// panelGuid. Remap just that entry to the node-unique key so the
// clones don't merge; descendant subtrees keep their distinct GUIDs.
if _, ok := tree[nodeRow.Guid]; ok {
tree = remapGuidTreeKey(tree, nodeRow.Guid, selfKey)
}
}
process.SetNodeOnlineTree(nodeID, tree)
activeTree := normalizeActiveInboundTreeTags(snap.ActiveInboundTree, tagToCentral)
if guidShared && len(activeTree) > 0 {
if _, ok := activeTree[nodeRow.Guid]; ok {
activeTree = remapGuidTreeKey(activeTree, nodeRow.Guid, selfKey)
}
}
if len(activeTree) > 0 {
activeTree = filterGuidTreeKeys(activeTree, activeInboundGuidKeys(snap.Inbounds, tagToCentral, originGuidFor))
}
process.SetNodeActiveInboundTree(nodeID, activeTree)
}
return structuralChange, nil
}
@@ -1323,11 +1294,9 @@ func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
}
func (s *InboundService) GetOnlineClients() []string {
process := currentXrayProcess()
if process == nil {
return []string{}
}
return process.GetOnlineClients()
// Lite master: no local core aggregates online clients; node onlines
// arrive through agent reports and node polling instead.
return []string{}
}
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
@@ -1336,17 +1305,7 @@ func (s *InboundService) GetOnlineClients() []string {
// node-id keying so a client three hops down is attributed to its real node,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
process := currentXrayProcess()
if process == nil {
return map[string][]string{}
}
out := process.GetMergedNodeTrees()
if local := process.GetLocalOnlineClients(); len(local) > 0 {
if guid := s.panelGuid(); guid != "" {
out[guid] = mergeEmails(out[guid], local)
}
}
return out
return map[string][]string{}
}
// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
@@ -1354,34 +1313,12 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
// each inbound. A GUID missing from the map means "don't gate" for that node's
// inbounds (old-build node or no active-inbound signal).
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
process := currentXrayProcess()
if process == nil {
return map[string][]string{}
}
out := process.GetMergedActiveInboundTrees()
active := process.GetLocalActiveInbounds()
if len(active) == 0 {
return out
}
guid := s.panelGuid()
if guid == "" {
return out
}
out[guid] = mergeEmails(out[guid], active)
return out
return map[string][]string{}
}
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
if process := currentXrayProcess(); process != nil {
process.SetNodeOnlineTree(nodeID, tree)
}
}
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
if process := currentXrayProcess(); process != nil {
process.ClearNodeOnlineClients(nodeID)
}
}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {}
// panelGuid returns this panel's stable self-identifier, used to key the local
// panel's own clients in the per-node online maps (#4983).
@@ -1518,11 +1455,7 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
// grace window and pruning stale entries. Pass nil to only prune. See
// xray.Process for why the local sets are kept separate from the shared
// last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
if process := currentXrayProcess(); process != nil {
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
}
}
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {}
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB()
-116
View File
@@ -1,116 +0,0 @@
package service
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const (
mtprotoTestSecretA = "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d"
mtprotoTestSecretB = "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d"
mtprotoTestSecretC = "ee202122232425262728292a2b2c2d2e2f6578616d706c652e636f6d"
mtprotoTestSecretD = "ee303132333435363738393a3b3c3d3e3f6578616d706c652e636f6d"
)
func seedClientTraffic(t *testing.T, inboundId int, email string, enable bool) {
t.Helper()
row := xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: enable}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatalf("seed traffic %s: %v", email, err)
}
}
func loadInboundByTag(t *testing.T, tag string) *model.Inbound {
t.Helper()
var ib model.Inbound
if err := database.GetDB().Where("tag = ?", tag).First(&ib).Error; err != nil {
t.Fatalf("load inbound %s: %v", tag, err)
}
return &ib
}
// fakeMtgChildMain is what the re-executed test binary runs when posing as an
// mtg child process: it appends its pid to the file named by MTG_FAKE_PIDFILE
// so tests can count spawns, then blocks until the manager kills it.
func fakeMtgChildMain() {
if f, err := os.OpenFile(os.Getenv("MTG_FAKE_PIDFILE"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
fmt.Fprintf(f, "%d\n", os.Getpid())
f.Close()
}
select {}
}
// installFakeMtg points the mtproto manager at a copy of the running test
// binary posing as mtg (via the MTG_FAKE_CHILD gate in TestMain) and returns
// the pid file whose line count equals the number of processes spawned so far.
func installFakeMtg(t *testing.T) string {
t.Helper()
binDir := t.TempDir()
self, err := os.Executable()
if err != nil {
t.Fatalf("locate test binary: %v", err)
}
payload, err := os.ReadFile(self)
if err != nil {
t.Fatalf("read test binary: %v", err)
}
if err := os.WriteFile(filepath.Join(binDir, mtproto.GetBinaryName()), payload, 0o755); err != nil {
t.Fatalf("install fake mtg: %v", err)
}
pidFile := filepath.Join(binDir, "mtg-pids.txt")
t.Setenv("XUI_BIN_FOLDER", binDir)
t.Setenv("MTG_FAKE_CHILD", "1")
t.Setenv("MTG_FAKE_PIDFILE", pidFile)
return pidFile
}
func countSpawns(t *testing.T, pidFile string) int {
t.Helper()
data, err := os.ReadFile(pidFile)
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatalf("read pid file: %v", err)
}
return len(strings.Fields(string(data)))
}
// waitForSpawns polls until exactly want processes have registered, failing
// fast when the count overshoots and on timeout.
func waitForSpawns(t *testing.T, pidFile string, want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
got := countSpawns(t, pidFile)
if got == want {
return
}
if got > want {
t.Fatalf("expected %d mtg spawn(s), got %d", want, got)
}
if time.Now().After(deadline) {
t.Fatalf("expected %d mtg spawn(s), still %d after timeout", want, got)
}
time.Sleep(20 * time.Millisecond)
}
}
// assertNoNewSpawns gives a wrongly spawned child time to register, then
// asserts the spawn count is still exactly want.
func assertNoNewSpawns(t *testing.T, pidFile string, want int) {
t.Helper()
time.Sleep(500 * time.Millisecond)
if got := countSpawns(t, pidFile); got != want {
t.Fatalf("expected the mtg process to be kept (%d spawn(s)), got %d", want, got)
}
}
@@ -0,0 +1,37 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// Shared helpers for the surviving MTProto/AmneziaWG pure-logic tests.
// They used to live in mtproto_fake_test.go alongside the fake mtg child
// machinery; the fake went away with the local-core runtime tests in
// P9(4b), these seeders are still used by other tests.
const (
mtprotoTestSecretA = "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d"
mtprotoTestSecretB = "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d"
mtprotoTestSecretC = "ee202122232425262728292a2b2c2d2e2f6578616d706c652e636f6d"
mtprotoTestSecretD = "ee303132333435363738393a3b3c3d3e3f6578616d706c652e636f6d"
)
func seedClientTraffic(t *testing.T, inboundId int, email string, enable bool) {
t.Helper()
row := xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: enable}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatalf("seed traffic %s: %v", email, err)
}
}
func loadInboundByTag(t *testing.T, tag string) *model.Inbound {
t.Helper()
var ib model.Inbound
if err := database.GetDB().Where("tag = ?", tag).First(&ib).Error; err != nil {
t.Fatalf("load inbound %s: %v", tag, err)
}
return &ib
}
+5 -98
View File
@@ -22,10 +22,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
@@ -1109,103 +1107,12 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
return patch, err
}
// withOutboundBridge stands up a temporary loopback SOCKS5 inbound in the
// running Xray, routes it through outboundTag, and runs fn with the bridge's
// proxy URL before tearing it down. It is used to reach a node through its
// connection outbound before the persistent egress bridge has been injected
// into the config (e.g. while the node is still being added or edited). When
// Xray isn't running or the bridge can't be built, fn runs with an empty
// proxyURL so callers fall back to a direct connection.
// withOutboundBridge used to stand up a temporary loopback SOCKS5 inbound
// in the panel's own running xray to reach a node through its connection
// outbound. The lite master runs no local core, so probes always take the
// direct-connection fallback this function used when xray was down.
func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
proc := XrayProcess()
if proc == nil || !proc.IsRunning() {
fn("")
return
}
apiPort := proc.GetAPIPort()
if apiPort <= 0 {
fn("")
return
}
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
if err != nil {
fn("")
return
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
tag := fmt.Sprintf("node-test-%d-%d", nodeID, time.Now().UnixNano())
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
inboundJSON, err := json.Marshal(xray.InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: port,
Protocol: "socks",
Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
Tag: tag,
})
if err != nil {
fn("")
return
}
cfg := proc.GetConfig()
routing := map[string]any{}
if len(cfg.RouterConfig) > 0 {
_ = json.Unmarshal(cfg.RouterConfig, &routing)
}
rules, _ := routing["rules"].([]any)
rule := map[string]any{
"type": "field",
"inboundTag": []any{tag},
}
if routingTagIsBalancer(routing, outboundTag) {
rule["balancerTag"] = outboundTag
} else {
rule["outboundTag"] = outboundTag
}
routing["rules"] = append([]any{rule}, rules...)
routingJSON, err := json.Marshal(routing)
if err != nil {
fn("")
return
}
originalRoutingJSON := cfg.RouterConfig
api := xray.XrayAPI{}
if err := api.Init(apiPort); err != nil {
fn("")
return
}
defer api.Close()
if err := api.AddInbound(inboundJSON); err != nil {
fn("")
return
}
defer func() {
if err := api.DelInbound(tag); err != nil {
logger.Warning("remove temp node bridge inbound failed:", err)
}
}()
if err := api.ApplyRoutingConfig(routingJSON); err != nil {
fn("")
return
}
defer func() {
restore := originalRoutingJSON
if len(restore) == 0 {
restore = []byte("{}")
}
if err := api.ApplyRoutingConfig(restore); err != nil {
logger.Warning("restore routing after node bridge failed:", err)
}
}()
fn(proxyURL)
fn("")
}
func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
@@ -86,7 +86,7 @@ func (f *fakeNodeRuntime) ResetAllTraffics(context.Context) error
func setupNodeRuntime(t *testing.T) (int, *fakeNodeRuntime) {
t.Helper()
prev := runtime.GetManager()
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
mgr := runtime.NewManager()
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(prev) })
@@ -7,7 +7,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func assertStringSet(t *testing.T, label string, got, want []string) {
@@ -144,173 +143,8 @@ func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) {
}
}
func TestSetRemoteTraffic_RemapsActiveInboundTreeAndCentralTags(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
previousProcess, previousResult := xrayState.snapshot()
process := xray.NewTestProcess(nil, "")
xrayState.replace(process)
t.Cleanup(func() {
xrayState.mu.Lock()
xrayState.process = previousProcess
xrayState.result = previousResult
xrayState.mu.Unlock()
})
// Force the remote inbound to be adopted with an n1- prefix on the master:
// the active-inbound tree still arrives with the node-local tag.
if err := db.Create(&model.Inbound{
Tag: "shared-tag", Enable: true, Port: 1000, Protocol: model.VLESS, Settings: `{"clients":[]}`,
}).Error; err != nil {
t.Fatalf("create local conflicting inbound: %v", err)
}
// Two cloned nodes share the same panelGuid, so the node's own active tags
// must be keyed by node:1 instead of the duplicated GUID.
for _, n := range []*model.Node{
{Id: 1, Name: "a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup"},
{Id: 2, Name: "b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup"},
} {
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node %s: %v", n.Name, err)
}
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{
Tag: "shared-tag",
Enable: true,
Port: 8443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
}},
ActiveInboundTree: map[string][]string{
"dup": {"shared-tag"},
},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
merged := process.GetMergedActiveInboundTrees()
assertStringSet(t, "active node:1", merged["node:1"], []string{"n1-shared-tag"})
if _, ok := merged["dup"]; ok {
t.Fatalf("cloned active-inbound subtree must not stay under shared GUID: %v", merged)
}
}
func TestSetRemoteTraffic_NormalizesForwardedActiveInboundSubtreeTags(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
previousProcess, previousResult := xrayState.snapshot()
process := xray.NewTestProcess(nil, "")
xrayState.replace(process)
t.Cleanup(func() {
xrayState.mu.Lock()
xrayState.process = previousProcess
xrayState.result = previousResult
xrayState.mu.Unlock()
})
for _, tag := range []string{"own-tag", "child-tag"} {
if err := db.Create(&model.Inbound{
Tag: tag, Enable: true, Port: 1000, Protocol: model.VLESS, Settings: `{"clients":[]}`,
}).Error; err != nil {
t.Fatalf("create local conflicting inbound %q: %v", tag, err)
}
}
if err := db.Create(&model.Node{
Id: 1, Name: "node2", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node2-guid",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{
Tag: "own-tag",
Enable: true,
Port: 8443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
},
{
Tag: "child-tag",
Enable: true,
Port: 9443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
OriginNodeGuid: "child-guid",
},
},
ActiveInboundTree: map[string][]string{
"node2-guid": {"own-tag"},
"child-guid": {"child-tag"},
},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
merged := process.GetMergedActiveInboundTrees()
assertStringSet(t, "direct node active tags", merged["node2-guid"], []string{"n1-own-tag"})
assertStringSet(t, "forwarded child active tags", merged["child-guid"], []string{"n1-child-tag"})
}
func TestSetRemoteTraffic_DropsForeignActiveInboundGuid(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
previousProcess, previousResult := xrayState.snapshot()
process := xray.NewTestProcess(nil, "")
xrayState.replace(process)
t.Cleanup(func() {
xrayState.mu.Lock()
xrayState.process = previousProcess
xrayState.result = previousResult
xrayState.mu.Unlock()
})
for _, n := range []*model.Node{
{Id: 1, Name: "node-a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "node-a-guid"},
{Id: 2, Name: "node-b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-b-guid"},
} {
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node %s: %v", n.Name, err)
}
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{
Tag: "own-tag",
Enable: true,
Port: 8443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
}},
ActiveInboundTree: map[string][]string{
"node-a-guid": {"own-tag"},
"node-b-guid": {"foreign-tag"},
},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
merged := process.GetMergedActiveInboundTrees()
assertStringSet(t, "own active tags", merged["node-a-guid"], []string{"own-tag"})
if _, ok := merged["node-b-guid"]; ok {
t.Fatalf("foreign active-inbound subtree should be ignored: %v", merged)
}
}
// A node mid-restart can return an empty inbound list with success=true. The
// sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes
@@ -29,10 +29,9 @@ import (
// core predates that rejection, unencrypted outbounds are kept, mirroring
// CheckXrayConfig's version gate.
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
// Lite master: no local core, so the version gate always takes the
// "Unknown" branch — the same outcome as upstream with xray stopped.
coreVersion := "Unknown"
if process := currentXrayProcess(); process != nil {
coreVersion = process.GetXrayVersion()
}
kept := make([]any, 0, len(outbounds))
var dropped []string
for _, ob := range outbounds {
+1 -1
View File
@@ -26,7 +26,7 @@ func TestResetAllTrafficsDoesNotBlockWriterOnNodeCall(t *testing.T) {
resetTrafficWriterForTest(t)
StartTrafficWriter()
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
mgr := runtime.NewManager()
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
+15 -36
View File
@@ -540,20 +540,11 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.PublicIP.IPv4 = s.cachedIPv4
status.PublicIP.IPv6 = s.cachedIPv6
// Xray status
if s.xrayService.IsXrayRunning() {
status.Xray.State = Running
status.Xray.ErrorMsg = ""
} else {
err := s.xrayService.GetXrayErr()
if err != nil {
status.Xray.State = Error
} else {
status.Xray.State = Stop
}
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
}
status.Xray.Version = s.xrayService.GetXrayVersion()
// Xray status: the lite master runs no local core, so it always
// reports stopped with no version.
status.Xray.State = Stop
status.Xray.ErrorMsg = ""
status.Xray.Version = ""
var amneziawgCount int64
if err := database.GetDB().Model(model.Inbound{}).
@@ -578,11 +569,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.AppStats.Mem = rtm.Sys
}
status.AppStats.Threads = uint32(runtime.NumGoroutine())
if process := currentXrayProcess(); process != nil && process.IsRunning() {
status.AppStats.Uptime = process.GetUptime()
} else {
status.AppStats.Uptime = 0
}
status.AppStats.Uptime = 0
return status
}
@@ -672,21 +659,15 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
return s.emaCPU, nil
}
// StopXrayService is a no-op on the lite master: there is no local core to
// stop. It stays so the DB import/restore flows keep their shape.
func (s *ServerService) StopXrayService() error {
err := s.xrayService.StopXray()
if err != nil {
logger.Error("stop xray failed:", err)
return err
}
return nil
}
// RestartXrayService is a no-op on the lite master: there is no local core
// to restart. Agent nodes pick up changes on their next config pull.
func (s *ServerService) RestartXrayService() error {
err := s.xrayService.RestartXray(true)
if err != nil {
logger.Error("start xray failed:", err)
return err
}
return nil
}
@@ -718,19 +699,17 @@ func isVirtualInterface(name string) bool {
return false
}
// GetConfigJson returns the stored xray template as JSON. The lite master
// builds no local config (there is no local core to run one), so this is
// the template itself — the same document the settings page edits.
func (s *ServerService) GetConfigJson() (any, error) {
config, err := s.xrayService.GetXrayConfig()
if err != nil {
return nil, err
}
contents, err := json.MarshalIndent(config, "", " ")
template, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
return nil, err
}
var jsonData any
err = json.Unmarshal(contents, &jsonData)
if err != nil {
if err := json.Unmarshal([]byte(template), &jsonData); err != nil {
return nil, err
}
+4 -35
View File
@@ -393,45 +393,14 @@ func (s *SettingService) SetPanelOutbound(tag string) error {
// It returns "" — meaning a direct connection — when the feature is off or
// the bridge is not present in the running core yet.
func (s *SettingService) PanelEgressProxyURL() string {
tag, err := s.GetPanelOutbound()
if err != nil || tag == "" {
return ""
}
proc := XrayProcess()
if proc == nil || !proc.IsRunning() {
logger.Warning("panel outbound [", tag, "] is set but Xray is not running, using a direct connection")
return ""
}
cfg := proc.GetConfig()
if cfg == nil {
return ""
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
}
}
logger.Warning("panel outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
// The panel-egress bridge lived in the panel's own xray core. The lite
// master runs no local core, so there is never a bridge: direct.
return ""
}
func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
tag := NodeEgressInboundTag(nodeID)
proc := XrayProcess()
if proc == nil || !proc.IsRunning() {
logger.Warning("node outbound [", tag, "] is set but Xray is not running, using a direct connection")
return ""
}
cfg := proc.GetConfig()
if cfg == nil {
return ""
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag == tag {
return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
}
}
logger.Warning("node outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
// The per-node egress bridge also lived in the panel's own xray core;
// with no local core there is never a bridge: direct connection.
return ""
}
+2 -7
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strconv"
"strings"
"time"
@@ -492,14 +491,10 @@ func (t *Tgbot) clientInfoMsg(
active = t.I18nBot("tgbot.messages.no")
}
// Lite master: per-client online state comes from agent reports, not
// a local core; the bot reports the client as offline here.
status := t.I18nBot("tgbot.offline")
isOnline := false
if process := service.XrayProcess(); process != nil && process.IsRunning() {
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
status = t.I18nBot("tgbot.online")
isOnline = true
}
}
output := ""
output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
+2 -11
View File
@@ -14,7 +14,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mymmrac/telego"
@@ -110,10 +109,7 @@ func (t *Tgbot) prepareServerUsageInfo() string {
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
t.setCachedStatus(t.lastStatus)
}
var onlines []string
if process := service.XrayProcess(); process != nil {
onlines = process.GetOnlineClients()
}
onlines := t.inboundService.GetOnlineClients()
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
@@ -369,12 +365,7 @@ func (t *Tgbot) notifyExhausted() {
// onlineClients retrieves and sends information about online clients.
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
process := service.XrayProcess()
if process == nil || !process.IsRunning() {
return
}
onlines := process.GetOnlineClients()
onlines := t.inboundService.GetOnlineClients()
onlinesCount := len(onlines)
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
+2 -16
View File
@@ -148,7 +148,6 @@ func (t *Tgbot) OnReceive() {
userID := sharedUser.UserID
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, message.UsersShared.RequestID, userID)
if needRestart {
t.xrayService.SetToNeedRestart()
}
output := ""
if err != nil {
@@ -220,16 +219,8 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
onlyMessage = true
if isAdmin {
if len(commandArgs) == 0 {
if t.xrayService.IsXrayRunning() {
err := t.xrayService.RestartXray(true)
if err != nil {
msg += t.I18nBot("tgbot.commands.restartFailed", "Error=="+err.Error())
} else {
msg += t.I18nBot("tgbot.commands.restartSuccess")
}
} else {
msg += t.I18nBot("tgbot.commands.xrayNotRunning")
}
// Lite master: there is no local xray core to restart.
msg += t.I18nBot("tgbot.commands.xrayNotRunning")
} else {
handleUnknownCommand()
msg += t.I18nBot("tgbot.commands.restartUsage")
@@ -420,7 +411,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if err == nil {
needRestart, err := t.clientService.ResetClientTrafficLimitByEmail(&t.inboundService, email, limitTraffic)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.setTrafficLimitSuccess", "Email=="+email))
@@ -602,7 +592,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
}
needRestart, err := t.clientService.ResetClientExpiryTimeByEmail(&t.inboundService, email, date)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.expireResetSuccess", "Email=="+email))
@@ -772,7 +761,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if err == nil {
needRestart, err := t.clientService.ResetClientIpLimitByEmail(&t.inboundService, email, count)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.resetIpSuccess", "Email=="+email, "Count=="+strconv.Itoa(count)))
@@ -940,7 +928,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
}
needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, traffic.Id, EmptyTelegramUserID)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.removedTGUserSuccess", "Email=="+email))
@@ -961,7 +948,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
case "toggle_enable_c":
enabled, needRestart, err := t.clientService.ToggleClientEnableByEmail(&t.inboundService, email)
if needRestart {
t.xrayService.SetToNeedRestart()
}
if err == nil {
if enabled {
@@ -30,7 +30,7 @@ func TestTrafficDisableImmediatelyUpdatesNodeRuntime(t *testing.T) {
func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
mgr := runtime.NewManager()
fake := &fakeNodeRuntime{}
mgr.SetLocalRuntimeOverride(fake)
runtime.SetManager(mgr)
@@ -62,7 +62,7 @@ func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
mgr := runtime.NewManager()
fake := &fakeNodeRuntime{}
mgr.SetLocalRuntimeOverride(fake)
runtime.SetManager(mgr)
File diff suppressed because it is too large Load Diff
@@ -4,20 +4,41 @@ import (
"encoding/json"
"testing"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// clientsTestNodeID returns the shared agent node these client-emission
// tests home their inbounds on, creating it on first use. The only config
// build left in the lite master is the per-agent one.
func clientsTestNodeID(t *testing.T, db *gorm.DB) int {
t.Helper()
var node model.Node
if err := db.Where("name = ?", "clients-test-node").First(&node).Error; err == nil {
return node.Id
}
node = model.Node{Name: "clients-test-node", Address: "10.0.0.8", Port: 2053, ApiToken: "t"}
if err := db.Create(&node).Error; err != nil {
t.Fatalf("create clients-test node: %v", err)
}
return node.Id
}
func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
nodeID := clientsTestNodeID(t, db)
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: model.VLESS,
Settings: `{"clients":[],"decryption":"none"}`,
NodeID: &nodeID,
}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create vless inbound: %v", err)
@@ -40,9 +61,9 @@ func disableClients(t *testing.T, emails ...string) {
func emittedClients(t *testing.T, tag string) (any, bool) {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(clientsTestNodeID(t, database.GetDB()), "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag != tag {
+2 -878
View File
@@ -3,12 +3,8 @@ package service
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -17,13 +13,8 @@ import (
)
func TestMain(m *testing.M) {
// A test binary re-executed with MTG_FAKE_CHILD=1 poses as an mtg child
// process (see mtproto_fake_test.go) and never reaches the test runner.
if os.Getenv("MTG_FAKE_CHILD") == "1" {
fakeMtgChildMain()
}
// injectPanelEgress logs when it skips injection; the package logger must
// exist before any test exercises a skipped path.
// The package logger must exist before any test exercises a path that
// logs a skip/warning.
xuilogger.InitLogger(logging.ERROR)
os.Exit(m.Run())
}
@@ -146,870 +137,3 @@ type egressRouting struct {
Type string `json:"type"`
} `json:"rules"`
}
func TestInjectPanelEgress(t *testing.T) {
cfg := egressTestConfig()
injectPanelEgress(cfg, "warp")
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("expected the egress inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
}
ib := cfg.InboundConfigs[1]
if ib.Tag != PanelEgressInboundTag || ib.Protocol != "socks" || ib.Port != panelEgressBasePort {
t.Fatalf("unexpected egress inbound: %+v", ib)
}
if string(ib.Listen) != `"127.0.0.1"` {
t.Fatalf("egress inbound must listen on loopback, got %s", ib.Listen)
}
var routing egressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if routing.DomainStrategy != "AsIs" {
t.Fatalf("routing keys outside rules must be preserved, got %+v", routing)
}
if len(routing.Rules) != 2 {
t.Fatalf("expected egress rule + existing rule, got %+v", routing.Rules)
}
first := routing.Rules[0]
if first.Type != "field" || first.OutboundTag != "warp" ||
len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
t.Fatalf("egress rule must be prepended, got %+v", first)
}
}
func TestInjectPanelEgress_BalancerTag(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
// A tag that names a balancer must be targeted via balancerTag so the
// router resolves it; an outbound tag coexisting with balancers still uses
// outboundTag.
injectPanelEgress(cfg, "lb")
var routing struct {
Rules []struct {
InboundTag []string `json:"inboundTag"`
OutboundTag string `json:"outboundTag"`
BalancerTag string `json:"balancerTag"`
Type string `json:"type"`
} `json:"rules"`
}
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 1 {
t.Fatalf("expected the egress rule, got %+v", routing.Rules)
}
first := routing.Rules[0]
if first.BalancerTag != "lb" || first.OutboundTag != "" {
t.Fatalf("a balancer tag must target balancerTag, not outboundTag, got %+v", first)
}
if len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
t.Fatalf("egress rule must bind the egress inbound, got %+v", first)
}
// A non-balancer tag alongside balancers keeps the plain outbound path.
cfg2 := egressTestConfig()
cfg2.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
injectPanelEgress(cfg2, "warp")
var routing2 struct {
Rules []struct {
OutboundTag string `json:"outboundTag"`
BalancerTag string `json:"balancerTag"`
} `json:"rules"`
}
if err := json.Unmarshal(cfg2.RouterConfig, &routing2); err != nil {
t.Fatal(err)
}
if routing2.Rules[0].OutboundTag != "warp" || routing2.Rules[0].BalancerTag != "" {
t.Fatalf("a concrete outbound must target outboundTag, got %+v", routing2.Rules[0])
}
}
func TestInjectPanelEgress_PortCollision(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: panelEgressBasePort, Protocol: "vless", Tag: "in-1"},
xray.InboundConfig{Port: panelEgressBasePort + 1, Protocol: "vless", Tag: "in-2"},
)
injectPanelEgress(cfg, "direct")
got := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
if got.Tag != PanelEgressInboundTag || got.Port != panelEgressBasePort+2 {
t.Fatalf("egress inbound must skip taken ports, got %+v", got)
}
}
func TestInjectPanelEgress_TagCollisionSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: PanelEgressInboundTag},
)
before := string(cfg.RouterConfig)
injectPanelEgress(cfg, "direct")
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
t.Fatal("a user inbound owning the egress tag must make injection a no-op")
}
}
func TestInjectPanelEgress_NoRoutingSection(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = nil
injectPanelEgress(cfg, "direct")
var routing egressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 1 || routing.Rules[0].OutboundTag != "direct" {
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing)
}
if len(cfg.InboundConfigs) != 2 {
t.Fatal("egress inbound must still be appended")
}
}
func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{not json`)
injectPanelEgress(cfg, "direct")
if len(cfg.InboundConfigs) != 1 {
t.Fatal("unparsable routing must skip the whole injection, inbound included")
}
if string(cfg.RouterConfig) != `{not json` {
t.Fatal("unparsable routing must be left untouched")
}
}
func TestInjectPanelEgress_MissingTargetSkips(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.RouterConfig)
injectPanelEgress(cfg, "removed-subscription-outbound")
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("a missing target must not expose the panel bridge, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
}
}
func TestInjectPanelEgress_BadOutboundsSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
before := string(cfg.RouterConfig)
injectPanelEgress(cfg, "direct")
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("unparsable outbounds must not expose the panel bridge, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
}
}
func TestInjectNodeEgresses_MissingTargetSkips(t *testing.T) {
cfg := egressTestConfig()
injectNodeEgresses(cfg, []*model.Node{
{Id: 1, Enable: true, OutboundTag: "removed-subscription-outbound"},
{Id: 2, Enable: true, OutboundTag: "warp"},
})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("only the node with a valid target should get a bridge, got %+v", cfg.InboundConfigs)
}
bridge := cfg.InboundConfigs[1]
if bridge.Tag != NodeEgressInboundTag(2) || bridge.Port != nodeEgressBasePort+2 {
t.Fatalf("unexpected node egress bridge: %+v", bridge)
}
var routing egressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "warp" ||
len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(2) {
t.Fatalf("only the valid node egress rule should be prepended, got %+v", routing.Rules)
}
}
func TestInjectNodeEgresses_BadOutboundsSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
before := string(cfg.RouterConfig)
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("unparsable outbounds must not expose a node bridge, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
}
}
func TestInjectNodeEgresses_BalancerTarget(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "lb"}})
var routing struct {
Rules []struct {
OutboundTag string `json:"outboundTag"`
BalancerTag string `json:"balancerTag"`
} `json:"rules"`
}
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
t.Fatalf("a valid balancer target must create the node bridge and rule, got %+v", routing.Rules)
}
}
func TestInjectNodeEgresses_TagCollisionSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: NodeEgressInboundTag(1)},
)
before := string(cfg.RouterConfig)
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
t.Fatal("an existing node egress tag must make that node injection a no-op")
}
}
func TestInjectNodeEgresses_PortCollision(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: nodeEgressBasePort + 1, Protocol: "vless", Tag: "in-1"},
xray.InboundConfig{Port: nodeEgressBasePort + 2, Protocol: "vless", Tag: "in-2"},
)
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
bridge := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
if bridge.Tag != NodeEgressInboundTag(1) || bridge.Port != nodeEgressBasePort+3 {
t.Fatalf("node egress must skip taken ports, got %+v", bridge)
}
}
func TestInjectNodeEgresses_NoRoutingSection(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = nil
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
var routing egressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 ||
routing.Rules[0].OutboundTag != "direct" ||
len(routing.Rules[0].InboundTag) != 1 || routing.Rules[0].InboundTag[0] != NodeEgressInboundTag(1) {
t.Fatalf("a routing section must be created with the node egress rule, got %+v", routing.Rules)
}
}
func TestInjectNodeEgresses_BadRoutingSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{not json`)
injectNodeEgresses(cfg, []*model.Node{{Id: 1, Enable: true, OutboundTag: "direct"}})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("unparsable routing must not expose a node bridge, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != `{not json` {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
}
}
func mtprotoInbound(tag string, settings string) *model.Inbound {
return &model.Inbound{Tag: tag, Protocol: model.MTProto, Enable: true, Settings: settings}
}
func TestInjectMtprotoEgress_WithOutbound(t *testing.T) {
cfg := egressTestConfig()
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
`{"routeThroughXray":true,"routeXrayPort":50000,"outboundTag":"warp"}`))
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("expected the bridge inbound to be appended, got %d", len(cfg.InboundConfigs))
}
ib := cfg.InboundConfigs[1]
if ib.Tag != "inbound-443" || ib.Protocol != "socks" || ib.Port != 50000 {
t.Fatalf("unexpected bridge inbound: %+v", ib)
}
if string(ib.Listen) != `"127.0.0.1"` {
t.Fatalf("bridge must listen on loopback, got %s", ib.Listen)
}
var routing egressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 {
t.Fatalf("expected the egress rule prepended to the existing rule, got %+v", routing.Rules)
}
first := routing.Rules[0]
if first.Type != "field" || first.OutboundTag != "warp" ||
len(first.InboundTag) != 1 || first.InboundTag[0] != "inbound-443" {
t.Fatalf("egress rule must bind the inbound tag to the outbound, got %+v", first)
}
}
func TestInjectMtprotoEgress_NoOutboundLeavesRouting(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.RouterConfig)
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
`{"routeThroughXray":true,"routeXrayPort":50001}`))
if len(cfg.InboundConfigs) != 2 || cfg.InboundConfigs[1].Port != 50001 {
t.Fatalf("bridge must still be appended without an outbound, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("no outbound means no rule change, got %s", cfg.RouterConfig)
}
}
func TestInjectMtprotoEgress_BalancerTag(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
`{"routeThroughXray":true,"routeXrayPort":50002,"outboundTag":"lb"}`))
var routing struct {
Rules []struct {
OutboundTag string `json:"outboundTag"`
BalancerTag string `json:"balancerTag"`
} `json:"rules"`
}
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 1 || routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
t.Fatalf("a balancer tag must target balancerTag, got %+v", routing.Rules)
}
}
func TestInjectMtprotoEgress_Disabled(t *testing.T) {
// Not routed, and routed-but-portless, are both no-ops.
for _, settings := range []string{
`{"routeThroughXray":false,"routeXrayPort":50000}`,
`{"routeThroughXray":true}`,
`{"routeThroughXray":true,"routeXrayPort":0}`,
} {
cfg := egressTestConfig()
before := string(cfg.RouterConfig)
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443", settings))
if len(cfg.InboundConfigs) != 1 || string(cfg.RouterConfig) != before {
t.Fatalf("settings %s must be a no-op, got %d inbounds", settings, len(cfg.InboundConfigs))
}
}
}
func TestInjectMtprotoEgress_TagCollisionSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 443, Protocol: "vless", Tag: "inbound-443"})
before := string(cfg.RouterConfig)
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
`{"routeThroughXray":true,"routeXrayPort":50003,"outboundTag":"warp"}`))
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
t.Fatal("a real inbound already owning the tag must make the bridge a no-op")
}
}
func TestInjectMtprotoEgress_MissingTargetSkips(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.RouterConfig)
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
`{"routeThroughXray":true,"routeXrayPort":50004,"outboundTag":"removed-subscription-outbound"}`))
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("a missing target must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("a missing target must leave routing untouched, got %s", cfg.RouterConfig)
}
}
func TestInjectMtprotoEgress_BadOutboundsSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
before := string(cfg.RouterConfig)
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
`{"routeThroughXray":true,"routeXrayPort":50005,"outboundTag":"direct"}`))
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("unparsable outbounds must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("unparsable outbounds must leave routing untouched, got %s", cfg.RouterConfig)
}
}
func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{not json`)
injectMtprotoEgress(cfg, mtprotoInbound("inbound-443",
`{"routeThroughXray":true,"routeXrayPort":50006,"outboundTag":"direct"}`))
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("unparsable routing must not expose the mtproto bridge, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != `{not json` {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
}
}
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.RouterConfig)
inbound := amneziawgInbound(7, "awg-7", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("expected the relay inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
}
ib := cfg.InboundConfigs[1]
if ib.Tag != "awg-7" || ib.Protocol != "socks" || ib.Port != amneziawgnet.SOCKSPortForInbound(7) {
t.Fatalf("relay inbound must reuse the inbound's own tag (so per-inbound stats totals keep matching, and it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib)
}
if string(ib.Listen) != `"127.0.0.1"` {
t.Fatalf("relay inbound must listen on loopback, got %s", ib.Listen)
}
if !strings.Contains(string(ib.Settings), `"auth":"password"`) || !strings.Contains(string(ib.Settings), `"udp":true`) {
t.Fatalf("relay inbound must require password auth and allow UDP ASSOCIATE, got %s", ib.Settings)
}
if !strings.Contains(string(ib.Settings), `"a@x"`) {
t.Fatalf("relay inbound must have an account for the peer's email, got %s", ib.Settings)
}
if !strings.Contains(string(ib.Sniffing), `"enabled":true`) {
t.Fatalf("relay inbound must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing)
}
// No auto-generated routing rule: it's entirely up to the admin's own
// Routing-page rules, same as any other protocol's inbound tag.
if string(cfg.RouterConfig) != before {
t.Fatalf("injectAmneziawgnetSocks must never touch the routing section, got %s", cfg.RouterConfig)
}
}
func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) {
cfg := egressTestConfig()
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
})
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
if len(cfg.InboundConfigs) != 3 {
t.Fatalf("expected one relay inbound per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs)
}
byTag := map[string]int{}
for _, ib := range cfg.InboundConfigs[1:] {
byTag[ib.Tag] = ib.Port
}
if byTag["awg-1"] != amneziawgnet.SOCKSPortForInbound(1) || byTag["awg-2"] != amneziawgnet.SOCKSPortForInbound(2) {
t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag)
}
}
func TestInjectAmneziawgnetSocks_NoQualifyingPeerSkipsRelay(t *testing.T) {
cases := []struct {
name string
client model.Client
enable bool
}{
{"client disabled", model.Client{Email: "a@x", Enable: false, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
{"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true},
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true},
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false},
{"no Email", model.Client{Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client})
inbound.Enable = c.enable
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs))
}
})
}
}
func TestInjectAmneziawgnetSocks_AlwaysOnRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
// Unlike the retired kernel-module bridge, the embedded relay has no
// opt-in gate: there is no alternative datapath once traffic is
// decapsulated in gVisor. A stale RouteThroughXray=false left over from
// a pre-cutover install must not suppress the relay inbound.
cfg := egressTestConfig()
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: false}
settings, _ := json.Marshal(amneziawg.InboundSettings{
Server: &server,
Clients: []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
},
})
inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("the relay inbound must always be created regardless of RouteThroughXray, got %+v", cfg.InboundConfigs)
}
}
func TestInjectAmneziawgnetSocks_WrongProtocolOrNodeSkipped(t *testing.T) {
cfg := egressTestConfig()
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
nodeID := 5
nodeHosted := amneziawgInbound(2, "awg-2", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
nodeHosted.NodeID = &nodeID
injectAmneziawgnetSocks(cfg, []*model.Inbound{vless, nodeHosted})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a relay inbound, got %+v", cfg.InboundConfigs)
}
}
func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
})
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
// Started with 2 (api + the colliding vless entry); only awg-2's relay
// inbound should have been added, awg-1's skipped since its tag is taken.
if len(cfg.InboundConfigs) != 3 {
t.Fatalf("expected only the non-colliding inbound's relay inbound to be added, got %+v", cfg.InboundConfigs)
}
found := false
for _, ib := range cfg.InboundConfigs {
if ib.Tag == "awg-2" && ib.Protocol == "socks" {
found = true
}
}
if !found {
t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision")
}
}
// amneziawgV6Inbound builds an AmneziaWG inbound with IPv6 enabled and a
// given external interface -- amneziawgInbound's own ServerSettings never
// sets these, so injectAmneziawgV6Egress's tests need their own variant.
func amneziawgV6Inbound(id int, tag string, ext6 string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
IPv6Enabled: true, IPv6ExternalInterface: ext6,
}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
// amneziawgV6InboundNotActive builds an inbound that fails V6AliasesActive
// (either toggle can do it), unlike amneziawgV6Inbound which always passes it.
func amneziawgV6InboundNotActive(id int, tag string, ipv6Enabled bool, ext6 string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
IPv6Enabled: ipv6Enabled, IPv6ExternalInterface: ext6,
}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
// injectAmneziawgV6Egress runs after injectAmneziawgnetSocks in the real
// GetXrayConfig() pipeline and depends on its relay inbound already
// existing (see the "live" tag check) -- every test below calls both, in
// that order, to match production.
func injectAmneziawgSocksThenV6(cfg *xray.Config, inbounds []*model.Inbound) {
injectAmneziawgnetSocks(cfg, inbounds)
injectAmneziawgV6Egress(cfg, inbounds)
}
type v6EgressRouting struct {
Rules []struct {
InboundTag []string `json:"inboundTag"`
User []string `json:"user"`
OutboundTag string `json:"outboundTag"`
Type string `json:"type"`
} `json:"rules"`
}
type v6EgressOutbound struct {
Tag string `json:"tag"`
Protocol string `json:"protocol"`
SendThrough string `json:"sendThrough"`
}
func TestInjectAmneziawgV6Egress_CreatesOutboundAndRuleForV6Peer(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(7, "awg-7", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
wantTag := amneziawgV6EgressTag(7, "a@x")
var got *v6EgressOutbound
for i := range outbounds {
if outbounds[i].Tag == wantTag {
got = &outbounds[i]
}
}
if got == nil {
t.Fatalf("expected an outbound tagged %q, got %+v", wantTag, outbounds)
}
if got.Protocol != "freedom" || got.SendThrough != "fd86:ea04:1115::2" {
t.Fatalf("outbound must be a freedom outbound bound to the peer's own v6 address, got %+v", got)
}
// Pre-existing outbounds (direct, warp) must survive untouched.
if len(outbounds) != 3 {
t.Fatalf("expected the 2 pre-existing outbounds plus 1 new one, got %+v", outbounds)
}
var routing v6EgressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
ruleIdx := -1
for i := range routing.Rules {
if routing.Rules[i].OutboundTag == wantTag {
ruleIdx = i
}
}
if ruleIdx == -1 {
t.Fatalf("expected a routing rule targeting %q, got %+v", wantTag, routing.Rules)
}
rule := routing.Rules[ruleIdx]
if rule.Type != "field" || len(rule.User) != 1 || rule.User[0] != "a@x" ||
len(rule.InboundTag) != 1 || rule.InboundTag[0] != "awg-7" {
t.Fatalf("rule must match this peer's email and inbound tag, got %+v", rule)
}
}
func TestInjectAmneziawgV6Egress_SkipsPeerWithoutV6Address(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, // v4 only
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("a peer with no v6 AllowedIPs entry must not get an outbound, got %s", cfg.OutboundConfigs)
}
}
// The documented "leave the interface blank to auto-detect" happy path must
// not silently emit a sendThrough for an address the host was never told to
// own -- there is no auto-detect, so that would fail every connection.
func TestInjectAmneziawgV6Egress_SkipsWhenIPv6EnabledButInterfaceBlank(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6InboundNotActive(1, "awg-1", true, "", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("IPv6Enabled with a blank interface must not get an outbound (no auto-detect exists), got %s", cfg.OutboundConfigs)
}
}
// The inverse of amneziawgV6Inbound's own always-true IPv6Enabled: a filled
// IPv6ExternalInterface alone (e.g. left over from a previous enable) must
// not activate egress on its own.
func TestInjectAmneziawgV6Egress_SkipsWhenIPv6DisabledEvenWithInterfaceSet(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6InboundNotActive(1, "awg-1", false, "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("IPv6Enabled false must not get an outbound even with a leftover interface set, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_MultiplePeersEachGetOwnOutboundAndRule(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
tagA, tagB := amneziawgV6EgressTag(1, "a@x"), amneziawgV6EgressTag(1, "b@x")
seen := map[string]string{}
for _, o := range outbounds {
seen[o.Tag] = o.SendThrough
}
if seen[tagA] != "fd86:ea04:1115::2" || seen[tagB] != "fd86:ea04:1115::3" {
t.Fatalf("each peer must get its own outbound bound to its own address, got %+v", seen)
}
}
func TestInjectAmneziawgV6Egress_StableTagAcrossRegenerations(t *testing.T) {
// Same instance data, two independent injections -- hot_diff.go relies on
// the tag being a pure function of (inboundID, email) so it recognizes
// "unchanged" rather than remove+recreate on every poll.
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
cfg1 := egressTestConfig()
injectAmneziawgSocksThenV6(cfg1, []*model.Inbound{inbound})
cfg2 := egressTestConfig()
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
var out1, out2 []v6EgressOutbound
json.Unmarshal(cfg1.OutboundConfigs, &out1)
json.Unmarshal(cfg2.OutboundConfigs, &out2)
if len(out1) != len(out2) || out1[len(out1)-1].Tag != out2[len(out2)-1].Tag {
t.Fatalf("tag must be stable across independent regenerations, got %+v vs %+v", out1, out2)
}
}
func TestInjectAmneziawgV6Egress_SkipsWrongProtocolOrNodeHostedOrDisabled(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
nodeID := 5
nodeHosted := amneziawgV6Inbound(2, "awg-2", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
nodeHosted.NodeID = &nodeID
disabled := amneziawgV6Inbound(3, "awg-3", "eth0", []model.Client{
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
disabled.Enable = false
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{vless, nodeHosted, disabled})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("wrong-protocol, node-hosted, and disabled inbounds must never get a v6 outbound, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_SkipsWhenRelayInboundNotCreated(t *testing.T) {
cfg := egressTestConfig()
// A pre-existing inbound already holds this AmneziaWG inbound's tag, so
// injectAmneziawgnetSocks (called first, matching production order)
// skips creating its relay SOCKS5 inbound entirely.
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("no v6 outbound should be created when the relay inbound itself never got created, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_OutboundTagCollisionSkipsThatPeerOnly(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
// Pre-seed a colliding outbound tag for a@x specifically.
collidingTag := amneziawgV6EgressTag(1, "a@x")
existing, _ := json.Marshal([]any{map[string]any{"tag": collidingTag, "protocol": "freedom"}})
cfg.OutboundConfigs = json_util.RawMessage(existing)
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
tagB := amneziawgV6EgressTag(1, "b@x")
foundB := false
countA := 0
for _, o := range outbounds {
if o.Tag == collidingTag {
countA++
}
if o.Tag == tagB {
foundB = true
}
}
if countA != 1 {
t.Fatalf("a@x's pre-existing outbound must not be duplicated, got %d copies", countA)
}
if !foundB {
t.Fatal("b@x must still get its own outbound despite a@x's tag collision")
}
}
func TestInjectAmneziawgV6Egress_BadOutboundsOrRoutingSkips(t *testing.T) {
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
cfg := egressTestConfig()
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != `{not json` {
t.Fatalf("unparsable outbounds must be left untouched, got %s", cfg.OutboundConfigs)
}
cfg2 := egressTestConfig()
cfg2.RouterConfig = json_util.RawMessage(`{not json`)
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
if string(cfg2.RouterConfig) != `{not json` {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg2.RouterConfig)
}
}
func TestInjectAmneziawgV6Egress_NoQualifyingPeerLeavesConfigUntouched(t *testing.T) {
cfg := egressTestConfig()
beforeOut, beforeRoute := string(cfg.OutboundConfigs), string(cfg.RouterConfig)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", nil) // no clients at all
injectAmneziawgV6Egress(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != beforeOut || string(cfg.RouterConfig) != beforeRoute {
t.Fatalf("an inbound with no qualifying peer must leave the config byte-identical")
}
}
func TestInjectAmneziawgV6Egress_RulesPrependedBeforeExistingRules(t *testing.T) {
cfg := egressTestConfig() // already has one rule, targeting "api"
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var routing v6EgressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 {
t.Fatalf("expected the new rule plus the pre-existing one, got %+v", routing.Rules)
}
if routing.Rules[0].OutboundTag != amneziawgV6EgressTag(1, "a@x") {
t.Fatalf("the new infra rule must be prepended ahead of the pre-existing rule, got %+v", routing.Rules[0])
}
if routing.Rules[1].OutboundTag != "api" {
t.Fatalf("the pre-existing rule must survive, got %+v", routing.Rules[1])
}
}
+12 -3
View File
@@ -25,19 +25,28 @@ func TestGetXrayConfigScale(t *testing.T) {
for _, shape := range shapes {
t.Run(fmt.Sprintf("N=%d_%s", n, shape.name), func(t *testing.T) {
ds := seedScaleDataset(t, n, shape.inbounds)
// Home the seeded inbounds on one agent node: the per-agent
// build is the only config build left in the lite master.
scaleNode := &model.Node{Name: "scale-node", Address: "10.0.0.7", Port: 2053, ApiToken: "t"}
if err := database.GetDB().Create(scaleNode).Error; err != nil {
t.Fatalf("create scale node: %v", err)
}
if err := database.GetDB().Model(&model.Inbound{}).Where("1 = 1").Update("node_id", scaleNode.Id).Error; err != nil {
t.Fatalf("home scale inbounds: %v", err)
}
const reps = 3
start := time.Now()
for range reps {
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(scaleNode.Id, "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
if len(cfg.InboundConfigs) < shape.inbounds {
t.Fatalf("config has %d inbounds, want >= %d", len(cfg.InboundConfigs), shape.inbounds)
}
}
t.Logf("N=%-7d shape=%-8s GetXrayConfig=%v/run",
t.Logf("N=%-7d shape=%-8s GetXrayConfigForAgent=%v/run",
n, shape.name, (time.Since(start) / reps).Round(time.Millisecond))
var ib model.Inbound
@@ -1,68 +0,0 @@
package service
import (
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestXrayLifecycleSnapshotDoesNotOverwriteNewerResult(t *testing.T) {
state := xrayLifecycle{}
first := xray.NewProcess(&xray.Config{})
second := xray.NewProcess(&xray.Config{})
state.replace(first)
state.storeResult(first, "first result")
process, result := state.snapshot()
if process != first || result != "first result" {
t.Fatalf("snapshot = (%p, %q), want (%p, %q)", process, result, first, "first result")
}
state.replace(second)
state.storeResult(first, "old result")
process, result = state.snapshot()
if process != second {
t.Fatal("snapshot returned the replaced process")
}
if result != "" {
t.Fatalf("snapshot result = %q, want empty", result)
}
}
func TestXrayLifecycleConcurrentStatusResultAndTrafficReads(t *testing.T) {
previousProcess, previousResult := xrayState.snapshot()
t.Cleanup(func() {
xrayState.mu.Lock()
xrayState.process = previousProcess
xrayState.result = previousResult
xrayState.mu.Unlock()
})
first := xray.NewProcess(&xray.Config{})
second := xray.NewProcess(&xray.Config{})
service := XrayService{}
var wg sync.WaitGroup
wg.Go(func() {
for range 200 {
xrayState.replace(first)
xrayState.replace(second)
}
})
for range 4 {
wg.Go(func() {
for range 200 {
_ = service.IsXrayRunning()
_ = service.GetXrayResult()
_, _, err := service.GetXrayTraffic()
if err == nil || err.Error() != "xray is not running" {
t.Errorf("GetXrayTraffic error = %v, want xray is not running", err)
}
}
})
}
wg.Wait()
}
-40
View File
@@ -1,40 +0,0 @@
package service
import (
"testing"
)
func TestRestartXrayRespectsManualStop(t *testing.T) {
setupSettingTestDB(t)
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
t.Fatalf("seed template: %v", err)
}
t.Cleanup(func() { isManuallyStopped.Store(false) })
isManuallyStopped.Store(true)
_ = (&XrayService{}).RestartXray(false)
if !isManuallyStopped.Load() {
t.Fatal("a non-forced restart cleared a deliberate manual stop and would revive xray")
}
}
// The local core never runs in this build, so ApplyPendingRestart no
// longer restarts anything and must not re-arm the flag for a retry loop:
// it consumes the pending flag and drops it.
func TestApplyPendingRestartDropsFlagWithoutLocalCore(t *testing.T) {
setupSettingTestDB(t)
t.Cleanup(func() {
isManuallyStopped.Store(false)
isNeedXrayRestart.Store(false)
})
isManuallyStopped.Store(false)
svc := &XrayService{}
svc.SetToNeedRestart()
svc.ApplyPendingRestart()
if isNeedXrayRestart.Load() {
t.Fatal("pending restart must be consumed and dropped; there is no local core to retry against")
}
}
+1 -3
View File
@@ -53,10 +53,8 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
return common.NewError("xray template config invalid: outbounds is not an array:", err)
}
// Lite master: no local core; the version gate takes "Unknown".
coreVersion := "Unknown"
if process := currentXrayProcess(); process != nil {
coreVersion = process.GetXrayVersion()
}
for _, outbound := range outbounds {
if err := xray.ValidateOutboundConfig(outbound); err != nil {
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
@@ -9,6 +9,23 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// wgTestNodeID returns the shared agent node the wireguard emission tests
// home their inbounds on, creating it on first use.
func wgTestNodeID(t *testing.T) int {
t.Helper()
db := database.GetDB()
var node model.Node
if err := db.Where("name = ?", "wg-test-node").First(&node).Error; err == nil {
return node.Id
}
node = model.Node{Name: "wg-test-node", Address: "10.0.0.6", Port: 2053, ApiToken: "t"}
if err := db.Create(&node).Error; err != nil {
t.Fatalf("create wg-test node: %v", err)
}
return node.Id
}
func wgTestSecretKey() string {
return base64.StdEncoding.EncodeToString(make([]byte, 32))
}
@@ -16,9 +33,9 @@ func wgTestSecretKey() string {
func wgInboundEmittedSettings(t *testing.T, tag string) map[string]any {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
cfg, err := svc.GetXrayConfigForAgent(wgTestNodeID(t), "", "")
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
for i := range cfg.InboundConfigs {
ic := cfg.InboundConfigs[i]
@@ -39,11 +56,13 @@ func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
nodeID := wgTestNodeID(t)
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: model.WireGuard,
NodeID: &nodeID,
Settings: `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420}`,
}
if err := db.Create(in).Error; err != nil {