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:
@@ -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