mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +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:
@@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeClientRecordRenewalModes(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
@@ -28,3 +31,27 @@ func TestMergeClientRecordRenewalModes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A node snapshot must not undo a newer binding, an omitted list must keep the stored one,
|
||||
// and an explicit empty list clears it.
|
||||
func TestMergeClientRecordHostBindings(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
existing, incoming ClientRecord
|
||||
want []int
|
||||
}{
|
||||
{"newer list replaces", ClientRecord{ClientHostRuleIds: []int{2}, UpdatedAt: 1}, ClientRecord{ClientHostRuleIds: []int{5, 7}, UpdatedAt: 2}, []int{5, 7}},
|
||||
{"older list cannot divert a bound list", ClientRecord{ClientHostRuleIds: []int{2}, UpdatedAt: 2}, ClientRecord{ClientHostRuleIds: []int{9}, UpdatedAt: 1}, []int{2}},
|
||||
{"older list fills unset bindings", ClientRecord{UpdatedAt: 2}, ClientRecord{ClientHostRuleIds: []int{9}, UpdatedAt: 1}, []int{9}},
|
||||
{"omitted list preserves", ClientRecord{ClientHostRuleIds: []int{2}, UpdatedAt: 1}, ClientRecord{UpdatedAt: 2}, []int{2}},
|
||||
{"newer empty list clears", ClientRecord{ClientHostRuleIds: []int{2}, UpdatedAt: 1}, ClientRecord{ClientHostRuleIds: []int{}, UpdatedAt: 2}, []int{}},
|
||||
{"merged list is normalized", ClientRecord{UpdatedAt: 1}, ClientRecord{ClientHostRuleIds: []int{3, 0, 3}, UpdatedAt: 2}, []int{3}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
MergeClientRecord(&tt.existing, &tt.incoming)
|
||||
if got := tt.existing.ClientHostRuleIds; got == nil || !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("ClientHostRuleIds = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,6 +938,9 @@ type Client struct {
|
||||
PrivateKey string `json:"privateKey,omitempty"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
AllowedIPs []string `json:"allowedIPs,omitempty"`
|
||||
// ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty
|
||||
// clears, and bindings with no usable host leave that inbound out of the subscription.
|
||||
ClientHostRuleIds []int `json:"clientHostRuleIds,omitempty" example:"[1,2]"`
|
||||
// AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound
|
||||
// basis, keyed by inbound id. Lets one identity attached to both
|
||||
// WireGuard and AmneziaWG carry two genuinely different addresses in a
|
||||
@@ -972,41 +975,42 @@ type Client struct {
|
||||
}
|
||||
|
||||
type ClientRecord struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||
KeepAlive KeepAliveValue `json:"keepAlive" gorm:"-"` // Numeric values or AmneziaWG ranges.
|
||||
KeepAliveLegacy int `json:"-" gorm:"column:wg_keep_alive;default:0"`
|
||||
KeepAliveRange KeepAliveValue `json:"-" gorm:"column:wg_keep_alive_range;type:text;default:''"`
|
||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||
Secret string `json:"secret" gorm:"column:secret"`
|
||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"`
|
||||
ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"`
|
||||
ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"`
|
||||
TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"`
|
||||
TrafficResetDay int `json:"trafficResetDay" gorm:"column:traffic_reset_day;default:1"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
ClientHostRuleIds []int `json:"clientHostRuleIds,omitempty" gorm:"serializer:json;column:client_host_rule_ids" example:"[1,2]"`
|
||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||
KeepAlive KeepAliveValue `json:"keepAlive" gorm:"-"` // Numeric values or AmneziaWG ranges.
|
||||
KeepAliveLegacy int `json:"-" gorm:"column:wg_keep_alive;default:0"`
|
||||
KeepAliveRange KeepAliveValue `json:"-" gorm:"column:wg_keep_alive_range;type:text;default:''"`
|
||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||
Secret string `json:"secret" gorm:"column:secret"`
|
||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"`
|
||||
ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"`
|
||||
ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"`
|
||||
TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"`
|
||||
TrafficResetDay int `json:"trafficResetDay" gorm:"column:traffic_reset_day;default:1"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
|
||||
// deleting; orphans from any other cause stay at zero and are never reaped.
|
||||
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
|
||||
@@ -1254,28 +1258,29 @@ func keepAliveValue(value *KeepAliveValue) KeepAliveValue {
|
||||
|
||||
func (c *Client) ToRecord() *ClientRecord {
|
||||
rec := &ClientRecord{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
UUID: c.ID,
|
||||
Password: c.Password,
|
||||
Auth: c.Auth,
|
||||
Flow: c.Flow,
|
||||
Security: c.Security,
|
||||
LimitIP: c.LimitIP,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
Enable: c.Enable,
|
||||
TgID: c.TgID,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
Reset: c.Reset,
|
||||
ResetDay: c.ResetDay,
|
||||
ResetWeekday: c.ResetWeekday,
|
||||
ResetMax: c.ResetMax,
|
||||
TrafficReset: c.TrafficReset,
|
||||
TrafficResetDay: c.TrafficResetDay,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
UUID: c.ID,
|
||||
Password: c.Password,
|
||||
Auth: c.Auth,
|
||||
Flow: c.Flow,
|
||||
Security: c.Security,
|
||||
ClientHostRuleIds: CloneClientHostRuleIds(c.ClientHostRuleIds),
|
||||
LimitIP: c.LimitIP,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
Enable: c.Enable,
|
||||
TgID: c.TgID,
|
||||
Group: c.Group,
|
||||
Comment: c.Comment,
|
||||
Reset: c.Reset,
|
||||
ResetDay: c.ResetDay,
|
||||
ResetWeekday: c.ResetWeekday,
|
||||
ResetMax: c.ResetMax,
|
||||
TrafficReset: c.TrafficReset,
|
||||
TrafficResetDay: c.TrafficResetDay,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
|
||||
PrivateKey: c.PrivateKey,
|
||||
PublicKey: c.PublicKey,
|
||||
@@ -1313,28 +1318,29 @@ func splitWireguardAllowedIPs(csv string) []string {
|
||||
|
||||
func (r *ClientRecord) ToClient() *Client {
|
||||
c := &Client{
|
||||
ID: r.UUID,
|
||||
Email: r.Email,
|
||||
SubID: r.SubID,
|
||||
Password: r.Password,
|
||||
Auth: r.Auth,
|
||||
Flow: r.Flow,
|
||||
Security: r.Security,
|
||||
LimitIP: r.LimitIP,
|
||||
TotalGB: r.TotalGB,
|
||||
ExpiryTime: r.ExpiryTime,
|
||||
Enable: r.Enable,
|
||||
TgID: r.TgID,
|
||||
Group: r.Group,
|
||||
Comment: r.Comment,
|
||||
Reset: r.Reset,
|
||||
ResetDay: r.ResetDay,
|
||||
ResetWeekday: r.ResetWeekday,
|
||||
ResetMax: r.ResetMax,
|
||||
TrafficReset: r.TrafficReset,
|
||||
TrafficResetDay: r.TrafficResetDay,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
ID: r.UUID,
|
||||
Email: r.Email,
|
||||
SubID: r.SubID,
|
||||
Password: r.Password,
|
||||
Auth: r.Auth,
|
||||
Flow: r.Flow,
|
||||
Security: r.Security,
|
||||
ClientHostRuleIds: CloneClientHostRuleIds(r.ClientHostRuleIds),
|
||||
LimitIP: r.LimitIP,
|
||||
TotalGB: r.TotalGB,
|
||||
ExpiryTime: r.ExpiryTime,
|
||||
Enable: r.Enable,
|
||||
TgID: r.TgID,
|
||||
Group: r.Group,
|
||||
Comment: r.Comment,
|
||||
Reset: r.Reset,
|
||||
ResetDay: r.ResetDay,
|
||||
ResetWeekday: r.ResetWeekday,
|
||||
ResetMax: r.ResetMax,
|
||||
TrafficReset: r.TrafficReset,
|
||||
TrafficResetDay: r.TrafficResetDay,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
|
||||
PrivateKey: r.PrivateKey,
|
||||
PublicKey: r.PublicKey,
|
||||
@@ -1361,6 +1367,69 @@ type ClientMergeConflict struct {
|
||||
Kept any
|
||||
}
|
||||
|
||||
// CloneClientHostRuleIds copies a host binding list, preserving the
|
||||
// nil vs empty distinction so an explicit clear is not confused with unset.
|
||||
func CloneClientHostRuleIds(in []int) []int {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeClientHostRuleIds drops non-positive and repeated ids, keeping order; nil stays nil,
|
||||
// since nil means "leave the stored binding alone" and empty clears it.
|
||||
func NormalizeClientHostRuleIds(in []int) []int {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(in))
|
||||
seen := make(map[int]struct{}, len(in))
|
||||
for _, id := range in {
|
||||
if _, dup := seen[id]; id <= 0 || dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// equalClientHostRuleIds compares binding lists as sets of positive ids.
|
||||
func equalClientHostRuleIds(a, b []int) bool {
|
||||
na, nb := NormalizeClientHostRuleIds(a), NormalizeClientHostRuleIds(b)
|
||||
if len(na) != len(nb) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int]int, len(na))
|
||||
for _, id := range na {
|
||||
seen[id]++
|
||||
}
|
||||
for _, id := range nb {
|
||||
seen[id]--
|
||||
if seen[id] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EncodeClientHostRuleIds marshals ids into the JSON text stored by raw
|
||||
// column writes (Update/Updates), which bypass GORM's serializer.
|
||||
func EncodeClientHostRuleIds(ids []int) string {
|
||||
b, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return "null"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// EffectiveClientHostRuleIds returns the client's bound host rule ids, deduplicated, in order.
|
||||
func EffectiveClientHostRuleIds(c Client) []int {
|
||||
return NormalizeClientHostRuleIds(c.ClientHostRuleIds)
|
||||
}
|
||||
|
||||
type OutboundSubscription struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
@@ -1608,6 +1677,13 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
||||
existing.Group = incoming.Group
|
||||
}
|
||||
}
|
||||
if !equalClientHostRuleIds(existing.ClientHostRuleIds, incoming.ClientHostRuleIds) && incoming.ClientHostRuleIds != nil {
|
||||
if incomingNewer || existing.ClientHostRuleIds == nil {
|
||||
keep("clientHostRuleIds", existing.ClientHostRuleIds, incoming.ClientHostRuleIds, incoming.ClientHostRuleIds)
|
||||
existing.ClientHostRuleIds = CloneClientHostRuleIds(incoming.ClientHostRuleIds)
|
||||
}
|
||||
}
|
||||
existing.ClientHostRuleIds = NormalizeClientHostRuleIds(existing.ClientHostRuleIds)
|
||||
if existing.Enable != incoming.Enable {
|
||||
if incoming.Enable {
|
||||
if !existing.Enable {
|
||||
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -270,3 +271,26 @@ func TestGenXrayInboundConfig_OmitsInboundXmuxButDbRowUnchanged(t *testing.T) {
|
||||
t.Fatal("inbound row streamSettings must still carry xmux for subscriptions")
|
||||
}
|
||||
}
|
||||
|
||||
// nil must survive (it means "leave the stored binding alone"), while an empty or
|
||||
// all-invalid list must come back empty so it clears.
|
||||
func TestNormalizeClientHostRuleIds(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
in []int
|
||||
want []int
|
||||
}{
|
||||
{"nil preserves", nil, nil},
|
||||
{"empty clears", []int{}, []int{}},
|
||||
{"dedupe keeps order", []int{3, 2, 3, 2}, []int{3, 2}},
|
||||
{"non-positive dropped", []int{0, -1, 2, 0, 3}, []int{2, 3}},
|
||||
{"all non-positive clears", []int{0, -2}, []int{}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := NormalizeClientHostRuleIds(tt.in)
|
||||
if (got == nil) != (tt.want == nil) || !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("NormalizeClientHostRuleIds(%v) = %#v, want %#v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user