mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15: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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
||||
var proxies []map[string]any
|
||||
var ordered []orderedSubEntry[func() []map[string]any]
|
||||
var hasInactiveExternal bool
|
||||
var hasBoundClosed bool
|
||||
var hasEnabledClient bool
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
@@ -73,9 +74,11 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
var baseEps []map[string]any
|
||||
baseStream := inbound.StreamSettings
|
||||
if !subReq.hostsFirst {
|
||||
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
if baseEps = subReq.hostEndpoints(inbound, "clash"); len(baseEps) > 0 {
|
||||
injectExternalProxy(inbound, baseEps)
|
||||
}
|
||||
}
|
||||
for _, client := range clients {
|
||||
@@ -84,13 +87,26 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
||||
}
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
if subReq.hostsFirst {
|
||||
for _, variant := range subReq.subscriptionHostVariants(inbound, "clash") {
|
||||
variants, closed := subReq.clientHostVariants(inbound, client, "clash")
|
||||
hasBoundClosed = hasBoundClosed || closed
|
||||
for _, variant := range variants {
|
||||
render := func() []map[string]any { return s.getProxies(subReq, variant.inbound(subReq, inbound), client, host) }
|
||||
ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render})
|
||||
}
|
||||
} else {
|
||||
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
|
||||
continue
|
||||
}
|
||||
effective := inbound
|
||||
if effectiveEps, bound := subReq.clientHostEndpoints(inbound, client, "clash", baseEps); bound {
|
||||
if len(effectiveEps) == 0 {
|
||||
hasBoundClosed = true
|
||||
continue
|
||||
}
|
||||
clone := *inbound
|
||||
clone.StreamSettings = baseStream
|
||||
injectExternalProxy(&clone, effectiveEps)
|
||||
effective = &clone
|
||||
}
|
||||
proxies = append(proxies, s.getProxies(subReq, effective, client, host)...)
|
||||
}
|
||||
}
|
||||
if subReq.hostsFirst {
|
||||
@@ -120,7 +136,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
|
||||
}
|
||||
}
|
||||
|
||||
if len(proxies) == 0 && !hasInactiveExternal {
|
||||
if len(proxies) == 0 && !hasInactiveExternal && !hasBoundClosed {
|
||||
return "", "", nil
|
||||
}
|
||||
if legacy {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,86 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// hostBinding is how a client's host binding applies to one inbound.
|
||||
type hostBinding int
|
||||
|
||||
const (
|
||||
hostBindingNone hostBinding = iota // nothing bound here: the inbound's own hosts apply
|
||||
hostBindingClosed // bound, but no bound host survives: render nothing
|
||||
hostBindingBound // render only the returned bound hosts of this inbound
|
||||
)
|
||||
|
||||
// clientHostBinding resolves the client's bound host rules against one inbound. A binding whose
|
||||
// every id is gone fails closed; a missing id beside a live one never silences other inbounds.
|
||||
func (s *SubService) clientHostBinding(inbound *model.Inbound, client model.Client) (hostBinding, []*model.Host) {
|
||||
ids := model.EffectiveClientHostRuleIds(client)
|
||||
if len(ids) == 0 || database.GetDB() == nil {
|
||||
return hostBindingNone, nil
|
||||
}
|
||||
var rows []model.Host
|
||||
if err := database.GetDB().Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
logger.Warning("SubService - clientHostBinding:", err)
|
||||
return hostBindingClosed, nil
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return hostBindingClosed, nil
|
||||
}
|
||||
byId := make(map[int]*model.Host, len(rows))
|
||||
for i := range rows {
|
||||
byId[rows[i].Id] = &rows[i]
|
||||
}
|
||||
var here []*model.Host
|
||||
for _, id := range ids {
|
||||
if h, ok := byId[id]; ok && h.InboundId == inbound.Id {
|
||||
here = append(here, h)
|
||||
}
|
||||
}
|
||||
if len(here) == 0 {
|
||||
return hostBindingNone, nil
|
||||
}
|
||||
return hostBindingBound, here
|
||||
}
|
||||
|
||||
// clientHostEndpoints returns the endpoints of the client's usable bound hosts on this inbound, or
|
||||
// baseEps when nothing is bound here; (nil, true) fails closed, and the caller still accounts the client.
|
||||
func (s *SubService) clientHostEndpoints(inbound *model.Inbound, client model.Client, format string, baseEps []map[string]any) ([]map[string]any, bool) {
|
||||
state, hosts := s.clientHostBinding(inbound, client)
|
||||
switch state {
|
||||
case hostBindingNone:
|
||||
return baseEps, false
|
||||
case hostBindingClosed:
|
||||
return nil, true
|
||||
}
|
||||
defaultDest := s.resolveInboundAddress(inbound)
|
||||
var eps []map[string]any
|
||||
for _, h := range hosts {
|
||||
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||
continue
|
||||
}
|
||||
eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...)
|
||||
}
|
||||
return eps, true
|
||||
}
|
||||
|
||||
// clientHostVariants narrows the hosts-first variants of an inbound to the client's bound hosts;
|
||||
// closed reports a binding that leaves this client nothing to render on the inbound.
|
||||
func (s *SubService) clientHostVariants(inbound *model.Inbound, client model.Client, format string) (variants []subscriptionHostVariant, closed bool) {
|
||||
all := s.subscriptionHostVariants(inbound, format)
|
||||
state, hosts := s.clientHostBinding(inbound, client)
|
||||
switch state {
|
||||
case hostBindingNone:
|
||||
return all, false
|
||||
case hostBindingClosed:
|
||||
return nil, true
|
||||
}
|
||||
for _, v := range all {
|
||||
if v.host != nil && slices.ContainsFunc(hosts, func(h *model.Host) bool { return h.Id == v.host.Id }) {
|
||||
variants = append(variants, v)
|
||||
}
|
||||
}
|
||||
return variants, len(variants) == 0
|
||||
}
|
||||
|
||||
// hostEndpoints loads an inbound's enabled hosts for the given subscription
|
||||
// format ("raw"|"json"|"clash") and returns them as externalProxy-shaped maps so
|
||||
// the existing per-format renderers can fan out one link/proxy per host. Returns
|
||||
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
"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"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func seedSubDB(t *testing.T) {
|
||||
@@ -999,3 +1002,649 @@ func TestSub_HostCIDR_ClashAndJson(t *testing.T) {
|
||||
t.Errorf("JSON config should contain numbered host outbounds: %s", jsonDoc)
|
||||
}
|
||||
}
|
||||
|
||||
// seedSubClient adds a second client sharing subId to an already-seeded
|
||||
// inbound, so per-client host bindings can be compared within one sub.
|
||||
func seedSubClient(t *testing.T, ib *model.Inbound, subId, email string) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
uuid := "22222222-3333-4444-8555-" + fmt.Sprintf("%012d", ib.Id)
|
||||
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client %s: %v", email, err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
const tcpTLSStream = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`
|
||||
|
||||
// A bound host with blank address and port must use the inbound's own, even after an earlier
|
||||
// client rewrote the shared inbound's Listen/Port while rendering its endpoints.
|
||||
func TestSub_JsonBoundHostBlankAddressIgnoresEarlierClient(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "mv", 4491, 1, tcpTLSStream)
|
||||
// Host A has a real address and sorts last, so the unbound client (lower
|
||||
// id, rendered first) leaves the shared inbound pointing at a.cdn.com:443.
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls"})
|
||||
// Host B is override-only: blank address and port, so the bound client's
|
||||
// endpoint must inherit the inbound's own address and port.
|
||||
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "B", Security: "tls", Sni: "b.sni"})
|
||||
seedSubClient(t, ib, "s1", "bound@e")
|
||||
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", "bound@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostB.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rule: %v", err)
|
||||
}
|
||||
|
||||
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
type config struct {
|
||||
Remarks string `json:"remarks"`
|
||||
Outbounds []struct {
|
||||
Tag string `json:"tag"`
|
||||
Settings struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
} `json:"settings"`
|
||||
} `json:"outbounds"`
|
||||
}
|
||||
var docs []config
|
||||
if err := json.Unmarshal([]byte(out), &docs); err != nil {
|
||||
t.Fatalf("unmarshal json sub: %v\n%s", err, out)
|
||||
}
|
||||
var got struct {
|
||||
address string
|
||||
port int
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if !strings.Contains(doc.Remarks, "bound@e") {
|
||||
continue
|
||||
}
|
||||
for _, ob := range doc.Outbounds {
|
||||
if ob.Tag == "proxy" {
|
||||
got.address, got.port = ob.Settings.Address, ob.Settings.Port
|
||||
}
|
||||
}
|
||||
}
|
||||
if got.address == "" {
|
||||
t.Fatalf("no proxy outbound found for the bound client:\n%s", out)
|
||||
}
|
||||
if got.address != ib.Listen || got.port != ib.Port {
|
||||
t.Fatalf("override-only host resolved to %s:%d, want the inbound's own %s:%d",
|
||||
got.address, got.port, ib.Listen, ib.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// A client bound to one host renders only that host's link with its SNI,
|
||||
// while an unbound client on the same inbound still fans out to every host.
|
||||
func TestSub_ClientHostRule_SingleBinding(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "cb", 443, 1, tcpTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
||||
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
||||
seedSubClient(t, ib, "s1", "other@e")
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "cb@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostB.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rule: %v", err)
|
||||
}
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("bound client renders 1 link, unbound renders 2, got %d: %v", len(parts), parts)
|
||||
}
|
||||
bound := 0
|
||||
for _, l := range parts {
|
||||
if strings.Contains(l, "b.cdn.com:443") {
|
||||
bound++
|
||||
if !strings.Contains(l, "sni=b.sni") {
|
||||
t.Fatalf("bound link must carry the bound host SNI: %s", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bound != 2 {
|
||||
t.Fatalf("want 2 links via b.cdn.com (1 bound + 1 unbound fan-out), got %d: %v", bound, parts)
|
||||
}
|
||||
aLinks := 0
|
||||
for _, l := range parts {
|
||||
if strings.Contains(l, "a.cdn.com:443") {
|
||||
aLinks++
|
||||
if strings.Contains(l, "sni=b.sni") {
|
||||
t.Fatalf("unbound host-A link must not carry host-B SNI: %s", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
if aLinks != 1 {
|
||||
t.Fatalf("want exactly 1 link via a.cdn.com (unbound fan-out), got %d: %v", aLinks, parts)
|
||||
}
|
||||
}
|
||||
|
||||
// A client bound to several host rules renders one link per bound host,
|
||||
// and only those hosts — a third inbound host stays out of its links.
|
||||
func TestSub_ClientHostRule_MultipleBindings(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "mb", 444, 1, tcpTLSStream)
|
||||
hostA := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
||||
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 3, Remark: "C", Address: "c.cdn.com", Port: 443, Security: "tls", Sni: "c.sni"})
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "mb@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostA.Id, hostB.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rules: %v", err)
|
||||
}
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
parts := strings.Split(joined, "\n")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("two bound hosts must yield two links, got %d: %v", len(parts), parts)
|
||||
}
|
||||
if !strings.Contains(joined, "sni=a.sni") || !strings.Contains(joined, "sni=b.sni") {
|
||||
t.Fatalf("each bound host SNI must appear: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "c.cdn.com") {
|
||||
t.Fatalf("unbound host must not render for the bound client: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted binding is skipped; with no valid binding left the client fails closed with zero
|
||||
// links (no fallback, no dummy link) but stays in traffic accounting.
|
||||
func TestSub_ClientHostRule_DeletedFallsBack(t *testing.T) {
|
||||
t.Run("deleted and valid binding renders only the valid host", func(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "fb", 445, 1, tcpTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
||||
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "fb@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{9999, hostB.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rules: %v", err)
|
||||
}
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("deleted id skipped, want only the valid host's link, got %d: %v", len(parts), parts)
|
||||
}
|
||||
if !strings.Contains(parts[0], "b.cdn.com:443") || !strings.Contains(parts[0], "sni=b.sni") {
|
||||
t.Fatalf("valid bound host must render with its SNI: %s", parts[0])
|
||||
}
|
||||
if strings.Contains(parts[0], "a.cdn.com") {
|
||||
t.Fatalf("unbound host must not render for the bound client: %s", parts[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deleted-only binding fails closed with zero links", func(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "fb", 445, 1, tcpTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "fb@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{9999})).Error; err != nil {
|
||||
t.Fatalf("bind host rule: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: "fb@e", Up: 100, Down: 200, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != 0 {
|
||||
t.Fatalf("deleted-only binding must emit zero links, got %d: %v", len(links), links)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
if strings.Contains(joined, "a.cdn.com") || strings.Contains(joined, "b.cdn.com") {
|
||||
t.Fatalf("fail-closed must not fall back to inbound hosts: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "socks://") || strings.Contains(joined, "127.0.0.1") {
|
||||
t.Fatalf("fail-closed must not emit a dummy link: %s", joined)
|
||||
}
|
||||
if traffic.Up != 100 || traffic.Down != 200 {
|
||||
t.Fatalf("usage = up %d/down %d, want 100/200: closed client stays in accounting", traffic.Up, traffic.Down)
|
||||
}
|
||||
if !traffic.Enable {
|
||||
t.Fatalf("traffic.Enable = false, want true: closed client stays enabled")
|
||||
}
|
||||
})
|
||||
|
||||
// A deleted host id must not silence an inbound without the client's other bound hosts:
|
||||
// relevance comes from rows that still exist, fail-closed only when every bound id is gone.
|
||||
t.Run("deleted id on one inbound does not silence an unrelated inbound", func(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ibA := seedSubInbound(t, "s1", "xa", 4471, 1, tcpTLSStream)
|
||||
ibB := seedSubInbound(t, "s1", "xb", 4472, 2, tcpTLSStream)
|
||||
|
||||
// Put the client on both inbounds under one email.
|
||||
db := database.GetDB()
|
||||
var client model.ClientRecord
|
||||
if err := db.Where("email = ?", "xb@e").First(&client).Error; err != nil {
|
||||
t.Fatalf("load client xb@e: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ibA.Id}).Error; err != nil {
|
||||
t.Fatalf("link client to inbound A: %v", err)
|
||||
}
|
||||
// Host A lives on ibA; bind it plus a deleted id 9999.
|
||||
hostA := seedHost(t, &model.Host{InboundId: ibA.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "xb@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostA.Id, 9999})).Error; err != nil {
|
||||
t.Fatalf("bind host rules: %v", err)
|
||||
}
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
// Inbound B has no bound host and none of the ids map to it, so its
|
||||
// legacy fallback link must survive; inbound A still renders host A.
|
||||
if !strings.Contains(joined, "a.cdn.com:443") {
|
||||
t.Fatalf("inbound A's bound host must render: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, fmt.Sprintf("203.0.113.5:%d", ibB.Port)) {
|
||||
t.Fatalf("deleted id must not silence inbound B's fallback link: %s", joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A client bound to a disabled host fails closed on every format (no links, outbounds or proxies)
|
||||
// but stays in traffic accounting, so quota and expiry headers stay correct.
|
||||
func TestSub_ClientHostRule_DisabledFailsClosed(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "dis", 447, 1, tcpTLSStream)
|
||||
host := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "D", Address: "dis.cdn.com", Port: 443, Security: "tls", Sni: "dis.sni"})
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "dis@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{host.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rules: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.Host{}).Where("id = ?", host.Id).Update("is_disabled", true).Error; err != nil {
|
||||
t.Fatalf("disable host: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: "dis@e", Up: 100, Down: 200, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
t.Run("raw", func(t *testing.T) {
|
||||
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != 0 {
|
||||
t.Fatalf("disabled binding must emit zero links, got %d: %v", len(links), links)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
if strings.Contains(joined, "dis.cdn.com") || strings.Contains(joined, "203.0.113.5") {
|
||||
t.Fatalf("fail-closed must not fall back: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "socks://") || strings.Contains(joined, "127.0.0.1") {
|
||||
t.Fatalf("fail-closed must not emit a dummy link: %s", joined)
|
||||
}
|
||||
if traffic.Up != 100 || traffic.Down != 200 {
|
||||
t.Fatalf("usage = up %d/down %d, want 100/200", traffic.Up, traffic.Down)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json", func(t *testing.T) {
|
||||
out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "" {
|
||||
t.Fatalf("disabled binding must emit zero outbounds, got:\n%s", out)
|
||||
}
|
||||
if !strings.HasPrefix(header, "upload=100; download=200; ") {
|
||||
t.Fatalf("header = %q, want usage prefix %q", header, "upload=100; download=200; ")
|
||||
}
|
||||
if strings.Contains(out, "dis.cdn.com") {
|
||||
t.Fatalf("fail-closed json must not contain the disabled host:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clash", func(t *testing.T) {
|
||||
out, header, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "" {
|
||||
t.Fatalf("disabled binding must emit zero proxies, got:\n%s", out)
|
||||
}
|
||||
if !strings.HasPrefix(header, "upload=100; download=200; ") {
|
||||
t.Fatalf("header = %q, want usage prefix %q", header, "upload=100; download=200; ")
|
||||
}
|
||||
if strings.Contains(out, "dis.cdn.com") {
|
||||
t.Fatalf("fail-closed clash must not contain the disabled host:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("panel links", func(t *testing.T) {
|
||||
if got := NewLinkProvider().LinksForClient("req.example.com", ib, "dis@e"); len(got) != 0 {
|
||||
t.Fatalf("LinksForClient must emit zero links, got %v", got)
|
||||
}
|
||||
svc := NewSubService("")
|
||||
svc.PrepareForRequest("req.example.com")
|
||||
if got := svc.inboundLinks(ib); len(got) != 0 {
|
||||
t.Fatalf("inboundLinks must emit zero links, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A host excluded from one format fails closed only there: that format emits nothing but keeps
|
||||
// accounting, while the other formats still render the host.
|
||||
func TestSub_ClientHostRule_FormatExcludedFailsClosed(t *testing.T) {
|
||||
formats := []string{"raw", "json", "clash"}
|
||||
for _, format := range formats {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "exc", 448, 1, tcpTLSStream)
|
||||
host := seedHost(t, &model.Host{
|
||||
InboundId: ib.Id, SortOrder: 1, Remark: "X", Address: "exc.cdn.com", Port: 443,
|
||||
Security: "tls", Sni: "exc.sni", ExcludeFromSubTypes: []string{format},
|
||||
})
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "exc@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{host.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rules: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: "exc@e", Up: 300, Down: 400, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "raw":
|
||||
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != 0 {
|
||||
t.Fatalf("format-excluded binding must emit zero links, got %d: %v", len(links), links)
|
||||
}
|
||||
if traffic.Up != 300 || traffic.Down != 400 {
|
||||
t.Fatalf("usage = up %d/down %d, want 300/400", traffic.Up, traffic.Down)
|
||||
}
|
||||
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "exc.cdn.com") {
|
||||
t.Fatalf("json (not excluded) must still render the host:\n%s", out)
|
||||
}
|
||||
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if !strings.Contains(yaml, "exc.cdn.com") {
|
||||
t.Fatalf("clash (not excluded) must still render the host:\n%s", yaml)
|
||||
}
|
||||
case "json":
|
||||
out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "" {
|
||||
t.Fatalf("format-excluded binding must emit zero outbounds, got:\n%s", out)
|
||||
}
|
||||
if !strings.HasPrefix(header, "upload=300; download=400; ") {
|
||||
t.Fatalf("header = %q, want usage prefix", header)
|
||||
}
|
||||
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != 1 || !strings.Contains(links[0], "exc.cdn.com") {
|
||||
t.Fatalf("raw (not excluded) must still render the host: %v", links)
|
||||
}
|
||||
if traffic.Up != 300 || traffic.Down != 400 {
|
||||
t.Fatalf("usage = up %d/down %d, want 300/400", traffic.Up, traffic.Down)
|
||||
}
|
||||
case "clash":
|
||||
yaml, header, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(yaml) != "" {
|
||||
t.Fatalf("format-excluded binding must emit zero proxies, got:\n%s", yaml)
|
||||
}
|
||||
if !strings.HasPrefix(header, "upload=300; download=400; ") {
|
||||
t.Fatalf("header = %q, want usage prefix", header)
|
||||
}
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != 1 || !strings.Contains(links[0], "exc.cdn.com") {
|
||||
t.Fatalf("raw (not excluded) must still render the host: %v", links)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The bound host's SNI wins in the JSON subscription too, and unbound
|
||||
// inbound hosts stay out of the bound client's configs.
|
||||
func TestSub_ClientHostRule_JSONSNI(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "jb", 446, 1, tcpTLSStream)
|
||||
host := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 443, Security: "tls", Sni: "bound.sni"})
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "O", Address: "o.cdn.com", Port: 443, Security: "tls", Sni: "other.sni"})
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "jb@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{host.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rules: %v", err)
|
||||
}
|
||||
|
||||
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "bound.sni") {
|
||||
t.Fatalf("json config must carry the bound host SNI:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "j.cdn.com") {
|
||||
t.Fatalf("json config must dial the bound host address:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "other.sni") || strings.Contains(out, "o.cdn.com") {
|
||||
t.Fatalf("json config must not contain the unbound host:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A host bound on inbound A must not leak onto inbound B for a client on
|
||||
// both: A renders only the bound host, B keeps its own endpoint.
|
||||
func TestSub_ClientHostRule_ScopedToInbound(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
db := database.GetDB()
|
||||
ibA := seedSubInbound(t, "s1", "scope-a", 443, 1, tcpTLSStream)
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", "scope-a@e").First(&rec).Error; err != nil {
|
||||
t.Fatalf("load client record: %v", err)
|
||||
}
|
||||
ibB := &model.Inbound{
|
||||
UserId: 1, Tag: "scope-b", Enable: true, Listen: "203.0.113.5", Port: 8443,
|
||||
Protocol: model.VLESS, Remark: "scope-b", Settings: ibA.Settings,
|
||||
StreamSettings: tcpTLSStream, SubSortIndex: 2,
|
||||
}
|
||||
if err := db.Create(ibB).Error; err != nil {
|
||||
t.Fatalf("seed inbound B: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ibB.Id}).Error; err != nil {
|
||||
t.Fatalf("attach client to inbound B: %v", err)
|
||||
}
|
||||
hostA := seedHost(t, &model.Host{InboundId: ibA.Id, SortOrder: 1, Remark: "HA", Address: "host-a.cdn.com", Port: 9443, Security: "tls", Sni: "host-a.sni"})
|
||||
seedHost(t, &model.Host{InboundId: ibA.Id, SortOrder: 2, Remark: "HA2", Address: "host-a2.cdn.com", Port: 9443, Security: "tls", Sni: "host-a2.sni"})
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostA.Id})).Error; err != nil {
|
||||
t.Fatalf("bind host rule: %v", err)
|
||||
}
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("one link per inbound, got %d: %v", len(parts), parts)
|
||||
}
|
||||
joined := strings.Join(parts, "\n")
|
||||
if got := strings.Count(joined, "host-a.cdn.com"); got != 1 {
|
||||
t.Fatalf("host-a address appears %d times, want once (inbound A only):\n%s", got, joined)
|
||||
}
|
||||
if got := strings.Count(joined, "host-a.sni"); got != 1 {
|
||||
t.Fatalf("host-a SNI appears %d times, want once (inbound A only):\n%s", got, joined)
|
||||
}
|
||||
if strings.Contains(joined, "host-a2.cdn.com") || strings.Contains(joined, "host-a2.sni") {
|
||||
t.Fatalf("unbound host-a2 must not render for the bound client:\n%s", joined)
|
||||
}
|
||||
plain := ""
|
||||
for _, l := range parts {
|
||||
if strings.Contains(l, "203.0.113.5:8443") {
|
||||
plain = l
|
||||
}
|
||||
}
|
||||
if plain == "" {
|
||||
t.Fatalf("inbound B must keep its own endpoint 203.0.113.5:8443:\n%s", joined)
|
||||
}
|
||||
if strings.Contains(plain, "host-a.cdn.com") || strings.Contains(plain, "host-a.sni") {
|
||||
t.Fatalf("inbound B link leaked host A:\n%s", plain)
|
||||
}
|
||||
|
||||
jsOut, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if got := strings.Count(jsOut, "host-a.cdn.com"); got != 1 {
|
||||
t.Fatalf("json host-a address appears %d times, want once:\n%s", got, jsOut)
|
||||
}
|
||||
if got := strings.Count(jsOut, "host-a.sni"); got != 1 {
|
||||
t.Fatalf("json host-a SNI appears %d times, want once:\n%s", got, jsOut)
|
||||
}
|
||||
if strings.Contains(jsOut, "host-a2.cdn.com") || strings.Contains(jsOut, "host-a2.sni") {
|
||||
t.Fatalf("json must not contain the unbound host-a2:\n%s", jsOut)
|
||||
}
|
||||
if !strings.Contains(jsOut, "203.0.113.5") {
|
||||
t.Fatalf("json must keep inbound B on its own address:\n%s", jsOut)
|
||||
}
|
||||
|
||||
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if got := strings.Count(yaml, "host-a.cdn.com"); got != 1 {
|
||||
t.Fatalf("clash host-a server appears %d times, want once:\n%s", got, yaml)
|
||||
}
|
||||
if strings.Contains(yaml, "host-a2.cdn.com") {
|
||||
t.Fatalf("clash must not contain the unbound host-a2:\n%s", yaml)
|
||||
}
|
||||
if !strings.Contains(yaml, "203.0.113.5") {
|
||||
t.Fatalf("clash must keep inbound B on its own server:\n%s", yaml)
|
||||
}
|
||||
}
|
||||
|
||||
// Binding survives a host-rule edit: UpdateHostGroup keeps row ids, so
|
||||
// raw/JSON/Clash still render only the bound host with the new SNI.
|
||||
func TestSub_ClientHostRule_SurvivesGroupEdit(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "edit", 4451, 1, tcpTLSStream)
|
||||
hostSvc := &service.HostService{}
|
||||
created, err := hostSvc.AddHostGroup(&entity.HostGroup{
|
||||
InboundIds: []int{ib.Id},
|
||||
Hosts: []string{"a.cdn.com:443", "b.cdn.com:443"},
|
||||
Remark: "orig",
|
||||
Port: 443,
|
||||
Security: "tls",
|
||||
Sni: "old.sni",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddHostGroup: %v", err)
|
||||
}
|
||||
if len(created) != 2 {
|
||||
t.Fatalf("created = %d rows, want 2", len(created))
|
||||
}
|
||||
boundId := 0
|
||||
for _, h := range created {
|
||||
if h.Address == "b.cdn.com" {
|
||||
boundId = h.Id
|
||||
}
|
||||
}
|
||||
if boundId == 0 {
|
||||
t.Fatalf("bound host row not found in %v", created)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "edit@e").
|
||||
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{boundId})).Error; err != nil {
|
||||
t.Fatalf("bind host rule: %v", err)
|
||||
}
|
||||
|
||||
g, err := hostSvc.GetHostGroup(created[0].GroupId)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostGroup: %v", err)
|
||||
}
|
||||
g.Remark = "renamed"
|
||||
g.Sni = "new.sni"
|
||||
if _, err := hostSvc.UpdateHostGroup(g.GroupId, g); err != nil {
|
||||
t.Fatalf("UpdateHostGroup: %v", err)
|
||||
}
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("edited binding must still render 1 link, got %d: %v", len(parts), parts)
|
||||
}
|
||||
if !strings.Contains(parts[0], "b.cdn.com:443") || !strings.Contains(parts[0], "sni=new.sni") {
|
||||
t.Fatalf("bound link must carry the edited host with new SNI: %s", parts[0])
|
||||
}
|
||||
if strings.Contains(parts[0], "a.cdn.com") || strings.Contains(parts[0], "sni=old.sni") {
|
||||
t.Fatalf("edited binding must not fall back to the unbound host: %s", parts[0])
|
||||
}
|
||||
|
||||
jsOut, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if !strings.Contains(jsOut, "b.cdn.com") || !strings.Contains(jsOut, "new.sni") {
|
||||
t.Fatalf("json must carry the edited bound host with new SNI:\n%s", jsOut)
|
||||
}
|
||||
if strings.Contains(jsOut, "a.cdn.com") || strings.Contains(jsOut, "old.sni") {
|
||||
t.Fatalf("json must not contain the unbound host or old SNI:\n%s", jsOut)
|
||||
}
|
||||
|
||||
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if got := strings.Count(yaml, "b.cdn.com"); got != 1 {
|
||||
t.Fatalf("clash bound host appears %d times, want once:\n%s", got, yaml)
|
||||
}
|
||||
if !strings.Contains(yaml, "new.sni") {
|
||||
t.Fatalf("clash must carry the new SNI:\n%s", yaml)
|
||||
}
|
||||
if strings.Contains(yaml, "a.cdn.com") || strings.Contains(yaml, "old.sni") {
|
||||
t.Fatalf("clash must not contain the unbound host or old SNI:\n%s", yaml)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
|
||||
var header string
|
||||
var hasInactiveExternal bool
|
||||
var hasBoundClosed bool
|
||||
var hasEnabledClient bool
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
@@ -150,9 +151,14 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
var baseEps []map[string]any
|
||||
baseStream := inbound.StreamSettings
|
||||
// getConfig rewrites inbound.Listen/Port per endpoint while rendering a client, so bound
|
||||
// endpoints resolve against this pre-loop snapshot.
|
||||
baseInbound := *inbound
|
||||
if !subReq.hostsFirst {
|
||||
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
if baseEps = subReq.hostEndpoints(inbound, "json"); len(baseEps) > 0 {
|
||||
injectExternalProxy(inbound, baseEps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,16 +169,29 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
}
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
if subReq.hostsFirst {
|
||||
for _, variant := range subReq.subscriptionHostVariants(inbound, "json") {
|
||||
variants, closed := subReq.clientHostVariants(&baseInbound, client, "json")
|
||||
hasBoundClosed = hasBoundClosed || closed
|
||||
for _, variant := range variants {
|
||||
render := func() subConfigEntry {
|
||||
configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host)
|
||||
return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order}
|
||||
}
|
||||
ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render})
|
||||
}
|
||||
} else {
|
||||
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
|
||||
continue
|
||||
}
|
||||
effective := inbound
|
||||
if effectiveEps, bound := subReq.clientHostEndpoints(&baseInbound, client, "json", baseEps); bound {
|
||||
if len(effectiveEps) == 0 {
|
||||
hasBoundClosed = true
|
||||
continue
|
||||
}
|
||||
clone := baseInbound
|
||||
clone.StreamSettings = baseStream
|
||||
injectExternalProxy(&clone, effectiveEps)
|
||||
effective = &clone
|
||||
}
|
||||
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, effective, client, host)...)
|
||||
}
|
||||
if len(inboundConfigs) > 0 {
|
||||
entries = append(entries, subConfigEntry{
|
||||
@@ -254,7 +273,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
}
|
||||
}
|
||||
|
||||
if len(configArray) == 0 && !hasInactiveExternal {
|
||||
if len(configArray) == 0 && !hasInactiveExternal && !hasBoundClosed {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -38,9 +38,16 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
|
||||
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
|
||||
svc := p.build(host)
|
||||
svc.projectThroughFallbackMaster(inbound)
|
||||
if endpoints := svc.hostEndpoints(inbound, "raw"); len(endpoints) > 0 {
|
||||
if client, ok := svc.clientForLink(inbound, email); ok {
|
||||
return splitLinkLines(svc.linkFromHosts(inbound, client, endpoints))
|
||||
baseEps := svc.hostEndpoints(inbound, "raw")
|
||||
if client, ok := svc.clientForLink(inbound, email); ok {
|
||||
effectiveEps, bound := svc.clientHostEndpoints(inbound, client, "raw", baseEps)
|
||||
if bound && len(effectiveEps) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(effectiveEps) > 0 {
|
||||
if link := svc.linkFromHosts(inbound, client, effectiveEps); link != "" {
|
||||
return splitLinkLines(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
return splitLinkLines(svc.GetLink(inbound, email))
|
||||
|
||||
+23
-5
@@ -513,7 +513,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
||||
}
|
||||
var link string
|
||||
if s.hostsFirst {
|
||||
for _, variant := range s.subscriptionHostVariants(inbound, "raw") {
|
||||
variants, closed := s.clientHostVariants(inbound, client, "raw")
|
||||
if closed {
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
continue
|
||||
}
|
||||
for _, variant := range variants {
|
||||
render := func() string {
|
||||
if variant.host != nil {
|
||||
return s.linkFromHosts(inbound, client, variant.endpoint(s, inbound))
|
||||
@@ -522,8 +527,14 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
||||
}
|
||||
ordered = append(ordered, orderedSubEntry[func() string]{variant.order, render})
|
||||
}
|
||||
} else if len(hostEps) > 0 {
|
||||
link = s.linkFromHosts(inbound, client, hostEps)
|
||||
} else if effectiveEps, bound := s.clientHostEndpoints(inbound, client, "raw", hostEps); bound && len(effectiveEps) == 0 {
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
continue
|
||||
} else if len(effectiveEps) > 0 {
|
||||
link = s.linkFromHosts(inbound, client, effectiveEps)
|
||||
if link == "" {
|
||||
link = s.GetLink(inbound, client.Email)
|
||||
}
|
||||
} else {
|
||||
link = s.GetLink(inbound, client.Email)
|
||||
}
|
||||
@@ -601,9 +612,16 @@ func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
effectiveEps, bound := s.clientHostEndpoints(inbound, client, "raw", hostEps)
|
||||
if bound && len(effectiveEps) == 0 {
|
||||
continue
|
||||
}
|
||||
var link string
|
||||
if len(hostEps) > 0 {
|
||||
link = s.linkFromHosts(inbound, client, hostEps)
|
||||
if len(effectiveEps) > 0 {
|
||||
link = s.linkFromHosts(inbound, client, effectiveEps)
|
||||
if link == "" {
|
||||
link = s.GetLink(inbound, client.Email)
|
||||
}
|
||||
} else {
|
||||
link = s.GetLink(inbound, client.Email)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func NewHostController(g *gin.RouterGroup) *HostController {
|
||||
|
||||
func (a *HostController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/raw", a.raw)
|
||||
g.GET("/get/:groupId", a.get)
|
||||
g.GET("/byInbound/:inboundId", a.byInbound)
|
||||
g.GET("/tags", a.tags)
|
||||
@@ -45,6 +46,17 @@ func (a *HostController) list(c *gin.Context) {
|
||||
jsonObj(c, hosts, nil)
|
||||
}
|
||||
|
||||
// raw lists every host row with its numeric id for id-based pickers such as
|
||||
// the per-client host rule override.
|
||||
func (a *HostController) raw(c *gin.Context) {
|
||||
hosts, err := a.hostService.GetRawHosts()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.list"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, hosts, nil)
|
||||
}
|
||||
|
||||
func (a *HostController) get(c *gin.Context) {
|
||||
groupId := c.Param("groupId")
|
||||
h, err := a.hostService.GetHostGroup(groupId)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",
|
||||
"mtprotoAdTag": "علامة إعلانية (قناة مموّلة)",
|
||||
"mtprotoAdTagHint": "علامة اختيارية مكوّنة من 32 حرفًا ست عشريًا يتم الحصول عليها عند تسجيل البروكسي في تلغرام. عند تعيينها، يتم توجيه هذا العميل عبر البروكسيات الوسيطة في تلغرام وتظهر قناة مموّلة أعلى قائمة محادثاته.",
|
||||
"hostRuleOverride": "تجاوز قاعدة المضيف",
|
||||
"hostRuleOverridePlaceholder": "افتراضي (قاعدة الاتصال الوارد)",
|
||||
"hostRuleDisabled": "{label} (معطل)",
|
||||
"reverseTag": "وسم عكسي",
|
||||
"reverseTagPlaceholder": "Reverse tag اختياري",
|
||||
"telegramId": "معرّف مستخدم تلغرام",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
|
||||
"mtprotoAdTag": "Ad-tag (sponsored channel)",
|
||||
"mtprotoAdTagHint": "Optional 32-character hex tag from Telegram's proxy registration. When set, this client is routed through Telegram middle proxies and a sponsored channel appears at the top of their chat list.",
|
||||
"hostRuleOverride": "Host Rule Override",
|
||||
"hostRuleOverridePlaceholder": "Default (inbound rule)",
|
||||
"hostRuleDisabled": "{label} (disabled)",
|
||||
"reverseTag": "Reverse tag",
|
||||
"reverseTagPlaceholder": "Optional reverse tag",
|
||||
"telegramId": "Telegram user ID",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "El secreto FakeTLS del cliente. Vuelve a generarlo para cambiarlo.",
|
||||
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
|
||||
"mtprotoAdTagHint": "Etiqueta hexadecimal opcional de 32 caracteres del registro de proxy de Telegram. Si se establece, este cliente se enruta a través de los proxies intermedios de Telegram y aparece un canal patrocinado en la parte superior de su lista de chats.",
|
||||
"hostRuleOverride": "Anulación de regla de host",
|
||||
"hostRuleOverridePlaceholder": "Predeterminado (regla de inbound)",
|
||||
"hostRuleDisabled": "{label} (deshabilitado)",
|
||||
"reverseTag": "Etiqueta inversa",
|
||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||
"telegramId": "ID de usuario de Telegram",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",
|
||||
"mtprotoAdTag": "برچسب تبلیغاتی (کانال حامی)",
|
||||
"mtprotoAdTagHint": "برچسب هگزادسیمال اختیاری ۳۲ کاراکتری که هنگام ثبت پروکسی در تلگرام دریافت میشود. با تنظیم آن، این کلاینت از طریق پروکسیهای میانی تلگرام هدایت میشود و یک کانال حامی در بالای فهرست گفتگوهایش نمایش داده میشود.",
|
||||
"hostRuleOverride": "بازنویسی قانون میزبان",
|
||||
"hostRuleOverridePlaceholder": "پیشفرض (قانون اینباند)",
|
||||
"hostRuleDisabled": "{label} (غیرفعال)",
|
||||
"reverseTag": "تگ معکوس",
|
||||
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
||||
"telegramId": "شناسه کاربر تلگرام",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "Secret FakeTLS klien. Buat ulang untuk menggantinya.",
|
||||
"mtprotoAdTag": "Ad-tag (kanal bersponsor)",
|
||||
"mtprotoAdTagHint": "Tag heksadesimal 32 karakter opsional dari pendaftaran proxy Telegram. Jika diatur, klien ini dirutekan melalui proxy perantara Telegram dan kanal bersponsor muncul di bagian atas daftar obrolannya.",
|
||||
"hostRuleOverride": "Penggantian aturan host",
|
||||
"hostRuleOverridePlaceholder": "Bawaan (aturan inbound)",
|
||||
"hostRuleDisabled": "{label} (nonaktif)",
|
||||
"reverseTag": "Reverse tag",
|
||||
"reverseTagPlaceholder": "Reverse tag opsional",
|
||||
"telegramId": "ID pengguna Telegram",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "このクライアントの FakeTLS シークレット。変更するには再生成します。",
|
||||
"mtprotoAdTag": "広告タグ(スポンサーチャンネル)",
|
||||
"mtprotoAdTagHint": "Telegram のプロキシ登録で取得する任意の 32 文字の 16 進数タグ。設定すると、このクライアントは Telegram の中間プロキシ経由でルーティングされ、スポンサーチャンネルがチャット一覧の先頭に表示されます。",
|
||||
"hostRuleOverride": "ホストルールのオーバーライド",
|
||||
"hostRuleOverridePlaceholder": "デフォルト (インバウンドルール)",
|
||||
"hostRuleDisabled": "{label} (無効)",
|
||||
"reverseTag": "Reverse tag",
|
||||
"reverseTagPlaceholder": "任意の Reverse tag",
|
||||
"telegramId": "Telegram ユーザー ID",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "O segredo FakeTLS do cliente. Gere novamente para trocá-lo.",
|
||||
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
|
||||
"mtprotoAdTagHint": "Tag hexadecimal opcional de 32 caracteres do registro de proxy do Telegram. Quando definida, este cliente é roteado pelos proxies intermediários do Telegram e um canal patrocinado aparece no topo da lista de conversas.",
|
||||
"hostRuleOverride": "Substituição de regra de host",
|
||||
"hostRuleOverridePlaceholder": "Padrão (regra de inbound)",
|
||||
"hostRuleDisabled": "{label} (desativado)",
|
||||
"reverseTag": "Tag reversa",
|
||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||
"telegramId": "ID de usuário do Telegram",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
|
||||
"mtprotoAdTag": "Рекламный тег (спонсорский канал)",
|
||||
"mtprotoAdTagHint": "Необязательный шестнадцатеричный тег из 32 символов, получаемый при регистрации прокси в Telegram. Если задан, этот клиент маршрутизируется через промежуточные прокси Telegram, и спонсорский канал появляется вверху списка чатов.",
|
||||
"hostRuleOverride": "Переопределение правила хоста",
|
||||
"hostRuleOverridePlaceholder": "По умолчанию (правило входящего)",
|
||||
"hostRuleDisabled": "{label} (отключено)",
|
||||
"reverseTag": "Обратный тег",
|
||||
"reverseTagPlaceholder": "Необязательный Reverse tag",
|
||||
"telegramId": "ID пользователя Telegram",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "Tajný kľúč FakeTLS tohto klienta. Novým vygenerovaním ho zmeníte.",
|
||||
"mtprotoAdTag": "Reklamná značka (sponzorovaný kanál)",
|
||||
"mtprotoAdTagHint": "Voliteľná 32-znaková hexadecimálna značka z registrácie proxy v Telegrame. Klient sa potom pripája cez sprostredkujúce proxy Telegramu a sponzorovaný kanál sa zobrazí na začiatku jeho zoznamu konverzácií.",
|
||||
"hostRuleOverride": "Prepísanie pravidla hostiteľa",
|
||||
"hostRuleOverridePlaceholder": "Predvolené (pravidlo inboundu)",
|
||||
"hostRuleDisabled": "{label} (vypnuté)",
|
||||
"reverseTag": "Značka spätného pripojenia",
|
||||
"reverseTagPlaceholder": "Voliteľná značka spätného pripojenia",
|
||||
"telegramId": "ID používateľa Telegramu",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "İstemcinin FakeTLS sırrı. Değiştirmek için yeniden oluşturun.",
|
||||
"mtprotoAdTag": "Reklam etiketi (sponsorlu kanal)",
|
||||
"mtprotoAdTagHint": "Telegram proxy kaydından alınan isteğe bağlı 32 karakterlik onaltılık etiket. Ayarlandığında bu istemci Telegram ara proxy'leri üzerinden yönlendirilir ve sohbet listesinin en üstünde sponsorlu bir kanal görünür.",
|
||||
"hostRuleOverride": "Host kuralı geçersiz kılma",
|
||||
"hostRuleOverridePlaceholder": "Varsayılan (gelen bağlantı kuralı)",
|
||||
"hostRuleDisabled": "{label} (devre dışı)",
|
||||
"reverseTag": "Reverse Tag",
|
||||
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
|
||||
"telegramId": "Telegram Kullanıcı ID'si",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",
|
||||
"mtprotoAdTag": "Рекламний тег (спонсорський канал)",
|
||||
"mtprotoAdTagHint": "Необовʼязковий шістнадцятковий тег із 32 символів, який видається під час реєстрації проксі в Telegram. Якщо задано, цей клієнт маршрутизується через проміжні проксі Telegram, а спонсорський канал зʼявляється вгорі списку чатів.",
|
||||
"hostRuleOverride": "Перевизначення правила хоста",
|
||||
"hostRuleOverridePlaceholder": "За замовчуванням (правило вхідного)",
|
||||
"hostRuleDisabled": "{label} (вимкнено)",
|
||||
"reverseTag": "Зворотний тег",
|
||||
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
|
||||
"telegramId": "ID користувача Telegram",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "Secret FakeTLS của client. Tạo lại để thay đổi.",
|
||||
"mtprotoAdTag": "Ad-tag (kênh tài trợ)",
|
||||
"mtprotoAdTagHint": "Thẻ thập lục phân 32 ký tự tùy chọn từ đăng ký proxy của Telegram. Khi được đặt, client này sẽ được định tuyến qua các proxy trung gian của Telegram và một kênh tài trợ xuất hiện ở đầu danh sách trò chuyện.",
|
||||
"hostRuleOverride": "Ghi đè quy tắc host",
|
||||
"hostRuleOverridePlaceholder": "Mặc định (quy tắc inbound)",
|
||||
"hostRuleDisabled": "{label} (đã tắt)",
|
||||
"reverseTag": "Reverse tag",
|
||||
"reverseTagPlaceholder": "Reverse tag tùy chọn",
|
||||
"telegramId": "ID người dùng Telegram",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",
|
||||
"mtprotoAdTag": "广告标签(赞助频道)",
|
||||
"mtprotoAdTagHint": "可选的 32 位十六进制标签,从 Telegram 代理注册处获取。设置后,该客户端将通过 Telegram 中间代理路由,赞助频道会显示在其聊天列表顶部。",
|
||||
"hostRuleOverride": "主机规则覆盖",
|
||||
"hostRuleOverridePlaceholder": "默认 (入站规则)",
|
||||
"hostRuleDisabled": "{label} (关闭)",
|
||||
"reverseTag": "反向标签",
|
||||
"reverseTagPlaceholder": "可选 Reverse tag",
|
||||
"telegramId": "Telegram 用户 ID",
|
||||
|
||||
@@ -992,6 +992,9 @@
|
||||
"mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",
|
||||
"mtprotoAdTag": "廣告標籤(贊助頻道)",
|
||||
"mtprotoAdTagHint": "選填的 32 位十六進位標籤,從 Telegram 代理註冊處取得。設定後,該用戶端會透過 Telegram 中間代理路由,贊助頻道會顯示在其聊天清單頂端。",
|
||||
"hostRuleOverride": "主機規則覆寫",
|
||||
"hostRuleOverridePlaceholder": "預設(入站規則)",
|
||||
"hostRuleDisabled": "{label} (關閉)",
|
||||
"reverseTag": "反向標籤",
|
||||
"reverseTagPlaceholder": "選用 Reverse tag",
|
||||
"telegramId": "Telegram 使用者 ID",
|
||||
|
||||
Reference in New Issue
Block a user