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
+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 {