fix(node): bound what one node inbound adds to a subscription

A subscription renders an inbound's stream and remark once per client
and per external proxy, and every client holding its subId, all values
a node chooses: an inbound with a megabyte stream and thousands of
external proxies, or many clients sharing one subId, made a single
unauthenticated request cost gigabytes of memory and minutes of CPU.

A reported inbound whose streamSettings exceeds 64 KiB, sniffing 16 KiB
or remark 1 KiB, or that lists more than 64 external proxies, is no
longer taken in (an existing one keeps its stored values), and a node
can no longer give a client a subId another client already holds,
including another one in the same snapshot.
This commit is contained in:
MHSanaei
2026-11-01 01:11:00 +01:00
parent f9f21fd8a4
commit fc8efc0745
3 changed files with 123 additions and 9 deletions
@@ -383,3 +383,59 @@ func TestNodeRefusedClaimLogStateIsBounded(t *testing.T) {
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")
}
}