mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 15:55:02 +03:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user