精简版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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+20
-1188
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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user