feat(clients): bind host rules to individual clients in subscriptions (#6620)

Host rules applied per inbound only, so every client of an inbound got the same
subscription endpoints (#6618). A client can now be bound to one or more host
rules, and its raw, JSON and Clash subscriptions render only those hosts, in
either subscription order; an unbound client is unchanged. A binding whose hosts
are deleted, disabled or excluded from the format fails closed: the client gets
no links there but stays in traffic accounting. Host edits keep row ids, so
bindings survive them; nil leaves a stored binding alone and an empty list
clears it.
This commit is contained in:
Nethmina Yasas
2026-10-24 16:03:02 +05:30
committed by GitHub
parent d103c0c012
commit bc1b750e00
47 changed files with 1940 additions and 168 deletions
+12
View File
@@ -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)
+44 -24
View File
@@ -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)
}
}
+9 -1
View File
@@ -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"} {
+79 -9
View File
@@ -202,6 +202,17 @@ func (s *HostService) GetHosts() ([]*entity.HostGroup, error) {
return groupHosts(hosts), nil
}
// GetRawHosts returns every host row with its numeric id, so callers that
// bind by host id (per-client host rule overrides) can list candidates.
func (s *HostService) GetRawHosts() ([]*model.Host, error) {
var hosts []*model.Host
err := database.GetDB().Order("sort_order asc, id asc").Find(&hosts).Error
if err != nil {
return nil, err
}
return hosts, nil
}
func (s *HostService) GetHostsByInbound(inboundId int) ([]*entity.HostGroup, error) {
var groupIds []string
if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", inboundId).Distinct().Pluck("group_id", &groupIds).Error; err != nil {
@@ -262,31 +273,90 @@ func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([]
if err := validateFinalMaskJSONSalamander(req.FinalMask); err != nil {
return nil, err
}
created := buildHostRows(groupId, req)
desired := buildHostRows(groupId, req)
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Count(&count).Error; err != nil {
var existing []*model.Host
if err := tx.Where("group_id = ?", groupId).Order("id asc").Find(&existing).Error; err != nil {
return err
}
if count == 0 {
if len(existing) == 0 {
return common.NewError("host not found")
}
if err := validateInboundsExist(tx, req.InboundIds); err != nil {
return err
}
if err := tx.Where("group_id = ?", groupId).Delete(&model.Host{}).Error; err != nil {
return err
// Exact (inbound, address, port) matches reuse ids so edits keep bindings; a second pass pairs
// same-inbound leftovers with edits, and rows still unclaimed are deleted.
type hostKey struct {
inbound int
addr string
port int
}
if len(created) > 0 {
return tx.Create(&created).Error
byKey := make(map[hostKey][]*model.Host)
for _, h := range existing {
k := hostKey{h.InboundId, h.Address, h.Port}
byKey[k] = append(byKey[k], h)
}
var toInsert, toUpdate []*model.Host
var unmatched []*model.Host
matched := make(map[int]struct{}, len(desired))
for _, d := range desired {
k := hostKey{d.InboundId, d.Address, d.Port}
if q := byKey[k]; len(q) > 0 {
d.Id = q[0].Id
d.CreatedAt = q[0].CreatedAt
byKey[k] = q[1:]
toUpdate = append(toUpdate, d)
matched[d.Id] = struct{}{}
} else {
unmatched = append(unmatched, d)
}
}
leftovers := make(map[int][]*model.Host)
for _, h := range existing {
if _, ok := matched[h.Id]; !ok {
leftovers[h.InboundId] = append(leftovers[h.InboundId], h)
}
}
for _, d := range unmatched {
if q := leftovers[d.InboundId]; len(q) > 0 {
d.Id = q[0].Id
d.CreatedAt = q[0].CreatedAt
leftovers[d.InboundId] = q[1:]
toUpdate = append(toUpdate, d)
matched[d.Id] = struct{}{}
} else {
toInsert = append(toInsert, d)
}
}
var toDelete []int
for _, h := range existing {
if _, ok := matched[h.Id]; !ok {
toDelete = append(toDelete, h.Id)
}
}
if len(toDelete) > 0 {
if err := tx.Where("id IN ?", toDelete).Delete(&model.Host{}).Error; err != nil {
return err
}
}
for _, u := range toUpdate {
if err := tx.Save(u).Error; err != nil {
return err
}
}
if len(toInsert) > 0 {
if err := tx.Create(&toInsert).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
return desired, nil
}
func (s *HostService) DeleteHostGroup(groupId string) error {
+98
View File
@@ -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) {
+3
View File
@@ -992,6 +992,9 @@
"mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",
"mtprotoAdTag": "علامة إعلانية (قناة مموّلة)",
"mtprotoAdTagHint": "علامة اختيارية مكوّنة من 32 حرفًا ست عشريًا يتم الحصول عليها عند تسجيل البروكسي في تلغرام. عند تعيينها، يتم توجيه هذا العميل عبر البروكسيات الوسيطة في تلغرام وتظهر قناة مموّلة أعلى قائمة محادثاته.",
"hostRuleOverride": "تجاوز قاعدة المضيف",
"hostRuleOverridePlaceholder": "افتراضي (قاعدة الاتصال الوارد)",
"hostRuleDisabled": "{label} (معطل)",
"reverseTag": "وسم عكسي",
"reverseTagPlaceholder": "Reverse tag اختياري",
"telegramId": "معرّف مستخدم تلغرام",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -992,6 +992,9 @@
"mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",
"mtprotoAdTag": "برچسب تبلیغاتی (کانال حامی)",
"mtprotoAdTagHint": "برچسب هگزادسیمال اختیاری ۳۲ کاراکتری که هنگام ثبت پروکسی در تلگرام دریافت می‌شود. با تنظیم آن، این کلاینت از طریق پروکسی‌های میانی تلگرام هدایت می‌شود و یک کانال حامی در بالای فهرست گفتگوهایش نمایش داده می‌شود.",
"hostRuleOverride": "بازنویسی قانون میزبان",
"hostRuleOverridePlaceholder": "پیش‌فرض (قانون اینباند)",
"hostRuleDisabled": "{label} (غیرفعال)",
"reverseTag": "تگ معکوس",
"reverseTagPlaceholder": "Reverse tag اختیاری",
"telegramId": "شناسه کاربر تلگرام",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -992,6 +992,9 @@
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
"mtprotoAdTag": "Рекламный тег (спонсорский канал)",
"mtprotoAdTagHint": "Необязательный шестнадцатеричный тег из 32 символов, получаемый при регистрации прокси в Telegram. Если задан, этот клиент маршрутизируется через промежуточные прокси Telegram, и спонсорский канал появляется вверху списка чатов.",
"hostRuleOverride": "Переопределение правила хоста",
"hostRuleOverridePlaceholder": "По умолчанию (правило входящего)",
"hostRuleDisabled": "{label} (отключено)",
"reverseTag": "Обратный тег",
"reverseTagPlaceholder": "Необязательный Reverse tag",
"telegramId": "ID пользователя Telegram",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -992,6 +992,9 @@
"mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",
"mtprotoAdTag": "Рекламний тег (спонсорський канал)",
"mtprotoAdTagHint": "Необовʼязковий шістнадцятковий тег із 32 символів, який видається під час реєстрації проксі в Telegram. Якщо задано, цей клієнт маршрутизується через проміжні проксі Telegram, а спонсорський канал зʼявляється вгорі списку чатів.",
"hostRuleOverride": "Перевизначення правила хоста",
"hostRuleOverridePlaceholder": "За замовчуванням (правило вхідного)",
"hostRuleDisabled": "{label} (вимкнено)",
"reverseTag": "Зворотний тег",
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
"telegramId": "ID користувача Telegram",
+3
View File
@@ -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",
+3
View File
@@ -992,6 +992,9 @@
"mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",
"mtprotoAdTag": "广告标签(赞助频道)",
"mtprotoAdTagHint": "可选的 32 位十六进制标签,从 Telegram 代理注册处获取。设置后,该客户端将通过 Telegram 中间代理路由,赞助频道会显示在其聊天列表顶部。",
"hostRuleOverride": "主机规则覆盖",
"hostRuleOverridePlaceholder": "默认 (入站规则)",
"hostRuleDisabled": "{label} (关闭)",
"reverseTag": "反向标签",
"reverseTagPlaceholder": "可选 Reverse tag",
"telegramId": "Telegram 用户 ID",
+3
View File
@@ -992,6 +992,9 @@
"mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",
"mtprotoAdTag": "廣告標籤(贊助頻道)",
"mtprotoAdTagHint": "選填的 32 位十六進位標籤,從 Telegram 代理註冊處取得。設定後,該用戶端會透過 Telegram 中間代理路由,贊助頻道會顯示在其聊天清單頂端。",
"hostRuleOverride": "主機規則覆寫",
"hostRuleOverridePlaceholder": "預設(入站規則)",
"hostRuleDisabled": "{label} (關閉)",
"reverseTag": "反向標籤",
"reverseTagPlaceholder": "選用 Reverse tag",
"telegramId": "Telegram 使用者 ID",