mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
1d373cf67e
The migration backfills sync_orphaned_by = 0 for every client a node merge had already soft-orphaned, and the gate refused a detached record unless its mark named the reporting node. A client inside its orphan grace window at upgrade time was therefore refused when its node reported it again, and ReapSyncOrphans then deleted it on the master while the node kept serving it. A mark that names no node predates the column and may be taken back by any node, as before.
467 lines
20 KiB
Go
467 lines
20 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 not be stored (auto-renew re-syncs the settings later) nor move the
|
|
// client's lifecycle; usage under an email the master also serves still adds up across panels.
|
|
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: false, ExpiryTime: 1},
|
|
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)
|
|
}
|
|
if got := readTraffic(t, db, "victim@x"); got.Up != 500 || !got.Enable || got.ExpiryTime != 0 {
|
|
t.Fatalf("victim traffic up=%d enable=%v expiry=%d, want the node's usage (500) and the master's lifecycle (true, 0)", got.Up, got.Enable, got.ExpiryTime)
|
|
}
|
|
var lookalike int64
|
|
db.Model(xray.ClientTraffic{}).Where("email <> ? AND LOWER(email) LIKE ?", "victim@x", "victim%").Count(&lookalike)
|
|
if lookalike != 0 {
|
|
t.Fatalf("a padded claim's stats created %d look-alike traffic rows", lookalike)
|
|
}
|
|
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},`+
|
|
`{"email":"legacy@x","id":"44444444-4444-4444-8444-444444444444","enable":true}`)
|
|
if err := db.Exec("DELETE FROM client_inbounds WHERE inbound_id = ?", master.Id).Error; err != nil {
|
|
t.Fatalf("detach: %v", err)
|
|
}
|
|
// legacy@x was marked before sync_orphaned_by existed, so its mark names no node.
|
|
for email, node := range map[string]int{"mine@x": 1, "theirs@x": 2, "legacy@x": 0} {
|
|
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},{"email":"legacy@x","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")
|
|
}
|
|
if !nodeHostsClient(t, db, "n1-in", "legacy@x") {
|
|
t.Fatal("a client orphaned before the upgrade could not be taken back and would be reaped")
|
|
}
|
|
}
|
|
|
|
// 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; rewriting
|
|
// it to the record's JSON would churn the stored settings 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})
|
|
|
|
if stored := storedInboundSettings(t, db, "n1-in"); !strings.Contains(stored, `"updated_at":2`) {
|
|
t.Fatalf("a node in agreement with the master had its entry rewritten: %s", stored)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// A subscription renders every client holding its subId, so a node creating many clients
|
|
// under one subId multiplied the cost of a single request without bound.
|
|
func TestNodeClientsCannotShareASubID(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)
|
|
|
|
syncNodeWithSettings(t, svc, 1, "n1-in",
|
|
`{"clients":[{"email":"a@x","subId":"one-sub","enable":true},{"email":"b@x","subId":"one-sub","enable":true},{"email":"c@x","subId":"c-sub","enable":true}]}`)
|
|
syncNodeWithSettings(t, svc, 1, "n1-in",
|
|
`{"clients":[{"email":"a@x","subId":"one-sub","enable":true},{"email":"c@x","subId":"c-sub","enable":true},{"email":"d@x","subId":"one-sub","enable":true}]}`)
|
|
|
|
if n := countClientsWhere(t, db, "sub_id = ?", "one-sub"); n != 1 {
|
|
t.Fatalf("%d records hold one subId, want 1", n)
|
|
}
|
|
if !nodeHostsClient(t, db, "n1-in", "c@x") {
|
|
t.Fatal("a client with its own subId was not adopted")
|
|
}
|
|
}
|
|
|
|
// Every reported stream, sniffing and remark is rendered once per client and external
|
|
// proxy of a subscription, so an oversized one let a node make one request cost gigabytes.
|
|
func TestNodeOversizeInboundIsNotTakenIn(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)
|
|
|
|
proxies := make([]string, maxNodeExternalProxies+1)
|
|
for i := range proxies {
|
|
proxies[i] = fmt.Sprintf(`{"dest":"h%d.example","port":443,"forceTls":"same"}`, i)
|
|
}
|
|
stream := `{"network":"tcp","externalProxy":[` + strings.Join(proxies, ",") + `]}`
|
|
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
|
|
{Tag: "n1-in", Enable: true, Port: 41001, Protocol: model.VLESS, Settings: `{"clients":[]}`, StreamSettings: stream},
|
|
{Tag: "n1-new", Enable: true, Port: 41002, Protocol: model.VLESS, Settings: `{"clients":[]}`, Remark: strings.Repeat("r", maxNodeRemarkBytes+1)},
|
|
}}
|
|
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
|
|
var stored model.Inbound
|
|
if err := db.Where("tag = ?", "n1-in").First(&stored).Error; err != nil {
|
|
t.Fatalf("the oversized report removed the existing inbound: %v", err)
|
|
}
|
|
if strings.Contains(stored.StreamSettings, "externalProxy") {
|
|
t.Fatal("an inbound with too many external proxies was taken in")
|
|
}
|
|
var created int64
|
|
db.Model(&model.Inbound{}).Where("tag = ?", "n1-new").Count(&created)
|
|
if created != 0 {
|
|
t.Fatal("an inbound with an oversized remark was adopted")
|
|
}
|
|
}
|
|
|
|
// SQLite's LOWER folds ASCII only, so a lookup by lowered email missed a stored non-ASCII
|
|
// capital and the node's claim on that master-only client went through untouched.
|
|
func TestNodeCannotClaimNonASCIICapitalEmail(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":"Иван@x","id":"11111111-1111-4111-8111-111111111111","subId":"ivan-sub","enable":true}`)
|
|
createNodeInbound(t, db, 1, "n1-in", 41001)
|
|
|
|
syncNodeWithSettings(t, svc, 1, "n1-in",
|
|
`{"clients":[{"email":"Иван@x","subId":"picked-by-node","enable":true},{"email":"иван@x","subId":"lower","enable":true}]}`)
|
|
|
|
if row := readClientRecord(t, db, "Иван@x"); row.SubID != "ivan-sub" {
|
|
t.Fatalf("node rewrote a master-only non-ASCII client: subId=%q", row.SubID)
|
|
}
|
|
if n := countClientsWhere(t, db, "email = ?", "иван@x"); n != 0 {
|
|
t.Fatal("a case variant of a non-ASCII email created a look-alike record")
|
|
}
|
|
}
|