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
+44 -24
View File
@@ -198,6 +198,7 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
return false, err
}
normalizeClientTrafficReset(&client)
client.ClientHostRuleIds = model.NormalizeClientHostRuleIds(client.ClientHostRuleIds)
if len(payload.InboundIds) == 0 {
return false, common.NewError("at least one inbound is required")
}
@@ -285,6 +286,14 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
// already existed, and a create the panel reported as failed must not.
return needRestart, fanoutErr
}
// nil means omitted and preserves the stored binding.
if client.ClientHostRuleIds != nil {
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("email = ?", client.Email).
Update("client_host_rule_ids", model.EncodeClientHostRuleIds(client.ClientHostRuleIds)).Error; err != nil {
return needRestart, err
}
}
return needRestart, s.setClientLimitHwidByEmail(client.Email, payload.LimitHwid)
}
@@ -713,6 +722,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
}
}
normalizeClientTrafficReset(&updated)
updated.ClientHostRuleIds = model.NormalizeClientHostRuleIds(updated.ClientHostRuleIds)
if updated.SubID == "" {
updated.SubID = existing.SubID
}
@@ -853,30 +863,31 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Updates(map[string]any{
"sub_id": merged.SubID,
"uuid": merged.UUID,
"password": merged.Password,
"auth": merged.Auth,
"secret": merged.Secret,
"flow": merged.Flow,
"security": merged.Security,
"wg_private_key": merged.PrivateKey,
"wg_public_key": merged.PublicKey,
"wg_allowed_ips": merged.AllowedIPs,
"wg_pre_shared_key": merged.PreSharedKey,
"wg_keep_alive": keepAliveLegacy,
"wg_keep_alive_range": keepAliveRange,
"limit_ip": merged.LimitIP,
"total_gb": merged.TotalGB,
"expiry_time": merged.ExpiryTime,
"tg_id": merged.TgID,
"comment": merged.Comment,
"reset": merged.Reset,
"reset_day": merged.ResetDay,
"reset_weekday": merged.ResetWeekday,
"reset_max": merged.ResetMax,
"traffic_reset": merged.TrafficReset,
"traffic_reset_day": merged.TrafficResetDay,
"sub_id": merged.SubID,
"uuid": merged.UUID,
"password": merged.Password,
"auth": merged.Auth,
"secret": merged.Secret,
"flow": merged.Flow,
"security": merged.Security,
"client_host_rule_ids": model.EncodeClientHostRuleIds(merged.ClientHostRuleIds),
"wg_private_key": merged.PrivateKey,
"wg_public_key": merged.PublicKey,
"wg_allowed_ips": merged.AllowedIPs,
"wg_pre_shared_key": merged.PreSharedKey,
"wg_keep_alive": keepAliveLegacy,
"wg_keep_alive_range": keepAliveRange,
"limit_ip": merged.LimitIP,
"total_gb": merged.TotalGB,
"expiry_time": merged.ExpiryTime,
"tg_id": merged.TgID,
"comment": merged.Comment,
"reset": merged.Reset,
"reset_day": merged.ResetDay,
"reset_weekday": merged.ResetWeekday,
"reset_max": merged.ResetMax,
"traffic_reset": merged.TrafficReset,
"traffic_reset_day": merged.TrafficResetDay,
}).Error; err != nil {
return needRestart, err
}
@@ -915,6 +926,15 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
return needRestart, err
}
// nil preserves the stored binding, empty clears it.
if updated.ClientHostRuleIds != nil {
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Update("client_host_rule_ids", model.EncodeClientHostRuleIds(updated.ClientHostRuleIds)).Error; err != nil {
return needRestart, err
}
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
UpdateColumn("enable", updated.Enable).Error; err != nil {
@@ -0,0 +1,64 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func setupHostBindingDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
}
func TestUpdate_OmittedPreservesAndEmptyClearsHostBindings(t *testing.T) {
setupHostBindingDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
rec := &model.ClientRecord{
Email: "hostbind@x",
UUID: "11111111-1111-1111-1111-111111111111",
SubID: "hostbind@x",
Enable: true,
ClientHostRuleIds: []int{7},
}
if err := database.GetDB().Create(rec).Error; err != nil {
t.Fatalf("create record: %v", err)
}
omitted := rec.ToClient()
omitted.ClientHostRuleIds = nil
omitted.Comment = "touch"
if _, err := svc.Update(inboundSvc, rec.Id, *omitted, 0); err != nil {
t.Fatalf("Update omitted: %v", err)
}
got, err := svc.GetByID(rec.Id)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.ClientHostRuleIds == nil || len(got.ClientHostRuleIds) != 1 || got.ClientHostRuleIds[0] != 7 {
t.Fatalf("omitted must preserve list, got %v", got.ClientHostRuleIds)
}
cleared := got.ToClient()
cleared.ClientHostRuleIds = []int{}
if _, err := svc.Update(inboundSvc, rec.Id, *cleared, 0); err != nil {
t.Fatalf("Update clear: %v", err)
}
after, err := svc.GetByID(rec.Id)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if after.ClientHostRuleIds == nil {
t.Fatalf("empty must clear to non-nil empty, got nil")
}
if len(after.ClientHostRuleIds) != 0 {
t.Fatalf("empty must clear list, got %v", after.ClientHostRuleIds)
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"reflect"
"strings"
"github.com/google/uuid"
@@ -42,6 +43,10 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
if incoming.Security != "" {
row.Security = incoming.Security
}
// nil preserves the stored binding, empty clears it via Update's write.
if ids := model.NormalizeClientHostRuleIds(incoming.ClientHostRuleIds); ids != nil {
row.ClientHostRuleIds = ids
}
if incoming.Reverse != "" {
row.Reverse = incoming.Reverse
}
@@ -169,6 +174,7 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
// ToRecord copies the raw email; store the trimmed key this function
// looks up by, or a padded email is inserted and never found again.
incoming.Email = email
incoming.ClientHostRuleIds = model.NormalizeClientHostRuleIds(incoming.ClientHostRuleIds)
row, ok := existing[email]
if !ok {
if _, dup := pending[email]; !dup {
@@ -187,7 +193,9 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
idByEmail[email] = row.Id
if *row == before {
// DeepEqual: ClientRecord now carries a slice (ClientHostRuleIds)
// and is no longer ==-comparable.
if reflect.DeepEqual(*row, before) {
continue
}
if err := tx.Save(row).Error; err != nil {
@@ -1,6 +1,7 @@
package service
import (
"reflect"
"testing"
"time"
@@ -76,7 +77,8 @@ func TestClientRenewalWriteValidation(t *testing.T) {
t.Fatal("rejected write changed inbound settings")
}
record, err = svc.GetRecordByEmail(nil, beforeRecord.Email)
if err != nil || *record != beforeRecord || readTraffic(t, database.GetDB(), beforeRecord.Email) != beforeTraffic {
// ClientRecord holds slices, so it is not comparable with !=.
if err != nil || !reflect.DeepEqual(*record, beforeRecord) || readTraffic(t, database.GetDB(), beforeRecord.Email) != beforeTraffic {
t.Fatalf("rejected write changed client/traffic: record=%+v error=%v", record, err)
}
for _, table := range []string{"inbounds", "clients", "client_traffics"} {
+79 -9
View File
@@ -202,6 +202,17 @@ func (s *HostService) GetHosts() ([]*entity.HostGroup, error) {
return groupHosts(hosts), nil
}
// GetRawHosts returns every host row with its numeric id, so callers that
// bind by host id (per-client host rule overrides) can list candidates.
func (s *HostService) GetRawHosts() ([]*model.Host, error) {
var hosts []*model.Host
err := database.GetDB().Order("sort_order asc, id asc").Find(&hosts).Error
if err != nil {
return nil, err
}
return hosts, nil
}
func (s *HostService) GetHostsByInbound(inboundId int) ([]*entity.HostGroup, error) {
var groupIds []string
if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", inboundId).Distinct().Pluck("group_id", &groupIds).Error; err != nil {
@@ -262,31 +273,90 @@ func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([]
if err := validateFinalMaskJSONSalamander(req.FinalMask); err != nil {
return nil, err
}
created := buildHostRows(groupId, req)
desired := buildHostRows(groupId, req)
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Count(&count).Error; err != nil {
var existing []*model.Host
if err := tx.Where("group_id = ?", groupId).Order("id asc").Find(&existing).Error; err != nil {
return err
}
if count == 0 {
if len(existing) == 0 {
return common.NewError("host not found")
}
if err := validateInboundsExist(tx, req.InboundIds); err != nil {
return err
}
if err := tx.Where("group_id = ?", groupId).Delete(&model.Host{}).Error; err != nil {
return err
// Exact (inbound, address, port) matches reuse ids so edits keep bindings; a second pass pairs
// same-inbound leftovers with edits, and rows still unclaimed are deleted.
type hostKey struct {
inbound int
addr string
port int
}
if len(created) > 0 {
return tx.Create(&created).Error
byKey := make(map[hostKey][]*model.Host)
for _, h := range existing {
k := hostKey{h.InboundId, h.Address, h.Port}
byKey[k] = append(byKey[k], h)
}
var toInsert, toUpdate []*model.Host
var unmatched []*model.Host
matched := make(map[int]struct{}, len(desired))
for _, d := range desired {
k := hostKey{d.InboundId, d.Address, d.Port}
if q := byKey[k]; len(q) > 0 {
d.Id = q[0].Id
d.CreatedAt = q[0].CreatedAt
byKey[k] = q[1:]
toUpdate = append(toUpdate, d)
matched[d.Id] = struct{}{}
} else {
unmatched = append(unmatched, d)
}
}
leftovers := make(map[int][]*model.Host)
for _, h := range existing {
if _, ok := matched[h.Id]; !ok {
leftovers[h.InboundId] = append(leftovers[h.InboundId], h)
}
}
for _, d := range unmatched {
if q := leftovers[d.InboundId]; len(q) > 0 {
d.Id = q[0].Id
d.CreatedAt = q[0].CreatedAt
leftovers[d.InboundId] = q[1:]
toUpdate = append(toUpdate, d)
matched[d.Id] = struct{}{}
} else {
toInsert = append(toInsert, d)
}
}
var toDelete []int
for _, h := range existing {
if _, ok := matched[h.Id]; !ok {
toDelete = append(toDelete, h.Id)
}
}
if len(toDelete) > 0 {
if err := tx.Where("id IN ?", toDelete).Delete(&model.Host{}).Error; err != nil {
return err
}
}
for _, u := range toUpdate {
if err := tx.Save(u).Error; err != nil {
return err
}
}
if len(toInsert) > 0 {
if err := tx.Create(&toInsert).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
return desired, nil
}
func (s *HostService) DeleteHostGroup(groupId string) error {
+98
View File
@@ -404,6 +404,104 @@ func TestUpdateHostGroup_ValidateBeforeDelete(t *testing.T) {
}
}
// An address/port edit must keep the row id: per-client host rule bindings
// reference ids, so a deleted row orphans every bound client.
func TestUpdateHostGroup_AddressEditKeepsHostId(t *testing.T) {
setupBulkDB(t)
svc := &HostService{}
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
created, err := svc.AddHostGroup(&entity.HostGroup{
InboundIds: []int{ib.Id},
Remark: "cdn",
Hosts: []string{"a.cdn.com"},
Port: 8443,
})
if err != nil {
t.Fatalf("AddHostGroup: %v", err)
}
wantId := created[0].Id
if _, err := svc.UpdateHostGroup(created[0].GroupId, &entity.HostGroup{
InboundIds: []int{ib.Id},
Remark: "cdn",
Hosts: []string{"a2.cdn.com"},
Port: 8443,
}); err != nil {
t.Fatalf("UpdateHostGroup: %v", err)
}
raws, err := svc.GetRawHosts()
if err != nil {
t.Fatalf("GetRawHosts: %v", err)
}
var row *model.Host
for _, h := range raws {
if h.GroupId == created[0].GroupId {
if row != nil {
t.Fatalf("expected 1 row after edit, got extra %v:%d", h.Address, h.Port)
}
row = h
}
}
if row == nil {
t.Fatalf("host row vanished after edit")
}
if row.Id != wantId {
t.Fatalf("address edit replaced row id %d with %d; bound clients lose all links", wantId, row.Id)
}
if row.Address != "a2.cdn.com" || row.Port != 8443 {
t.Fatalf("row = %s:%d, want a2.cdn.com:8443", row.Address, row.Port)
}
}
// A host moved to another inbound has no leftover row to pair with on the
// target: the old row must be deleted and the new one inserted.
func TestUpdateHostGroup_MovedRowsDeletedAndInserted(t *testing.T) {
setupBulkDB(t)
svc := &HostService{}
ib1 := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
ib2 := mkInbound(t, 8443, model.VLESS, `{"clients":[]}`)
created, err := svc.AddHostGroup(&entity.HostGroup{
InboundIds: []int{ib1.Id},
Remark: "move",
Hosts: []string{"old.example.com"},
Port: 8080,
})
if err != nil {
t.Fatalf("AddHostGroup: %v", err)
}
if _, err := svc.UpdateHostGroup(created[0].GroupId, &entity.HostGroup{
InboundIds: []int{ib2.Id},
Remark: "move",
Hosts: []string{"new.example.com"},
Port: 9090,
}); err != nil {
t.Fatalf("UpdateHostGroup: %v", err)
}
raws, err := svc.GetRawHosts()
if err != nil {
t.Fatalf("GetRawHosts: %v", err)
}
rowsOn := func(inboundId int) []*model.Host {
var out []*model.Host
for _, h := range raws {
if h.GroupId == created[0].GroupId && h.InboundId == inboundId {
out = append(out, h)
}
}
return out
}
if rows := rowsOn(ib1.Id); len(rows) != 0 {
t.Fatalf("old inbound kept %d rows, want 0", len(rows))
}
rows := rowsOn(ib2.Id)
if len(rows) != 1 || rows[0].Address != "new.example.com" || rows[0].Port != 9090 {
t.Fatalf("moved row = %+v, want 1x new.example.com:9090", rows)
}
}
// Host fields are copied by hand in buildHostRows and newHostGroup; a missed
// copy on either side silently blanks the value on the next edit-and-save.
func TestHostGroup_CipherSuitesRoundTrip(t *testing.T) {