mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
6a0c983c89
The set that kept a refused node claim's warning to one line was keyed by the reported email and never forgot one. Some refused emails, such as a padded one or a new client holding a foreign subId, match no row on the master, so a node rotating them grew the set without bound; a snapshot made only of padded entries also skipped the warning. Each node now keeps just the emails its latest snapshot was refused, both return paths log, and the logged email is cut to 64 characters.
386 lines
16 KiB
Go
386 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func readClientUUID(t *testing.T, db *gorm.DB, email string) string {
|
|
t.Helper()
|
|
var row model.ClientRecord
|
|
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
|
t.Fatalf("read client %q: %v", email, err)
|
|
}
|
|
return row.UUID
|
|
}
|
|
|
|
// Emails are globally unique, so a node reporting one that belongs to a master
|
|
// inbound would otherwise overwrite its credentials and lock the real user out.
|
|
func TestNodeCannotClaimClientOfAnotherInbound(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
clientSvc := &ClientService{}
|
|
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
|
|
const (
|
|
victim = "victim@x"
|
|
nodeLocal = "nodelocal@x"
|
|
legitUUID = "11111111-1111-1111-1111-111111111111"
|
|
attackUUID = "99999999-9999-9999-9999-999999999999"
|
|
)
|
|
|
|
master := &model.Inbound{
|
|
UserId: 1, Tag: "master-in", Enable: true, Port: 40001, Protocol: model.VLESS,
|
|
Settings: fmt.Sprintf(`{"clients":[{"email":%q,"id":%q,"enable":true}]}`, victim, legitUUID),
|
|
}
|
|
if err := db.Create(master).Error; err != nil {
|
|
t.Fatalf("create master inbound: %v", err)
|
|
}
|
|
masterClients, err := svc.GetClients(master)
|
|
if err != nil {
|
|
t.Fatalf("parse master clients: %v", err)
|
|
}
|
|
if err := clientSvc.SyncInbound(db, master.Id, masterClients); err != nil {
|
|
t.Fatalf("attach master client: %v", err)
|
|
}
|
|
if got := readClientUUID(t, db, victim); got != legitUUID {
|
|
t.Fatalf("setup: master client uuid = %q, want %q", got, legitUUID)
|
|
}
|
|
|
|
createNodeInbound(t, db, 1, "n1-in", 41001)
|
|
hostile := fmt.Sprintf(`{"clients":[{"email":%q,"id":%q,"enable":true},{"email":%q,"id":%q,"enable":true}]}`,
|
|
victim, attackUUID, nodeLocal, attackUUID)
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", hostile,
|
|
xray.ClientTraffic{Email: victim, Enable: true},
|
|
xray.ClientTraffic{Email: nodeLocal, Enable: true})
|
|
|
|
if got := readClientUUID(t, db, victim); got != legitUUID {
|
|
t.Fatalf("node overwrote a master client's uuid: got %q, want %q", got, legitUUID)
|
|
}
|
|
nodeAttached, err := clientSvc.ListForInbound(db, nodeInboundID(t, db, "n1-in"))
|
|
if err != nil {
|
|
t.Fatalf("list node clients: %v", err)
|
|
}
|
|
for _, c := range nodeAttached {
|
|
if c.Email == victim {
|
|
t.Fatal("node inbound adopted a client that belongs to a master inbound")
|
|
}
|
|
}
|
|
|
|
// The node's own client must still be adopted, or the guard has replaced one
|
|
// bug with a worse one.
|
|
if got := readClientUUID(t, db, nodeLocal); got != attackUUID {
|
|
t.Fatalf("node-owned client not adopted: uuid = %q, want %q", got, attackUUID)
|
|
}
|
|
}
|
|
|
|
func nodeInboundID(t *testing.T, db *gorm.DB, tag string) int {
|
|
t.Helper()
|
|
var ib model.Inbound
|
|
if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
|
|
t.Fatalf("read inbound %q: %v", tag, err)
|
|
}
|
|
return ib.Id
|
|
}
|
|
|
|
func seedMasterInbound(t *testing.T, db *gorm.DB, tag string, port int, clientsJSON string) *model.Inbound {
|
|
t.Helper()
|
|
ib := &model.Inbound{
|
|
UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS,
|
|
Settings: `{"clients":[` + clientsJSON + `]}`,
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create inbound %q: %v", tag, err)
|
|
}
|
|
clients, err := (&InboundService{}).GetClients(ib)
|
|
if err != nil {
|
|
t.Fatalf("parse clients of %q: %v", tag, err)
|
|
}
|
|
if err := (&ClientService{}).SyncInbound(db, ib.Id, clients); err != nil {
|
|
t.Fatalf("attach clients of %q: %v", tag, err)
|
|
}
|
|
return ib
|
|
}
|
|
|
|
func readClientRecord(t *testing.T, db *gorm.DB, email string) model.ClientRecord {
|
|
t.Helper()
|
|
var row model.ClientRecord
|
|
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
|
t.Fatalf("read client %q: %v", email, err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
func countClientsWhere(t *testing.T, db *gorm.DB, query string, args ...any) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := db.Model(&model.ClientRecord{}).Where(query, args...).Count(&n).Error; err != nil {
|
|
t.Fatalf("count clients: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func storedInboundSettings(t *testing.T, db *gorm.DB, tag string) string {
|
|
t.Helper()
|
|
var ib model.Inbound
|
|
if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
|
|
t.Fatalf("read inbound %q: %v", tag, err)
|
|
}
|
|
return ib.Settings
|
|
}
|
|
|
|
func nodeHostsClient(t *testing.T, db *gorm.DB, tag, email string) bool {
|
|
t.Helper()
|
|
attached, err := (&ClientService{}).ListForInbound(db, nodeInboundID(t, db, tag))
|
|
if err != nil {
|
|
t.Fatalf("list clients of %q: %v", tag, err)
|
|
}
|
|
for _, c := range attached {
|
|
if c.Email == email {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// A client shared with a master inbound is rendered into the master's own Xray, so the
|
|
// node's copy must not move its credential, reverse tag or subscription id.
|
|
func TestNodeCannotRewriteClientSharedWithMaster(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
|
|
const legitUUID = "11111111-1111-4111-8111-111111111111"
|
|
shared := fmt.Sprintf(`{"email":"shared@x","id":%q,"subId":"shared-sub","enable":true}`, legitUUID)
|
|
seedMasterInbound(t, db, "master-in", 40001, shared)
|
|
nid := 1
|
|
nodeIb := &model.Inbound{
|
|
UserId: 1, Tag: "n1-in", Enable: true, Port: 41001, Protocol: model.VLESS, NodeID: &nid,
|
|
Settings: `{"clients":[` + shared + `]}`,
|
|
}
|
|
if err := db.Create(nodeIb).Error; err != nil {
|
|
t.Fatalf("create node inbound: %v", err)
|
|
}
|
|
clients, _ := svc.GetClients(nodeIb)
|
|
if err := (&ClientService{}).SyncInbound(db, nodeIb.Id, clients); err != nil {
|
|
t.Fatalf("attach node clients: %v", err)
|
|
}
|
|
|
|
syncNodeWithSettings(t, svc, 1, "n1-in",
|
|
`{"clients":[{"email":"shared@x","id":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz","subId":"picked-by-node","reverse":{"tag":"direct"},"enable":true}]}`,
|
|
xray.ClientTraffic{Email: "shared@x", Enable: true})
|
|
|
|
row := readClientRecord(t, db, "shared@x")
|
|
if row.UUID != legitUUID || row.SubID != "shared-sub" || row.Reverse != "" {
|
|
t.Fatalf("node rewrote a shared client: uuid=%q subId=%q reverse=%q", row.UUID, row.SubID, row.Reverse)
|
|
}
|
|
stored := storedInboundSettings(t, db, "n1-in")
|
|
if strings.Contains(stored, "zzzz") || strings.Contains(stored, "picked-by-node") || strings.Contains(stored, "direct") {
|
|
t.Fatalf("node inbound stored the node's values for a shared client: %s", stored)
|
|
}
|
|
if !nodeHostsClient(t, db, "n1-in", "shared@x") {
|
|
t.Fatal("the shared client lost its node link")
|
|
}
|
|
}
|
|
|
|
// A refused claim must leave no trace: the stored settings are re-synced later by
|
|
// auto-renew, and its stats would land on the victim's traffic row.
|
|
func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
master := seedMasterInbound(t, db, "master-in", 40001, `{"email":"victim@x","id":"11111111-1111-4111-8111-111111111111","subId":"victim-sub","enable":true}`)
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: master.Id, Email: "victim@x", Enable: true}).Error; err != nil {
|
|
t.Fatalf("seed victim traffic: %v", err)
|
|
}
|
|
createNodeInbound(t, db, 1, "n1-in", 41001)
|
|
|
|
hostile := `{"clients":[{"email":"victim@x","id":"","subId":"picked-by-node","enable":true},{"email":"victim@x ","subId":"padded","enable":true},{"email":"VICTIM@X","subId":"cased","enable":true},{"email":"own@x","enable":true}]}`
|
|
for _, up := range []int64{100, 600} {
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", hostile,
|
|
xray.ClientTraffic{Email: "victim@x", Up: up, Enable: true},
|
|
xray.ClientTraffic{Email: "own@x", Enable: true})
|
|
}
|
|
|
|
if stored := storedInboundSettings(t, db, "n1-in"); strings.Contains(strings.ToLower(stored), "victim") {
|
|
t.Fatalf("refused claim stored on the node inbound: %s", stored)
|
|
}
|
|
if row := readClientRecord(t, db, "victim@x"); row.SubID != "victim-sub" {
|
|
t.Fatalf("victim subId = %q, want victim-sub", row.SubID)
|
|
}
|
|
if n := countClientsWhere(t, db, "LOWER(email) LIKE ?", "victim%"); n != 1 {
|
|
t.Fatalf("a padded or case-variant claim created a look-alike record: %d victim rows", n)
|
|
}
|
|
var counted int64
|
|
db.Model(xray.ClientTraffic{}).Where("LOWER(email) LIKE ? AND up > 0", "victim%").Count(&counted)
|
|
if counted != 0 {
|
|
t.Fatalf("refused claim's stats reached a victim traffic row: %d rows", counted)
|
|
}
|
|
if !nodeHostsClient(t, db, "n1-in", "own@x") {
|
|
t.Fatal("the node's own client was not adopted")
|
|
}
|
|
}
|
|
|
|
// A record with no inbound is the admin's (detached, or its inbound deleted), not a
|
|
// node's; only a node whose own merge orphaned it may take it back.
|
|
func TestNodeCannotAdoptDetachedClient(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
seedNodeRow(t, db, &model.Node{Id: 2, Name: "n2", Address: "127.0.0.2", Port: 2096, ApiToken: "tok2", Enable: true})
|
|
master := seedMasterInbound(t, db, "master-in", 40001,
|
|
`{"email":"detached@x","id":"11111111-1111-4111-8111-111111111111","subId":"d-sub","enable":true},`+
|
|
`{"email":"mine@x","id":"22222222-2222-4222-8222-222222222222","enable":true},`+
|
|
`{"email":"theirs@x","id":"33333333-3333-4333-8333-333333333333","enable":true}`)
|
|
if err := db.Exec("DELETE FROM client_inbounds WHERE inbound_id = ?", master.Id).Error; err != nil {
|
|
t.Fatalf("detach: %v", err)
|
|
}
|
|
for email, node := range map[string]int{"mine@x": 1, "theirs@x": 2} {
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).
|
|
Updates(map[string]any{"sync_orphaned_at": 1, "sync_orphaned_by": node}).Error; err != nil {
|
|
t.Fatalf("mark %s orphaned: %v", email, err)
|
|
}
|
|
}
|
|
createNodeInbound(t, db, 1, "n1-in", 41001)
|
|
|
|
syncNodeWithSettings(t, svc, 1, "n1-in",
|
|
`{"clients":[{"email":"detached@x","subId":"picked","enable":true},{"email":"mine@x","enable":true},{"email":"theirs@x","subId":"picked2","enable":true}]}`)
|
|
|
|
if row := readClientRecord(t, db, "detached@x"); row.SubID != "d-sub" || nodeHostsClient(t, db, "n1-in", "detached@x") {
|
|
t.Fatalf("node adopted a detached client: subId=%q", row.SubID)
|
|
}
|
|
if nodeHostsClient(t, db, "n1-in", "theirs@x") {
|
|
t.Fatal("node adopted a client another node's merge orphaned")
|
|
}
|
|
if !nodeHostsClient(t, db, "n1-in", "mine@x") {
|
|
t.Fatal("node could not take back a client its own merge orphaned")
|
|
}
|
|
}
|
|
|
|
// The subscription server selects by subId alone, so a node's client must not join
|
|
// the subscription of a customer it does not host.
|
|
func TestNodeCannotClaimForeignSubID(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
seedMasterInbound(t, db, "master-in", 40001, `{"email":"customer@x","id":"11111111-1111-4111-8111-111111111111","subId":"cust-sub","enable":true}`)
|
|
createNodeInbound(t, db, 1, "n1-in", 41001)
|
|
|
|
syncNodeWithSettings(t, svc, 1, "n1-in",
|
|
`{"clients":[{"email":"node-own@x","id":"66666666-6666-4666-8666-666666666666","subId":"cust-sub","enable":true}]}`)
|
|
|
|
if n := countClientsWhere(t, db, "sub_id = ?", "cust-sub"); n != 1 {
|
|
t.Fatalf("node joined a customer's subscription: %d records hold cust-sub", n)
|
|
}
|
|
}
|
|
|
|
// A shared client's tunnel keys are rendered into the master's own WireGuard inbound, so a
|
|
// node tunnel must not own them even when its inbound id is the lower one.
|
|
func TestNodeTunnelDoesNotOwnSharedPeerKeys(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
|
|
const masterKey = "mJ3F8Yb3Kx6cdx6Sx4WbJ3qQDXB5cN3d0pH6m4kq0Ug="
|
|
const nodeKey = "nK9Pq1Zr6s8Lw2Vb4Xc7Dm0Fh5Jt3Ny9Ru1Ea6Go2Is="
|
|
entry := func(key, ip string) string {
|
|
return fmt.Sprintf(`{"email":"shared@x","publicKey":%q,"allowedIPs":[%q],"enable":true}`, key, ip)
|
|
}
|
|
nid := 1
|
|
nodeIb := &model.Inbound{
|
|
UserId: 1, Tag: "n1-wg", Enable: true, Port: 41002, Protocol: model.WireGuard, NodeID: &nid,
|
|
Settings: `{"clients":[` + entry(masterKey, "10.1.0.2/32") + `]}`,
|
|
}
|
|
masterIb := &model.Inbound{
|
|
UserId: 1, Tag: "master-wg", Enable: true, Port: 40002, Protocol: model.WireGuard,
|
|
Settings: `{"clients":[` + entry(masterKey, "10.0.0.2/32") + `]}`,
|
|
}
|
|
for _, ib := range []*model.Inbound{nodeIb, masterIb} {
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("create %q: %v", ib.Tag, err)
|
|
}
|
|
clients, err := svc.GetClients(ib)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", ib.Tag, err)
|
|
}
|
|
if err := (&ClientService{}).SyncInbound(db, ib.Id, clients); err != nil {
|
|
t.Fatalf("attach %q: %v", ib.Tag, err)
|
|
}
|
|
}
|
|
|
|
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
|
|
Tag: "n1-wg", Enable: true, Port: 41002, Protocol: model.WireGuard,
|
|
Settings: `{"clients":[` + entry(nodeKey, "10.1.0.2/32") + `]}`,
|
|
ClientStats: []xray.ClientTraffic{{Email: "shared@x", Enable: true}},
|
|
}}}
|
|
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
if !nodeHostsClient(t, db, "n1-wg", "shared@x") {
|
|
t.Fatal("the shared client lost its node tunnel link")
|
|
}
|
|
if got := readClientRecord(t, db, "shared@x").PublicKey; got != masterKey {
|
|
t.Fatalf("node tunnel replaced a shared client's public key: %q", got)
|
|
}
|
|
}
|
|
|
|
// An honest node's copy of a shared client differs only in timestamps and comment; treating
|
|
// that as a refused claim would mark the node dirty and re-push it on every tick.
|
|
func TestNodeSharedClientInAgreementIsNotRewritten(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
|
|
shared := `{"email":"shared@x","id":"11111111-1111-4111-8111-111111111111","subId":"shared-sub","comment":"vip","enable":true}`
|
|
seedMasterInbound(t, db, "master-in", 40001, shared)
|
|
nid := 1
|
|
nodeIb := &model.Inbound{
|
|
UserId: 1, Tag: "n1-in", Enable: true, Port: 41001, Protocol: model.VLESS, NodeID: &nid,
|
|
Settings: `{"clients":[` + shared + `]}`,
|
|
}
|
|
if err := db.Create(nodeIb).Error; err != nil {
|
|
t.Fatalf("create node inbound: %v", err)
|
|
}
|
|
clients, _ := svc.GetClients(nodeIb)
|
|
if err := (&ClientService{}).SyncInbound(db, nodeIb.Id, clients); err != nil {
|
|
t.Fatalf("attach node clients: %v", err)
|
|
}
|
|
|
|
syncNodeWithSettings(t, svc, 1, "n1-in",
|
|
`{"clients":[{"email":"shared@x","id":"11111111-1111-4111-8111-111111111111","subId":"shared-sub","enable":true,"created_at":1,"updated_at":2}]}`,
|
|
xray.ClientTraffic{Email: "shared@x", Enable: true})
|
|
|
|
var node model.Node
|
|
if err := db.Select("config_dirty").Where("id = ?", 1).First(&node).Error; err != nil {
|
|
t.Fatalf("read node: %v", err)
|
|
}
|
|
if node.ConfigDirty {
|
|
t.Fatal("a node in agreement with the master was marked dirty for a re-push")
|
|
}
|
|
}
|
|
|
|
// The refused emails are the node's choice, so the state that keeps their warning to one
|
|
// line must not keep every name a node ever sent.
|
|
func TestNodeRefusedClaimLogStateIsBounded(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
createNodeInbound(t, db, 1, "n1-in", 41001)
|
|
|
|
for i := range 50 {
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", fmt.Sprintf(`{"clients":[{"email":"rotating-%d@x ","enable":true}]}`, i))
|
|
}
|
|
held, _ := reportedRefusedClaims.Load(1)
|
|
if got := len(held.(map[string]struct{})); got != 1 {
|
|
t.Fatalf("refused-claim log state holds %d emails for one node, want 1", got)
|
|
}
|
|
}
|