feat(clients): bind host rules to individual clients in subscriptions (#6620)

Host rules applied per inbound only, so every client of an inbound got the same
subscription endpoints (#6618). A client can now be bound to one or more host
rules, and its raw, JSON and Clash subscriptions render only those hosts, in
either subscription order; an unbound client is unchanged. A binding whose hosts
are deleted, disabled or excluded from the format fails closed: the client gets
no links there but stays in traffic accounting. Host edits keep row ids, so
bindings survive them; nil leaves a stored binding alone and an empty list
clears it.
This commit is contained in:
Nethmina Yasas
2026-10-24 16:03:02 +05:30
committed by GitHub
parent d103c0c012
commit bc1b750e00
47 changed files with 1940 additions and 168 deletions
+32
View File
@@ -208,6 +208,9 @@ func renderedHostAddresses(t *testing.T, format string, base *SubService) []stri
} `json:"settings"`
} `json:"outbounds"`
}
if body == "" {
break
}
if err := json.Unmarshal([]byte(body), &docs); err != nil {
t.Fatal(err)
}
@@ -277,3 +280,32 @@ func TestSub_HostOrderExpandsCIDRHosts(t *testing.T) {
}
}
}
// A client bound to host rules must get only those hosts in the hosts-first order too, on every
// format, and a binding whose hosts are all gone must render nothing rather than every host.
func TestSub_HostOrderRespectsClientHostBindings(t *testing.T) {
seedSubDB(t)
setHostSortMode(t, "hosts-first")
ib := seedSubInbound(t, "order-sub", "bound", 46920, 0, wsTLSStream)
seedHost(t, &model.Host{GroupId: "a", InboundId: ib.Id, SortOrder: 0, Remark: "A", Address: "a.example.com"})
b := seedHost(t, &model.Host{GroupId: "b", InboundId: ib.Id, SortOrder: 1, Remark: "B", Address: "b.example.com"})
bind := func(ids ...int) {
t.Helper()
if err := database.GetDB().Model(&model.ClientRecord{}).Where("sub_id = ?", "order-sub").
Update("client_host_rule_ids", model.EncodeClientHostRuleIds(ids)).Error; err != nil {
t.Fatal(err)
}
}
bind(b.Id)
for _, format := range []string{"raw", "clash", "json"} {
if got := renderedHostAddresses(t, format, NewSubService("")); !reflect.DeepEqual(got, []string{"b.example.com"}) {
t.Fatalf("%s addresses = %v, want only the bound host", format, got)
}
}
bind(999999)
for _, format := range []string{"raw", "clash", "json"} {
if got := renderedHostAddresses(t, format, NewSubService("")); len(got) != 0 {
t.Fatalf("%s addresses = %v, want none for a binding whose hosts are gone", format, got)
}
}
}