1
0

8 Коммиты 659f0f404c ... 2c49dbf54e

Автор SHA1 Сообщение Дата
  MHSanaei 2c49dbf54e fix(node): start a fresh quota window when a node auto-renews a client 7 часов назад
  MHSanaei cc3303dd8c fix(sub): carry a host's Final Mask into raw share links 7 часов назад
  MHSanaei 52d4af71bc fix(ldap): attach auto-created clients to every configured inbound tag 7 часов назад
  MHSanaei 7cb2adf429 fix(client): clean node_client_traffics rows when deleting a client 7 часов назад
  ecgang 6e75938c61 [Feature]: Add a tooltip/hint to the "Password" field in the client form clarifying which protocols use it (#5809) 5 часов назад
  MHSanaei ad7a0f8164 refactor(mtproto): manage ad-tags per client only 7 часов назад
  MHSanaei 406ce54fb2 chore(mtproto): bump the mtg-multi binary pin to v1.14.0 7 часов назад
  MHSanaei 43500a5470 feat(mtproto): per-client ad-tags, management-API auth, and record secret sync 7 часов назад
47 измененных файлов с 1035 добавлено и 199 удалено
  1. 2 2
      .github/workflows/release.yml
  2. 5 3
      CLAUDE.md
  3. 1 1
      DockerInit.sh
  4. 4 3
      docs/architecture.md
  5. 8 0
      frontend/public/openapi.json
  6. 2 0
      frontend/src/generated/examples.ts
  7. 8 0
      frontend/src/generated/schemas.ts
  8. 2 0
      frontend/src/generated/types.ts
  9. 2 0
      frontend/src/generated/zod.ts
  10. 27 8
      frontend/src/pages/clients/ClientFormModal.tsx
  11. 0 10
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  12. 1 0
      frontend/src/schemas/client.ts
  13. 7 9
      frontend/src/schemas/protocols/inbound/mtproto.ts
  14. 71 0
      frontend/src/test/client-form-modal.test.tsx
  15. 36 0
      internal/database/model/model.go
  16. 22 0
      internal/database/model/model_mtproto_test.go
  17. 96 38
      internal/mtproto/manager.go
  18. 6 2
      internal/mtproto/manager_mutation_test.go
  19. 22 8
      internal/mtproto/manager_reload_test.go
  20. 42 13
      internal/mtproto/manager_test.go
  21. 2 0
      internal/sub/endpoint.go
  22. 44 0
      internal/sub/endpoint_test.go
  23. 38 0
      internal/sub/host_sub.go
  24. 23 0
      internal/sub/host_sub_test.go
  25. 56 47
      internal/web/job/ldap_sync_job.go
  26. 85 0
      internal/web/job/ldap_sync_job_test.go
  27. 15 0
      internal/web/service/client_crud.go
  28. 95 0
      internal/web/service/client_delete_node_baseline_test.go
  29. 6 0
      internal/web/service/client_inbound_apply.go
  30. 6 0
      internal/web/service/client_link.go
  31. 70 0
      internal/web/service/client_sync_mtproto_test.go
  32. 9 3
      internal/web/service/inbound.go
  33. 61 26
      internal/web/service/inbound_node.go
  34. 109 0
      internal/web/service/node_client_renew_sync_test.go
  35. 4 2
      internal/web/translation/ar-EG.json
  36. 4 2
      internal/web/translation/en-US.json
  37. 4 2
      internal/web/translation/es-ES.json
  38. 4 2
      internal/web/translation/fa-IR.json
  39. 4 2
      internal/web/translation/id-ID.json
  40. 4 2
      internal/web/translation/ja-JP.json
  41. 4 2
      internal/web/translation/pt-BR.json
  42. 4 2
      internal/web/translation/ru-RU.json
  43. 4 2
      internal/web/translation/tr-TR.json
  44. 4 2
      internal/web/translation/uk-UA.json
  45. 4 2
      internal/web/translation/vi-VN.json
  46. 4 2
      internal/web/translation/zh-CN.json
  47. 4 2
      internal/web/translation/zh-TW.json

+ 2 - 2
.github/workflows/release.yml

@@ -159,7 +159,7 @@ jobs:
           # mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
           # platform labels match our matrix, so download and unpack the matching
           # archive. Only the platforms the fork publishes are packaged.
-          MTG_MULTI_VER="v1.13.3"
+          MTG_MULTI_VER="v1.14.0"
           case "${{ matrix.platform }}" in
             amd64|arm64|armv7|armv6|386)
               MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}"
@@ -285,7 +285,7 @@ jobs:
 
           # mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
           # download and unpack it instead of compiling.
-          $MTG_MULTI_VER = "v1.13.3"
+          $MTG_MULTI_VER = "v1.14.0"
           $MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64"
           curl.exe -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
           Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force

+ 5 - 3
CLAUDE.md

@@ -14,9 +14,11 @@ file locations when it can answer in one hop.
   API. MTProto inbounds run a second managed child — the `mtg-multi` binary
   (`github.com/mhsanaei/mtg-multi`, a multi-secret fork built from source;
   `internal/mtproto/`) — outside Xray, one process per inbound serving each
-  client's FakeTLS secret via the fork's `[secrets]` section. Client edits are
-  hot-applied through the fork's `POST /reload` so connections survive; the
-  manager falls back to a process restart on older binaries.
+  client's FakeTLS secret via the fork's `[secrets]` section (plus per-client
+  ad-tags via `[secret-ad-tags]`). Client and ad-tag edits are hot-applied
+  through the fork's management API (`PUT /secrets`, bearer-token guarded) so
+  connections survive; the manager falls back to a process restart on older
+  binaries.
 - Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on
   Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite
   driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail.

+ 1 - 1
DockerInit.sh

@@ -25,7 +25,7 @@ case $1 in
         FNAME="amd64"
         ;;
 esac
-MTG_MULTI_VER="v1.13.3"
+MTG_MULTI_VER="v1.14.0"
 mkdir -p build/bin
 cd build/bin
 curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip"

+ 4 - 3
docs/architecture.md

@@ -22,9 +22,10 @@ separate HTTP server serves **subscription links** to end users.
 The panel supervises **two managed child processes**: Xray-core itself and — when MTProto
 inbounds exist — the `mtg-multi` Telegram-proxy binary (`github.com/mhsanaei/mtg-multi`, a
 multi-secret fork built from source; `internal/mtproto/`). One process per inbound serves
-every attached client's FakeTLS secret through the fork's `[secrets]` section. A client edit
-is hot-applied via the fork's `POST /reload` endpoint (connections survive), with a process
-restart as the fallback on older binaries.
+every attached client's FakeTLS secret through the fork's `[secrets]` section, plus optional
+per-client sponsored-channel ad-tags via `[secret-ad-tags]`. A client or ad-tag edit is
+hot-applied via the fork's management API (`PUT /secrets`, guarded by a per-process bearer
+token), with a process restart as the fallback on older binaries.
 
 Servers and processes, all launched from `main.go`:
 

+ 8 - 0
frontend/public/openapi.json

@@ -1092,6 +1092,10 @@
       "Client": {
         "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
         "properties": {
+          "adTag": {
+            "example": "0123456789abcdef0123456789abcdef",
+            "type": "string"
+          },
           "allowedIPs": {
             "items": {
               "type": "string"
@@ -1231,6 +1235,9 @@
       },
       "ClientRecord": {
         "properties": {
+          "adTag": {
+            "type": "string"
+          },
           "allowedIPs": {
             "type": "string"
           },
@@ -1306,6 +1313,7 @@
           }
         },
         "required": [
+          "adTag",
           "allowedIPs",
           "auth",
           "comment",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -214,6 +214,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "token": "new-token-string"
   },
   "Client": {
+    "adTag": "0123456789abcdef0123456789abcdef",
     "allowedIPs": [
       ""
     ],
@@ -248,6 +249,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "inboundId": 0
   },
   "ClientRecord": {
+    "adTag": "",
     "allowedIPs": "",
     "auth": "",
     "comment": "",

+ 8 - 0
frontend/src/generated/schemas.ts

@@ -1066,6 +1066,10 @@ export const SCHEMAS: Record<string, unknown> = {
   "Client": {
     "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
     "properties": {
+      "adTag": {
+        "example": "0123456789abcdef0123456789abcdef",
+        "type": "string"
+      },
       "allowedIPs": {
         "items": {
           "type": "string"
@@ -1205,6 +1209,9 @@ export const SCHEMAS: Record<string, unknown> = {
   },
   "ClientRecord": {
     "properties": {
+      "adTag": {
+        "type": "string"
+      },
       "allowedIPs": {
         "type": "string"
       },
@@ -1280,6 +1287,7 @@ export const SCHEMAS: Record<string, unknown> = {
       }
     },
     "required": [
+      "adTag",
       "allowedIPs",
       "auth",
       "comment",

+ 2 - 0
frontend/src/generated/types.ts

@@ -224,6 +224,7 @@ export interface ApiTokenView {
 }
 
 export interface Client {
+  adTag?: string;
   allowedIPs?: string[];
   auth?: string;
   comment: string;
@@ -258,6 +259,7 @@ export interface ClientInbound {
 }
 
 export interface ClientRecord {
+  adTag: string;
   allowedIPs: string;
   auth: string;
   comment: string;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -240,6 +240,7 @@ export const ApiTokenViewSchema = z.object({
 export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
 
 export const ClientSchema = z.object({
+  adTag: z.string().optional(),
   allowedIPs: z.array(z.string()).optional(),
   auth: z.string().optional(),
   comment: z.string(),
@@ -276,6 +277,7 @@ export const ClientInboundSchema = z.object({
 export type ClientInbound = z.infer<typeof ClientInboundSchema>;
 
 export const ClientRecordSchema = z.object({
+  adTag: z.string(),
   allowedIPs: z.string(),
   auth: z.string(),
   comment: z.string(),

+ 27 - 8
frontend/src/pages/clients/ClientFormModal.tsx

@@ -119,6 +119,7 @@ interface FormState {
   wgPreSharedKey: string;
   wgAllowedIPs: string;
   secret: string;
+  adTag: string;
 }
 
 function emptyForm(): FormState {
@@ -148,6 +149,7 @@ function emptyForm(): FormState {
     wgPreSharedKey: '',
     wgAllowedIPs: '',
     secret: '',
+    adTag: '',
   };
 }
 
@@ -253,6 +255,7 @@ export default function ClientFormModal({
         wgPreSharedKey: client.preSharedKey || '',
         wgAllowedIPs: client.allowedIPs || '',
         secret: client.secret || '',
+        adTag: client.adTag || '',
       };
       if (et < 0) {
         next.delayedStart = true;
@@ -538,7 +541,13 @@ export default function ClientFormModal({
     }
 
     if (showMtproto) {
+      const adTag = form.adTag.trim();
+      if (adTag !== '' && !/^[0-9a-fA-F]{32}$/.test(adTag)) {
+        messageApi.error(t('pages.inbounds.form.mtgAdTagInvalid'));
+        return;
+      }
       clientPayload.secret = form.secret;
+      clientPayload.adTag = adTag;
     }
 
     const externalLinks: ExternalLinkInput[] = form.externalLinks
@@ -782,7 +791,7 @@ export default function ClientFormModal({
                       </Space.Compact>
                     </Form.Item>
 
-                    <Form.Item label={t('pages.clients.password')}>
+                    <Form.Item label={t('pages.clients.password')} tooltip={t('pages.clients.passwordDesc')}>
                       <Space.Compact style={{ display: 'flex' }}>
                         <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
                         <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
@@ -796,7 +805,7 @@ export default function ClientFormModal({
                       </Space.Compact>
                     </Form.Item>
 
-                    <Form.Item label={t('pages.clients.hysteriaAuth')}>
+                    <Form.Item label={t('pages.clients.hysteriaAuth')} tooltip={t('pages.clients.hysteriaAuthDesc')}>
                       <Space.Compact style={{ display: 'flex' }}>
                         <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
                         <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
@@ -862,12 +871,22 @@ export default function ClientFormModal({
                       </>
                     )}
                     {showMtproto && (
-                      <Form.Item label={t('pages.clients.mtprotoSecret')} extra={t('pages.clients.mtprotoSecretHint')}>
-                        <Space.Compact style={{ display: 'flex' }}>
-                          <Input value={form.secret} style={{ flex: 1 }} onChange={(e) => update('secret', e.target.value)} />
-                          <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateMtprotoSecret} />
-                        </Space.Compact>
-                      </Form.Item>
+                      <>
+                        <Form.Item label={t('pages.clients.mtprotoSecret')} extra={t('pages.clients.mtprotoSecretHint')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.secret} style={{ flex: 1 }} onChange={(e) => update('secret', e.target.value)} />
+                            <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateMtprotoSecret} />
+                          </Space.Compact>
+                        </Form.Item>
+                        <Form.Item label={t('pages.clients.mtprotoAdTag')} extra={t('pages.clients.mtprotoAdTagHint')}>
+                          <Input
+                            value={form.adTag}
+                            allowClear
+                            placeholder="0123456789abcdef0123456789abcdef"
+                            onChange={(e) => update('adTag', e.target.value)}
+                          />
+                        </Form.Item>
+                      </>
                     )}
                   </>
                 ),

+ 0 - 10
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -2,8 +2,6 @@ import { useTranslation } from 'react-i18next';
 import { Form, Input, InputNumber, Select, Switch } from 'antd';
 
 import { useOutboundTags } from '@/api/queries/useOutboundTags';
-import { MtprotoInboundSettingsSchema } from '@/schemas/protocols/inbound/mtproto';
-import { antdRule } from '@/utils/zodForm';
 
 export default function MtprotoFields() {
   const { t } = useTranslation();
@@ -87,14 +85,6 @@ export default function MtprotoFields() {
           />
         </Form.Item>
       )}
-      <Form.Item
-        name={['settings', 'adTag']}
-        label={t('pages.inbounds.form.mtgAdTag')}
-        tooltip={t('pages.inbounds.form.mtgAdTagHint')}
-        rules={[antdRule(MtprotoInboundSettingsSchema.shape.adTag, t)]}
-      >
-        <Input allowClear placeholder="0123456789abcdef0123456789abcdef" />
-      </Form.Item>
       <Form.Item
         name={['settings', 'publicIpv4']}
         label={t('pages.inbounds.form.mtgPublicIpv4')}

+ 1 - 0
frontend/src/schemas/client.ts

@@ -38,6 +38,7 @@ export const ClientRecordSchema = z.object({
   preSharedKey: z.string().optional(),
   keepAlive: z.number().optional(),
   secret: z.string().optional(),
+  adTag: z.string().optional(),
   createdAt: z.number().optional(),
   updatedAt: z.number().optional(),
 }).loose();

+ 7 - 9
frontend/src/schemas/protocols/inbound/mtproto.ts

@@ -17,6 +17,11 @@ export type MtprotoDomainFronting = z.infer<typeof MtprotoDomainFrontingSchema>;
 // default domain used when generating a new client's secret.
 export const MtprotoClientSchema = z.object({
   secret: z.string().default(''),
+  adTag: z
+    .string()
+    .regex(/^[0-9a-fA-F]{32}$/, 'pages.inbounds.form.mtgAdTagInvalid')
+    .or(z.literal(''))
+    .optional(),
   email: z.string().min(1),
   limitIp: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
@@ -52,15 +57,8 @@ export const MtprotoInboundSettingsSchema = z.object({
   routeThroughXray: z.boolean().optional(),
   outboundTag: z.string().optional(),
   routeXrayPort: z.number().int().min(0).max(65535).optional(),
-  // A 32-hex Telegram advertising tag: when set, mtg routes clients through
-  // Telegram middle proxies so a sponsored channel appears in their chat list.
-  // publicIpv4/publicIpv6 pin this server's reachable address the middle proxy
-  // needs; leave them blank to let mtg auto-detect it.
-  adTag: z
-    .string()
-    .regex(/^[0-9a-fA-F]{32}$/, 'pages.inbounds.form.mtgAdTagInvalid')
-    .or(z.literal(''))
-    .optional(),
+  // publicIpv4/publicIpv6 pin this server's reachable address the Telegram
+  // middle proxy needs when clients carry ad-tags; blank = mtg auto-detects.
   publicIpv4: z.string().optional(),
   publicIpv6: z.string().optional(),
 });

+ 71 - 0
frontend/src/test/client-form-modal.test.tsx

@@ -0,0 +1,71 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import ClientFormModal from '@/pages/clients/ClientFormModal';
+import { renderWithProviders } from './test-utils';
+
+// ClientFormModal reads server state via react-query (useFail2banStatusQuery),
+// so it needs a QueryClientProvider on top of the shared ThemeProvider wrapper.
+function renderModal() {
+  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  renderWithProviders(
+    <QueryClientProvider client={queryClient}>
+      <ClientFormModal
+        open
+        mode="add"
+        client={null}
+        inbounds={[]}
+        save={vi.fn().mockResolvedValue(null)}
+        onOpenChange={() => {}}
+      />
+    </QueryClientProvider>,
+  );
+}
+
+function openCredentialsTab() {
+  const tab = Array.from(document.querySelectorAll('.ant-tabs-tab'))
+    .find((t) => (t.textContent ?? '').trim() === 'Credentials');
+  if (!tab) throw new Error('Credentials tab not found');
+  fireEvent.click(tab);
+}
+
+function tooltipIconForLabel(label: string): HTMLElement {
+  const labelEl = Array.from(document.querySelectorAll('.ant-form-item-label label'))
+    .find((l) => (l.textContent ?? '').trim() === label);
+  const item = labelEl?.closest('.ant-form-item') as HTMLElement | null;
+  if (!item) throw new Error(`Form item not found for label: ${label}`);
+  const tip = item.querySelector('.ant-form-item-tooltip') as HTMLElement | null;
+  if (!tip) throw new Error(`No tooltip on form item: ${label}`);
+  return tip;
+}
+
+describe('ClientFormModal credential tooltips', () => {
+  it('explains that the Password field is only consumed by Trojan/Shadowsocks', async () => {
+    renderModal();
+    openCredentialsTab();
+
+    const tip = tooltipIconForLabel('Password');
+    fireEvent.mouseEnter(tip);
+
+    await waitFor(() => {
+      expect(document.body.textContent).toContain(
+        'Only used by Trojan and Shadowsocks clients; ignored for VLESS, VMess, Hysteria, and WireGuard.',
+      );
+    });
+  });
+
+  it('explains that Hysteria Auth is the credential Hysteria actually uses', async () => {
+    renderModal();
+    openCredentialsTab();
+
+    const tip = tooltipIconForLabel('Hysteria Auth');
+    fireEvent.mouseEnter(tip);
+
+    await waitFor(() => {
+      expect(document.body.textContent).toContain(
+        'Credential used only by Hysteria clients. Trojan and Shadowsocks use the Password field instead.',
+      );
+    });
+  });
+});

+ 36 - 0
internal/database/model/model.go

@@ -456,6 +456,18 @@ func mtprotoSecretMiddle(secret string) string {
 	return mtprotoRandomMiddle()
 }
 
+// ValidMtprotoAdTag reports whether a Telegram advertising tag from
+// @MTProxybot is well-formed: exactly 16 bytes as 32 hex characters. mtg
+// refuses to start (or rejects a live update) on a malformed tag, so every
+// write path validates before the tag can reach a generated config.
+func ValidMtprotoAdTag(tag string) bool {
+	if len(tag) != 32 {
+		return false
+	}
+	_, err := hex.DecodeString(tag)
+	return err == nil
+}
+
 // StripMtprotoInboundSecret removes the vestigial inbound-level `secret` from an
 // mtproto inbound's settings JSON. MTProto is multi-client: every secret lives on
 // a client, and mtg's [secrets] config plus every share link read only the
@@ -481,6 +493,26 @@ func StripMtprotoInboundSecret(settings string) (string, bool) {
 	return string(out), true
 }
 
+// StripMtprotoInboundAdTag drops the dead inbound-level `adTag` — tags live on clients.
+func StripMtprotoInboundAdTag(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	if _, ok := parsed["adTag"]; !ok {
+		return settings, false
+	}
+	delete(parsed, "adTag")
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
 // mtprotoSecretDomain extracts the FakeTLS domain embedded in the tail of a
 // secret, returning an empty string when the secret is malformed. Each mtproto
 // client carries its own domain inside its secret, so healing preserves it
@@ -666,6 +698,7 @@ type Client struct {
 	PreSharedKey string         `json:"preSharedKey,omitempty"`
 	KeepAlive    int            `json:"keepAlive,omitempty"`
 	Secret       string         `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
+	AdTag        string         `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
 	Email        string         `json:"email"`                        // Client email identifier
 	LimitIP      int            `json:"limitIp"`                      // IP limit for this client
 	TotalGB      int64          `json:"totalGB" form:"totalGB"`       // Total traffic limit in GB
@@ -696,6 +729,7 @@ type ClientRecord struct {
 	PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
 	KeepAlive    int    `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
 	Secret       string `json:"secret" gorm:"column:secret"`
+	AdTag        string `json:"adTag" gorm:"column:ad_tag;default:''"`
 	LimitIP      int    `json:"limitIp" gorm:"column:limit_ip"`
 	TotalGB      int64  `json:"totalGB" gorm:"column:total_gb"`
 	ExpiryTime   int64  `json:"expiryTime" gorm:"column:expiry_time"`
@@ -880,6 +914,7 @@ func (c *Client) ToRecord() *ClientRecord {
 		PreSharedKey: c.PreSharedKey,
 		KeepAlive:    c.KeepAlive,
 		Secret:       c.Secret,
+		AdTag:        c.AdTag,
 	}
 	if c.Reverse != nil {
 		if b, err := json.Marshal(c.Reverse); err == nil {
@@ -932,6 +967,7 @@ func (r *ClientRecord) ToClient() *Client {
 		PreSharedKey: r.PreSharedKey,
 		KeepAlive:    r.KeepAlive,
 		Secret:       r.Secret,
+		AdTag:        r.AdTag,
 	}
 	if r.Reverse != "" {
 		var rev ClientReverse

+ 22 - 0
internal/database/model/model_mtproto_test.go

@@ -25,6 +25,28 @@ func TestGenerateFakeTLSSecret(t *testing.T) {
 	}
 }
 
+func TestStripMtprotoInboundAdTag(t *testing.T) {
+	in := `{"adTag":"0123456789abcdef0123456789abcdef","clients":[{"email":"a","adTag":"fedcba9876543210fedcba9876543210"}]}`
+	out, changed := StripMtprotoInboundAdTag(in)
+	if !changed {
+		t.Fatal("expected the inbound-level adTag to be stripped")
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if _, ok := parsed["adTag"]; ok {
+		t.Fatalf("adTag key must be removed, got %s", out)
+	}
+	clients := parsed["clients"].([]any)
+	if clients[0].(map[string]any)["adTag"] != "fedcba9876543210fedcba9876543210" {
+		t.Fatalf("client adTag must be preserved, got %s", out)
+	}
+	if _, changed2 := StripMtprotoInboundAdTag(out); changed2 {
+		t.Fatal("second strip must be a no-op")
+	}
+}
+
 func TestStripMtprotoInboundSecret(t *testing.T) {
 	// A multi-client inbound that still carries a dead inbound-level secret has
 	// it removed while the clients (and every other key) survive untouched.

+ 96 - 38
internal/mtproto/manager.go

@@ -3,6 +3,8 @@ package mtproto
 import (
 	"bytes"
 	"context"
+	"crypto/rand"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"net"
@@ -20,10 +22,13 @@ import (
 
 // SecretEntry is one named FakeTLS secret served by an mtg-multi process. Name is
 // the client email, used both as the [secrets] key and as the per-user key in the
-// /stats API so traffic can be attributed back to the client.
+// /stats API so traffic can be attributed back to the client. AdTag is the
+// client's own advertising-tag override, emitted into the [secret-ad-tags]
+// section; empty falls back to the instance-level tag.
 type SecretEntry struct {
 	Name   string
 	Secret string
+	AdTag  string
 }
 
 // Instance is the desired runtime configuration of one mtproto inbound. A single
@@ -49,13 +54,9 @@ type Instance struct {
 	// fair-share algorithm; zero disables throttling.
 	ThrottleMaxConnections int
 
-	// AdTag is a 32-hex Telegram advertising tag; when set, mtg routes clients
-	// through Telegram middle proxies so a sponsored channel shows in their chat
-	// list. It is part of the reloadable secret config, so a change is applied
-	// via /reload without dropping connections. PublicIPv4/PublicIPv6 pin the
-	// proxy's reachable address the middle proxy needs; they are omitted when
-	// empty so mtg auto-detects, and a change forces a restart.
-	AdTag      string
+	// PublicIPv4/PublicIPv6 pin the proxy's reachable address the Telegram
+	// middle proxy needs when clients carry advertising tags; they are omitted
+	// when empty so mtg auto-detects, and a change forces a restart.
 	PublicIPv4 string
 	PublicIPv6 string
 
@@ -98,16 +99,16 @@ func (inst Instance) structuralFingerprint() string {
 
 // secretsFingerprint identifies the reloadable secret config regardless of
 // client order, so a reordered clients array in the stored settings does not
-// read as a change. It moves whenever a client is added, removed, disabled, or
-// re-keyed, or the advertising tag changes — all of which mtg applies through
-// /reload without dropping connections.
+// read as a change. It moves whenever a client is added, removed, disabled,
+// re-keyed, or re-tagged — all of which mtg applies in place without dropping
+// connections.
 func (inst Instance) secretsFingerprint() string {
 	pairs := make([]string, 0, len(inst.Secrets))
 	for _, e := range inst.Secrets {
-		pairs = append(pairs, e.Name+"="+e.Secret)
+		pairs = append(pairs, e.Name+"="+e.Secret+";tag="+e.AdTag)
 	}
 	slices.Sort(pairs)
-	return "adtag=" + inst.AdTag + "|" + strings.Join(pairs, "|")
+	return strings.Join(pairs, "|")
 }
 
 // Traffic is a per-client traffic delta scraped from an mtg /stats endpoint. Tag
@@ -130,6 +131,7 @@ type managed struct {
 	structuralFP string
 	secretsFP    string
 	apiPort      int
+	apiToken     string
 	last         map[string]clientCounters
 }
 
@@ -177,12 +179,12 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		ThrottleMaxConnections int    `json:"throttleMaxConnections"`
 		RouteThroughXray       bool   `json:"routeThroughXray"`
 		RouteXrayPort          int    `json:"routeXrayPort"`
-		AdTag                  string `json:"adTag"`
 		PublicIPv4             string `json:"publicIpv4"`
 		PublicIPv6             string `json:"publicIpv6"`
 		Clients                []struct {
 			Email  string `json:"email"`
 			Secret string `json:"secret"`
+			AdTag  string `json:"adTag"`
 			Enable bool   `json:"enable"`
 		} `json:"clients"`
 	}
@@ -194,7 +196,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		if !c.Enable || c.Secret == "" || c.Email == "" {
 			continue
 		}
-		secrets = append(secrets, SecretEntry{Name: c.Email, Secret: c.Secret})
+		secrets = append(secrets, SecretEntry{Name: c.Email, Secret: c.Secret, AdTag: usableAdTag(c.AdTag)})
 	}
 	if len(secrets) == 0 {
 		return Instance{}, false
@@ -214,12 +216,23 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		ThrottleMaxConnections: parsed.ThrottleMaxConnections,
 		RouteThroughXray:       parsed.RouteThroughXray,
 		XrayRoutePort:          parsed.RouteXrayPort,
-		AdTag:                  strings.TrimSpace(parsed.AdTag),
 		PublicIPv4:             strings.TrimSpace(parsed.PublicIPv4),
 		PublicIPv6:             strings.TrimSpace(parsed.PublicIPv6),
 	}, true
 }
 
+// usableAdTag returns a stored advertising tag only when it is well-formed.
+// The save paths validate tags, but settings can arrive from raw API payloads
+// or older data, and one malformed tag in a generated config makes mtg reject
+// the whole file — taking every client of the inbound down with it.
+func usableAdTag(tag string) string {
+	tag = strings.TrimSpace(tag)
+	if !model.ValidMtprotoAdTag(tag) {
+		return ""
+	}
+	return tag
+}
+
 // Ensure starts the mtg process for an instance, or restarts it when its
 // configuration changed. A no-op when the desired process is already running.
 func (m *Manager) Ensure(inst Instance) error {
@@ -277,10 +290,10 @@ func (m *Manager) ensureLocked(inst Instance) error {
 			cur.tag = inst.Tag
 			return nil
 		case ensureReload:
-			if err := writeConfig(configPathForID(inst.Id), inst, cur.apiPort); err != nil {
+			if err := writeConfig(configPathForID(inst.Id), inst, cur.apiPort, cur.apiToken); err != nil {
 				return err
 			}
-			if applySecrets(cur.apiPort, inst) {
+			if applySecrets(cur.apiPort, cur.apiToken, inst) {
 				cur.tag = inst.Tag
 				cur.secretsFP = secFP
 				logger.Infof("mtproto: applied secret update to inbound %d in place", inst.Id)
@@ -297,8 +310,12 @@ func (m *Manager) ensureLocked(inst Instance) error {
 	if err != nil {
 		return err
 	}
+	apiToken, err := newAPIToken()
+	if err != nil {
+		return err
+	}
 	cfgPath := configPathForID(inst.Id)
-	if err := writeConfig(cfgPath, inst, apiPort); err != nil {
+	if err := writeConfig(cfgPath, inst, apiPort, apiToken); err != nil {
 		return err
 	}
 	proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
@@ -311,6 +328,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
 		structuralFP: structFP,
 		secretsFP:    secFP,
 		apiPort:      apiPort,
+		apiToken:     apiToken,
 		last:         map[string]clientCounters{},
 	}
 	logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
@@ -370,10 +388,11 @@ func (m *Manager) StopAll() {
 // with at least one live connection.
 func (m *Manager) CollectTraffic() ([]Traffic, []string) {
 	type snap struct {
-		id      int
-		apiPort int
-		tag     string
-		last    map[string]clientCounters
+		id       int
+		apiPort  int
+		apiToken string
+		tag      string
+		last     map[string]clientCounters
 	}
 	m.mu.Lock()
 	snaps := make([]snap, 0, len(m.procs))
@@ -385,14 +404,14 @@ func (m *Manager) CollectTraffic() ([]Traffic, []string) {
 		for k, v := range cur.last {
 			lastCopy[k] = v
 		}
-		snaps = append(snaps, snap{id: id, apiPort: cur.apiPort, tag: cur.tag, last: lastCopy})
+		snaps = append(snaps, snap{id: id, apiPort: cur.apiPort, apiToken: cur.apiToken, tag: cur.tag, last: lastCopy})
 	}
 	m.mu.Unlock()
 
 	var out []Traffic
 	var online []string
 	for _, s := range snaps {
-		users, ok := scrapeStats(s.apiPort)
+		users, ok := scrapeStats(s.apiPort, s.apiToken)
 		if !ok {
 			continue
 		}
@@ -445,9 +464,11 @@ func FreeLocalPort() (int, error) {
 // renderConfig builds the mtg-multi TOML for an instance. Top-level keys must
 // precede any [section] header in TOML, and [secrets] must be the final section
 // so trailing keys are not swallowed by another table. The layout is therefore:
-// top-level scalars (incl. api-bind-to), then [domain-fronting], [network] and
-// [throttle], and finally [secrets] with one named secret per active client.
-func renderConfig(inst Instance, apiPort int) string {
+// top-level scalars (incl. api-bind-to and api-token), then [domain-fronting],
+// [network] and [throttle], then [secret-ad-tags] for clients overriding the
+// global advertising tag, and finally [secrets] with one named secret per
+// active client.
+func renderConfig(inst Instance, apiPort int, apiToken string) string {
 	var b strings.Builder
 	fmt.Fprintf(&b, "bind-to = %q\n", inst.bindTo())
 	if inst.Debug {
@@ -460,8 +481,8 @@ func renderConfig(inst Instance, apiPort int) string {
 		fmt.Fprintf(&b, "prefer-ip = %q\n", inst.PreferIP)
 	}
 	fmt.Fprintf(&b, "api-bind-to = \"127.0.0.1:%d\"\n", apiPort)
-	if inst.AdTag != "" {
-		fmt.Fprintf(&b, "ad-tag = %q\n", inst.AdTag)
+	if apiToken != "" {
+		fmt.Fprintf(&b, "api-token = %q\n", apiToken)
 	}
 	if inst.PublicIPv4 != "" {
 		fmt.Fprintf(&b, "public-ipv4 = %q\n", inst.PublicIPv4)
@@ -490,6 +511,20 @@ func renderConfig(inst Instance, apiPort int) string {
 	if inst.ThrottleMaxConnections > 0 {
 		fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
 	}
+	// Only clients present in [secrets] may appear here: mtg rejects a config
+	// whose [secret-ad-tags] names an unknown secret, so a disabled client's
+	// override must vanish together with its secret.
+	tagged := false
+	for _, e := range inst.Secrets {
+		if e.AdTag == "" {
+			continue
+		}
+		if !tagged {
+			b.WriteString("\n[secret-ad-tags]\n")
+			tagged = true
+		}
+		fmt.Fprintf(&b, "%q = %q\n", e.Name, e.AdTag)
+	}
 	b.WriteString("\n[secrets]\n")
 	for _, e := range inst.Secrets {
 		fmt.Fprintf(&b, "%q = %q\n", e.Name, e.Secret)
@@ -497,11 +532,11 @@ func renderConfig(inst Instance, apiPort int) string {
 	return b.String()
 }
 
-func writeConfig(path string, inst Instance, apiPort int) error {
+func writeConfig(path string, inst Instance, apiPort int, apiToken string) error {
 	if err := os.MkdirAll(configDir(), 0o750); err != nil {
 		return err
 	}
-	return os.WriteFile(path, []byte(renderConfig(inst, apiPort)), 0o640)
+	return os.WriteFile(path, []byte(renderConfig(inst, apiPort, apiToken)), 0o640)
 }
 
 // statsUser is one entry of the mtg-multi /stats users map. bytes_in is traffic
@@ -515,29 +550,50 @@ type statsUser struct {
 
 type secretPutEntry struct {
 	Secret string `json:"secret"`
+	AdTag  string `json:"ad_tag,omitempty"`
 }
 
 type secretsPutBody struct {
 	Secrets map[string]secretPutEntry `json:"secrets"`
-	AdTag   string                    `json:"ad_tag,omitempty"`
 }
 
 func secretsPayload(inst Instance) secretsPutBody {
 	secrets := make(map[string]secretPutEntry, len(inst.Secrets))
 	for _, e := range inst.Secrets {
-		secrets[e.Name] = secretPutEntry{Secret: e.Secret}
+		secrets[e.Name] = secretPutEntry{Secret: e.Secret, AdTag: e.AdTag}
+	}
+	return secretsPutBody{Secrets: secrets}
+}
+
+// newAPIToken mints the bearer token one mtg process and its manager share for
+// the lifetime of that process. The management API can replace the whole
+// secret set, so even though it only listens on loopback it must not be open
+// to every local process. The token lives in the generated config (mtg reads
+// it at startup only — a rewritten token would not apply until a restart,
+// which is why the reload path reuses the stored one) and in the manager's
+// memory, nowhere else.
+func newAPIToken() (string, error) {
+	buf := make([]byte, 16)
+	if _, err := rand.Read(buf); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(buf), nil
+}
+
+func authorize(req *http.Request, token string) {
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
 	}
-	return secretsPutBody{Secrets: secrets, AdTag: inst.AdTag}
 }
 
-// applySecrets pushes the desired secret set and advertising tag to a running
+// applySecrets pushes the desired secret set and advertising tags to a running
 // mtg-multi through its management API (PUT /secrets on the same loopback port
 // that serves /stats), so a client add, removal, re-key, or ad-tag change is
 // applied in place. mtg keeps every connection whose secret is unchanged and
 // closes only the removed or re-keyed ones. It returns true only on a 200: an
 // older binary without the endpoint (404), a refused connection, or any other
 // status yields false, so the caller falls back to a full restart.
-func applySecrets(port int, inst Instance) bool {
+func applySecrets(port int, token string, inst Instance) bool {
 	body, err := json.Marshal(secretsPayload(inst))
 	if err != nil {
 		return false
@@ -548,6 +604,7 @@ func applySecrets(port int, inst Instance) bool {
 		return false
 	}
 	req.Header.Set("Content-Type", "application/json")
+	authorize(req, token)
 	resp, err := client.Do(req)
 	if err != nil {
 		return false
@@ -559,12 +616,13 @@ func applySecrets(port int, inst Instance) bool {
 // scrapeStats reads the mtg-multi /stats JSON API and returns the per-user
 // cumulative counters. Best-effort: an unreachable endpoint or unparseable body
 // yields ok=false.
-func scrapeStats(port int) (map[string]statsUser, bool) {
+func scrapeStats(port int, token string) (map[string]statsUser, bool) {
 	client := http.Client{Timeout: 3 * time.Second}
 	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/stats", port), nil)
 	if err != nil {
 		return nil, false
 	}
+	authorize(req, token)
 	resp, err := client.Do(req)
 	if err != nil {
 		return nil, false

+ 6 - 2
internal/mtproto/manager_mutation_test.go

@@ -30,6 +30,10 @@ func TestScrapeStats(t *testing.T) {
 			http.NotFound(w, r)
 			return
 		}
+		if r.Header.Get("Authorization") != "Bearer sesame" {
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
 		_, _ = io.WriteString(w, `{"started_at":"2026-01-01T00:00:00Z","total_connections":2,`+
 			`"users":{`+
 			`"alice":{"connections":2,"bytes_in":100,"bytes_out":200,"last_seen":"2026-01-01T00:01:00Z"},`+
@@ -37,7 +41,7 @@ func TestScrapeStats(t *testing.T) {
 	}))
 	defer srv.Close()
 
-	users, ok := scrapeStats(serverPort(t, srv))
+	users, ok := scrapeStats(serverPort(t, srv), "sesame")
 	if !ok {
 		t.Fatal("scrapeStats should succeed against a valid /stats endpoint")
 	}
@@ -59,7 +63,7 @@ func TestScrapeStatsUnreachable(t *testing.T) {
 	port := serverPort(t, srv)
 	srv.Close()
 
-	if _, ok := scrapeStats(port); ok {
+	if _, ok := scrapeStats(port, ""); ok {
 		t.Fatal("scrapeStats must report ok=false when the endpoint is unreachable")
 	}
 }

+ 22 - 8
internal/mtproto/manager_reload_test.go

@@ -116,25 +116,32 @@ func TestApplySecrets(t *testing.T) {
 	}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			var gotMethod, gotPath string
+			var gotMethod, gotPath, gotAuth string
 			var gotBody secretsPutBody
 			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				gotMethod, gotPath = r.Method, r.URL.Path
+				gotMethod, gotPath, gotAuth = r.Method, r.URL.Path, r.Header.Get("Authorization")
 				_ = json.NewDecoder(r.Body).Decode(&gotBody)
 				w.WriteHeader(tc.status)
 			}))
 			defer srv.Close()
 
-			inst := mtgInst(1, SecretEntry{Name: "alice", Secret: "ee01"})
-			inst.AdTag = "0123456789abcdef0123456789abcdef"
-			if got := applySecrets(serverPort(t, srv), inst); got != tc.want {
+			inst := mtgInst(1,
+				SecretEntry{Name: "alice", Secret: "ee01"},
+				SecretEntry{Name: "bob", Secret: "ee02", AdTag: "fedcba9876543210fedcba9876543210"})
+			if got := applySecrets(serverPort(t, srv), "sesame", inst); got != tc.want {
 				t.Fatalf("applySecrets = %v, want %v", got, tc.want)
 			}
 			if gotMethod != http.MethodPut || gotPath != "/secrets" {
 				t.Fatalf("expected PUT /secrets, got %s %s", gotMethod, gotPath)
 			}
-			if gotBody.Secrets["alice"].Secret != "ee01" || gotBody.AdTag != "0123456789abcdef0123456789abcdef" {
-				t.Fatalf("payload must carry the secret and ad-tag: %+v", gotBody)
+			if gotAuth != "Bearer sesame" {
+				t.Fatalf("expected the bearer token on the request, got %q", gotAuth)
+			}
+			if gotBody.Secrets["alice"].Secret != "ee01" {
+				t.Fatalf("payload must carry the secret: %+v", gotBody)
+			}
+			if gotBody.Secrets["alice"].AdTag != "" || gotBody.Secrets["bob"].AdTag != "fedcba9876543210fedcba9876543210" {
+				t.Fatalf("payload must carry per-client ad-tags only where set: %+v", gotBody)
 			}
 		})
 	}
@@ -143,7 +150,7 @@ func TestApplySecrets(t *testing.T) {
 		srv := httptest.NewServer(http.NotFoundHandler())
 		port := serverPort(t, srv)
 		srv.Close()
-		if applySecrets(port, mtgInst(1, SecretEntry{Name: "a", Secret: "ee"})) {
+		if applySecrets(port, "", mtgInst(1, SecretEntry{Name: "a", Secret: "ee"})) {
 			t.Fatal("a refused connection must yield false")
 		}
 	})
@@ -159,6 +166,10 @@ func TestEnsureHotReloadKeepsProcess(t *testing.T) {
 	}
 	waitSpawnCount(t, pidFile, 1)
 	orig := mgr.procs[1].proc
+	origToken := mgr.procs[1].apiToken
+	if origToken == "" {
+		t.Fatal("a started process must get an api token")
+	}
 
 	reloaded := make(chan struct{}, 1)
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -201,6 +212,9 @@ func TestEnsureHotReloadKeepsProcess(t *testing.T) {
 	if !strings.Contains(string(cfg), fmt.Sprintf("api-bind-to = \"127.0.0.1:%d\"", serverPort(t, srv))) {
 		t.Fatalf("reload must reuse the same api port:\n%s", cfg)
 	}
+	if !strings.Contains(string(cfg), fmt.Sprintf("api-token = %q", origToken)) {
+		t.Fatalf("reload must reuse the token the running process was started with:\n%s", cfg)
+	}
 	mgr.StopAll()
 }
 

+ 42 - 13
internal/mtproto/manager_test.go

@@ -20,8 +20,9 @@ func TestInstanceFromInbound(t *testing.T) {
 			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
 			`"throttleMaxConnections":5000,` +
 			`"routeThroughXray":true,"routeXrayPort":50000,` +
+			`"adTag":" 0123456789abcdef0123456789abcdef ",` +
 			`"clients":[` +
-			`{"email":"alice","secret":"` + aliceSecret + `","enable":true},` +
+			`{"email":"alice","secret":"` + aliceSecret + `","adTag":"fedcba9876543210fedcba9876543210","enable":true},` +
 			`{"email":"bob","secret":"","enable":true},` +
 			`{"email":"carol","secret":"eeaa","enable":false}]}`,
 	}
@@ -38,6 +39,9 @@ func TestInstanceFromInbound(t *testing.T) {
 	if inst.Secrets[0].Secret != aliceSecret {
 		t.Fatalf("a valid secret must be preserved, got %q", inst.Secrets[0].Secret)
 	}
+	if inst.Secrets[0].AdTag != "fedcba9876543210fedcba9876543210" {
+		t.Fatalf("the client ad-tag must be parsed, got %q", inst.Secrets[0].AdTag)
+	}
 	if inst.Port != 8443 || inst.Id != 3 {
 		t.Fatalf("bad instance %+v", inst)
 	}
@@ -62,6 +66,15 @@ func TestInstanceFromInbound(t *testing.T) {
 	if _, ok := InstanceFromInbound(noSecrets); ok {
 		t.Fatal("an inbound with no active secret should not produce an instance")
 	}
+
+	badTags := &model.Inbound{Protocol: model.MTProto, Settings: `{"clients":[{"email":"x","secret":"ee00","adTag":"deadbeef","enable":true}]}`}
+	badInst, ok := InstanceFromInbound(badTags)
+	if !ok {
+		t.Fatal("expected a usable instance despite a malformed ad tag")
+	}
+	if badInst.Secrets[0].AdTag != "" {
+		t.Fatalf("a malformed ad tag must be dropped so the generated config stays valid, got %q", badInst.Secrets[0].AdTag)
+	}
 }
 
 func TestRenderConfig(t *testing.T) {
@@ -70,8 +83,8 @@ func TestRenderConfig(t *testing.T) {
 	bare := renderConfig(Instance{
 		Secrets: []SecretEntry{{Name: "alice", Secret: "ee00"}},
 		Listen:  "0.0.0.0", Port: 8443,
-	}, 5000)
-	for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]"} {
+	}, 5000, "")
+	for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[secret-ad-tags]", "api-token"} {
 		if strings.Contains(bare, unwanted) {
 			t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
 		}
@@ -87,22 +100,25 @@ func TestRenderConfig(t *testing.T) {
 	}
 
 	// A fully configured instance emits every option, the fronting section (as
-	// host, not the fork-deprecated ip), the throttle block, and [secrets] last.
+	// host, not the fork-deprecated ip), the throttle block, the per-client
+	// ad-tag overrides, and [secrets] last.
 	full := renderConfig(Instance{
-		Secrets: []SecretEntry{{Name: "alice", Secret: "ee11"}},
-		Listen:  "0.0.0.0", Port: 443,
+		Secrets: []SecretEntry{
+			{Name: "alice", Secret: "ee11"},
+			{Name: "bob", Secret: "ee22", AdTag: "fedcba9876543210fedcba9876543210"},
+		},
+		Listen: "0.0.0.0", Port: 443,
 		Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
 		FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
 		ThrottleMaxConnections: 5000,
-		AdTag:                  "0123456789abcdef0123456789abcdef",
 		PublicIPv4:             "1.2.3.4",
 		PublicIPv6:             "2001:db8::1",
-	}, 6000)
+	}, 6000, "sesame")
 	for _, want := range []string{
 		"debug = true\n",
 		"proxy-protocol-listener = true\n",
 		`prefer-ip = "only-ipv6"`,
-		`ad-tag = "0123456789abcdef0123456789abcdef"`,
+		`api-token = "sesame"`,
 		`public-ipv4 = "1.2.3.4"`,
 		`public-ipv6 = "2001:db8::1"`,
 		"[domain-fronting]",
@@ -111,6 +127,8 @@ func TestRenderConfig(t *testing.T) {
 		"proxy-protocol = true\n",
 		"[throttle]",
 		"max-connections = 5000",
+		"[secret-ad-tags]",
+		`"bob" = "fedcba9876543210fedcba9876543210"`,
 	} {
 		if !strings.Contains(full, want) {
 			t.Fatalf("full config missing %q:\n%s", want, full)
@@ -119,6 +137,12 @@ func TestRenderConfig(t *testing.T) {
 	if strings.Contains(full, `ip = "127.0.0.1"`) {
 		t.Fatalf("domain-fronting must use host, not the deprecated ip key:\n%s", full)
 	}
+	if strings.Contains(full, "ad-tag =") {
+		t.Fatalf("no global ad-tag must be emitted, tags are per client:\n%s", full)
+	}
+	if strings.Contains(full, `"alice" = "0123456789abcdef0123456789abcdef"`) || strings.Contains(full, `"alice" = ""`) {
+		t.Fatalf("a client without a tag must not appear in [secret-ad-tags]:\n%s", full)
+	}
 	// TOML requires top-level keys before any [section] header, and [secrets]
 	// must be the final section so trailing keys are not swallowed by a table.
 	if strings.Index(full, "prefer-ip") > strings.Index(full, "[domain-fronting]") {
@@ -130,6 +154,9 @@ func TestRenderConfig(t *testing.T) {
 	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[throttle]") {
 		t.Fatalf("[throttle] must precede [secrets]:\n%s", full)
 	}
+	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[secret-ad-tags]") {
+		t.Fatalf("[secret-ad-tags] must precede [secrets]:\n%s", full)
+	}
 }
 
 func TestRenderConfigXrayEgress(t *testing.T) {
@@ -139,7 +166,7 @@ func TestRenderConfigXrayEgress(t *testing.T) {
 		Secrets: []SecretEntry{{Name: "a", Secret: "ee22"}},
 		Listen:  "0.0.0.0", Port: 443,
 		RouteThroughXray: true, XrayRoutePort: 50000,
-	}, 7000)
+	}, 7000, "")
 	if !strings.Contains(routed, "[network]") ||
 		!strings.Contains(routed, `proxies = ["socks5://127.0.0.1:50000"]`) {
 		t.Fatalf("routed config must emit the SOCKS upstream:\n%s", routed)
@@ -153,7 +180,7 @@ func TestRenderConfigXrayEgress(t *testing.T) {
 		{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443},
 		{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443, RouteThroughXray: true},
 	} {
-		if got := renderConfig(inst, 7000); strings.Contains(got, "[network]") {
+		if got := renderConfig(inst, 7000, ""); strings.Contains(got, "[network]") {
 			t.Fatalf("unrouted config must omit [network]:\n%s", got)
 		}
 	}
@@ -194,7 +221,9 @@ func TestFingerprintSplit(t *testing.T) {
 		"rekey":  func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a", Secret: "ee99"}} },
 		"remove": func(i *Instance) { i.Secrets = nil },
 		"rename": func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a2", Secret: "ee"}} },
-		"adTag":  func(i *Instance) { i.AdTag = "0123456789abcdef0123456789abcdef" },
+		"clientAdTag": func(i *Instance) {
+			i.Secrets = []SecretEntry{{Name: "a", Secret: "ee", AdTag: "0123456789abcdef0123456789abcdef"}}
+		},
 	} {
 		t.Run("secrets/"+name, func(t *testing.T) {
 			changed := base
@@ -212,7 +241,7 @@ func TestFingerprintSplit(t *testing.T) {
 	t.Run("orderInsensitive", func(t *testing.T) {
 		forward := Instance{Secrets: []SecretEntry{{Name: "alice", Secret: "ee11"}, {Name: "bob", Secret: "ee22"}}}
 		reversed := Instance{Secrets: []SecretEntry{{Name: "bob", Secret: "ee22"}, {Name: "alice", Secret: "ee11"}}}
-		if got, want := forward.secretsFingerprint(), "adtag=|alice=ee11|bob=ee22"; got != want {
+		if got, want := forward.secretsFingerprint(), "alice=ee11;tag=|bob=ee22;tag="; got != want {
 			t.Fatalf("secrets fingerprint must join sorted pairs: got %q, want %q", got, want)
 		}
 		if forward.secretsFingerprint() != reversed.secretsFingerprint() {

+ 2 - 0
internal/sub/endpoint.go

@@ -90,6 +90,7 @@ func (s *SubService) buildEndpointLinks(
 		applyEndpointTLSParams(e, nextParams, securityToApply)
 		applyEndpointRealityParams(e, nextParams, securityToApply)
 		applyEndpointHostPath(e, nextParams)
+		applyEndpointFinalMask(e, nextParams)
 		applyEndpointAllowInsecure(e, nextParams, securityToApply)
 		links = append(links, buildLinkWithParamsAndSecurity(
 			makeLink(e),
@@ -119,6 +120,7 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
 		}
 		applyEndpointTLSObj(e, newObj, securityToApply)
 		applyEndpointHostPathObj(e, newObj)
+		applyEndpointFinalMaskObj(e, newObj)
 		if index > 0 {
 			links.WriteString("\n")
 		}

+ 44 - 0
internal/sub/endpoint_test.go

@@ -1,7 +1,11 @@
 package sub
 
 import (
+	"encoding/base64"
+	"encoding/json"
 	"fmt"
+	"net/url"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -111,3 +115,43 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 		t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
 	}
 }
+
+// N5 — a host's Final Mask is appended to the inbound's own fm param (#5831).
+func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) {
+	s := &SubService{}
+	in := &model.Inbound{Remark: "ib"}
+	params := map[string]string{"type": "tcp", "security": "tls", "fm": `{"tcp":[{"type":"sudoku"}]}`}
+	eps := []ShareEndpoint{
+		externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"tcp":[{"type":"fragment"}]}`}),
+	}
+	got := s.buildEndpointLinks(eps, params, "tls",
+		func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) },
+		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
+	)
+	wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`)
+	if !strings.Contains(got, wantFm) {
+		t.Fatalf("host finalMask not merged into the fm param.\n got: %q\nwant substring: %q", got, wantFm)
+	}
+}
+
+// N6 — same for the VMess object form: the host mask lands in obj["fm"].
+func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) {
+	s := &SubService{}
+	in := &model.Inbound{Remark: "ib"}
+	baseObj := map[string]any{"v": "2", "add": "base.example.com", "port": 443, "type": "none", "id": "uid", "scy": "auto", "net": "tcp", "tls": "tls"}
+	eps := []ShareEndpoint{
+		externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"udp":[{"type":"salamander"}]}`}),
+	}
+	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
+	raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(got, "vmess://"))
+	if err != nil {
+		t.Fatalf("decode vmess link: %v", err)
+	}
+	var obj map[string]any
+	if err := json.Unmarshal(raw, &obj); err != nil {
+		t.Fatalf("unmarshal vmess obj: %v", err)
+	}
+	if fm, _ := obj["fm"].(string); fm != `{"udp":[{"type":"salamander"}]}` {
+		t.Fatalf("vmess fm = %q, want the host mask", obj["fm"])
+	}
+}

+ 38 - 0
internal/sub/host_sub.go

@@ -305,6 +305,44 @@ func applyEndpointAllowInsecure(e ShareEndpoint, params map[string]string, secur
 	}
 }
 
+// applyEndpointFinalMask merges a host's Final Mask into the raw link's fm
+// param, mirroring the applyHostStreamOverrides merge on the JSON/Clash path.
+func applyEndpointFinalMask(e ShareEndpoint, params map[string]string) {
+	if merged, ok := endpointFinalMask(e, params["fm"]); ok {
+		params["fm"] = merged
+	}
+}
+
+// applyEndpointFinalMaskObj is applyEndpointFinalMask for the VMess object form.
+func applyEndpointFinalMaskObj(e ShareEndpoint, obj map[string]any) {
+	baseFm, _ := obj["fm"].(string)
+	if merged, ok := endpointFinalMask(e, baseFm); ok {
+		obj["fm"] = merged
+	}
+}
+
+func endpointFinalMask(e ShareEndpoint, baseFm string) (string, bool) {
+	if e.ep == nil {
+		return "", false
+	}
+	fm, ok := e.ep["finalMask"].(string)
+	if !ok || fm == "" {
+		return "", false
+	}
+	var masks map[string]any
+	if json.Unmarshal([]byte(fm), &masks) != nil || len(masks) == 0 {
+		return "", false
+	}
+	var base any
+	if baseFm != "" {
+		var baseMap map[string]any
+		if json.Unmarshal([]byte(baseFm), &baseMap) == nil {
+			base = baseMap
+		}
+	}
+	return marshalFinalMask(mergeFinalMask(base, masks))
+}
+
 // applyEndpointHostPathObj is applyEndpointHostPath for the VMess object form.
 func applyEndpointHostPathObj(e ShareEndpoint, obj map[string]any) {
 	if e.ep == nil {

+ 23 - 0
internal/sub/host_sub_test.go

@@ -2,6 +2,7 @@ package sub
 
 import (
 	"fmt"
+	"net/url"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -270,6 +271,28 @@ func TestSub_HostAllowInsecure(t *testing.T) {
 	}
 }
 
+// A host's Final Mask reaches the raw share link as the fm param, merged with
+// any inbound-level mask (#5831).
+func TestSub_HostFinalMask_RawLink(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "fmh", 4455, 1,
+		`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm.cdn.com", Port: 8443, Security: "tls",
+		FinalMask: `{"tcp":[{"type":"fragment"}]}`,
+	})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	joined := strings.Join(links, "\n")
+	wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`)
+	if !strings.Contains(joined, wantFm) {
+		t.Fatalf("raw link should merge the host Final Mask into fm.\n got: %s\nwant substring: %s", joined, wantFm)
+	}
+}
+
 // A host's sockoptParams is injected into the JSON output stream (sockopt is
 // stripped from the base stream, re-added per host).
 func TestSub_HostSockoptJSON(t *testing.T) {

+ 56 - 47
internal/web/job/ldap_sync_job.go

@@ -4,8 +4,6 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/uuid"
-
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
@@ -114,52 +112,41 @@ func (j *LdapSyncJob) Run() {
 	defExpiryDays := mustGetInt(j.settingService.GetLdapDefaultExpiryDays)
 	defLimitIP := mustGetInt(j.settingService.GetLdapDefaultLimitIP)
 
-	clientsToCreate := map[string][]model.Client{} // tag -> []new clients
-	clientsToEnable := map[string][]string{}       // tag -> []email
-	clientsToDisable := map[string][]string{}      // tag -> []email
-
-	for email, allowed := range flags {
-		exists := allClients[email] != nil
-		for _, tag := range inboundTags {
-			if !exists && allowed && autoCreate {
-				newClient := j.buildClient(inboundMap[tag], email, defGB, defExpiryDays, defLimitIP)
-				clientsToCreate[tag] = append(clientsToCreate[tag], newClient)
-			} else if exists {
-				if allowed && !allClients[email].Enable {
-					clientsToEnable[tag] = append(clientsToEnable[tag], email)
-				} else if !allowed && allClients[email].Enable {
-					clientsToDisable[tag] = append(clientsToDisable[tag], email)
-				}
-			}
+	resolvedInboundIds := make([]int, 0, len(inboundTags))
+	resolvedTags := make([]string, 0, len(inboundTags))
+	for _, tag := range inboundTags {
+		ib := inboundMap[tag]
+		if ib == nil {
+			logger.Warningf("LDAP inbound tag %s does not match any inbound", tag)
+			continue
 		}
+		resolvedInboundIds = append(resolvedInboundIds, ib.Id)
+		resolvedTags = append(resolvedTags, tag)
 	}
 
-	for tag, newClients := range clientsToCreate {
-		if len(newClients) == 0 {
-			continue
-		}
-		ib := inboundMap[tag]
-		created := 0
-		restartNeeded := false
-		for _, c := range newClients {
-			nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
-			if err != nil {
-				logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err)
-				continue
-			}
-			created++
-			if nr {
-				restartNeeded = true
+	clientsToCreate := []model.Client{}
+	clientsToEnable := map[string][]string{}  // tag -> []email
+	clientsToDisable := map[string][]string{} // tag -> []email
+
+	for email, allowed := range flags {
+		existing := allClients[email]
+		if existing == nil {
+			if allowed && autoCreate {
+				clientsToCreate = append(clientsToCreate, j.buildClient(email, defGB, defExpiryDays, defLimitIP))
 			}
+			continue
 		}
-		if created > 0 {
-			logger.Infof("LDAP auto-create: %d clients for %s", created, tag)
-			if restartNeeded {
-				j.xrayService.SetToNeedRestart()
+		for _, tag := range resolvedTags {
+			if allowed && !existing.Enable {
+				clientsToEnable[tag] = append(clientsToEnable[tag], email)
+			} else if !allowed && existing.Enable {
+				clientsToDisable[tag] = append(clientsToDisable[tag], email)
 			}
 		}
 	}
 
+	j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
+
 	// --- Execute enable/disable batch ---
 	for tag, emails := range clientsToEnable {
 		j.batchSetEnable(inboundMap[tag], emails, true)
@@ -196,8 +183,8 @@ func splitCsv(s string) []string {
 	return out
 }
 
-// buildClient creates a new client for auto-create
-func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExpiryDays, defLimitIP int) model.Client {
+// buildClient creates a new client for auto-create; ClientService.Create fills per-protocol credentials
+func (j *LdapSyncJob) buildClient(email string, defGB, defExpiryDays, defLimitIP int) model.Client {
 	c := model.Client{
 		Email:   email,
 		Enable:  true,
@@ -207,15 +194,37 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp
 	if defExpiryDays > 0 {
 		c.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli()
 	}
-	switch ib.Protocol {
-	case model.Trojan, model.Shadowsocks:
-		c.Password = uuid.NewString()
-	default:
-		c.ID = uuid.NewString()
-	}
 	return c
 }
 
+// createClients adds each new LDAP client once, attached to every configured inbound
+func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int, tags []string) {
+	if len(newClients) == 0 || len(inboundIds) == 0 {
+		return
+	}
+	tagList := strings.Join(tags, ",")
+	created := 0
+	restartNeeded := false
+	for _, c := range newClients {
+		nr, err := j.clientService.Create(&j.inboundService, &service.ClientCreatePayload{Client: c, InboundIds: inboundIds})
+		if err != nil {
+			logger.Warningf("Failed to add client %s for tags %s: %v", c.Email, tagList, err)
+			continue
+		}
+		created++
+		if nr {
+			restartNeeded = true
+		}
+	}
+	if created == 0 {
+		return
+	}
+	logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
+	if restartNeeded {
+		j.xrayService.SetToNeedRestart()
+	}
+}
+
 func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
 	if len(emails) == 0 {
 		return

+ 85 - 0
internal/web/job/ldap_sync_job_test.go

@@ -0,0 +1,85 @@
+package job
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+func initLdapJobDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+func TestLdapCreateClients_AttachesToAllConfiguredInbounds(t *testing.T) {
+	initLdapJobDB(t)
+	db := database.GetDB()
+
+	tags := []string{"in-1080-tcp", "in-1081-tcp", "in-1082-tcp"}
+	protocols := []model.Protocol{model.VLESS, model.Trojan, model.VLESS}
+	inboundIds := make([]int, 0, len(tags))
+	for i, tag := range tags {
+		ib := &model.Inbound{
+			UserId:         1,
+			Tag:            tag,
+			Enable:         true,
+			Port:           42080 + i,
+			Protocol:       protocols[i],
+			Settings:       `{"clients": []}`,
+			StreamSettings: `{"network":"tcp","security":"none"}`,
+		}
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", tag, err)
+		}
+		inboundIds = append(inboundIds, ib.Id)
+	}
+
+	j := NewLdapSyncJob()
+	const email = "[email protected]"
+	j.createClients([]model.Client{j.buildClient(email, 0, 0, 0)}, inboundIds, tags)
+
+	rec := &model.ClientRecord{}
+	if err := db.Where("email = ?", email).First(rec).Error; err != nil {
+		t.Fatalf("client record for %s not created: %v", email, err)
+	}
+	if rec.SubID == "" {
+		t.Error("created LDAP client must carry a subId")
+	}
+
+	clientSvc := &service.ClientService{}
+	for i, id := range inboundIds {
+		clients, err := clientSvc.ListForInbound(nil, id)
+		if err != nil {
+			t.Fatalf("ListForInbound(%s): %v", tags[i], err)
+		}
+		if len(clients) != 1 || clients[0].Email != email {
+			t.Fatalf("inbound %s must carry exactly the LDAP client, got %d clients", tags[i], len(clients))
+		}
+		if clients[0].SubID != rec.SubID {
+			t.Errorf("inbound %s client subId = %q, want the shared %q", tags[i], clients[0].SubID, rec.SubID)
+		}
+	}
+
+	trojanClients, err := clientSvc.ListForInbound(nil, inboundIds[1])
+	if err != nil {
+		t.Fatalf("ListForInbound(trojan): %v", err)
+	}
+	if trojanClients[0].Password == "" {
+		t.Error("trojan inbound client must get a generated password")
+	}
+	vlessClients, err := clientSvc.ListForInbound(nil, inboundIds[0])
+	if err != nil {
+		t.Fatalf("ListForInbound(vless): %v", err)
+	}
+	if vlessClients[0].ID == "" {
+		t.Error("vless inbound client must get a generated uuid")
+	}
+}

+ 15 - 0
internal/web/service/client_crud.go

@@ -435,6 +435,15 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		return needRestart, err
 	}
 
+	// Same shape as the group write above: SyncInbound keeps a stored ad-tag
+	// when the incoming settings carry none, so clearing the override must be
+	// applied here, where the editor always round-trips the field.
+	if err := database.GetDB().Model(&model.ClientRecord{}).
+		Where("id = ?", id).
+		UpdateColumn("ad_tag", updated.AdTag).Error; err != nil {
+		return needRestart, err
+	}
+
 	if err := database.GetDB().Model(&model.ClientRecord{}).
 		Where("id = ?", id).
 		UpdateColumn("enable", updated.Enable).Error; err != nil {
@@ -514,6 +523,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 			if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
 				return err
 			}
+			if err := tx.Where("email = ?", existing.Email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+				return err
+			}
 		}
 		return tx.Delete(&model.ClientRecord{}, id).Error
 	}); err != nil {
@@ -657,6 +669,9 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 		if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil {
 			return needRestart, err
 		}
+		if err := db.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+			return needRestart, err
+		}
 	}
 	return needRestart, nil
 }

+ 95 - 0
internal/web/service/client_delete_node_baseline_test.go

@@ -0,0 +1,95 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestClientDelete_CleansNodeBaselines(t *testing.T) {
+	db := initTrafficTestDB(t)
+
+	const email = "[email protected]"
+	rec := &model.ClientRecord{Email: email, SubID: "sub-orphan", Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("seed client record: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 20}).Error; err != nil {
+		t.Fatalf("seed node baseline 1: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 30, Down: 40}).Error; err != nil {
+		t.Fatalf("seed node baseline 2: %v", err)
+	}
+
+	svc := &ClientService{}
+	if _, err := svc.Delete(&InboundService{}, rec.Id, false); err != nil {
+		t.Fatalf("Delete: %v", err)
+	}
+
+	var cnt int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if cnt != 0 {
+		t.Errorf("expected node baselines cleaned on client delete, found %d", cnt)
+	}
+}
+
+func TestClientDelete_KeepTrafficPreservesNodeBaselines(t *testing.T) {
+	db := initTrafficTestDB(t)
+
+	const email = "[email protected]"
+	rec := &model.ClientRecord{Email: email, SubID: "sub-kept", Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("seed client record: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 20}).Error; err != nil {
+		t.Fatalf("seed node baseline: %v", err)
+	}
+
+	svc := &ClientService{}
+	if _, err := svc.Delete(&InboundService{}, rec.Id, true); err != nil {
+		t.Fatalf("Delete(keepTraffic): %v", err)
+	}
+
+	var cnt int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if cnt != 1 {
+		t.Errorf("keepTraffic delete must preserve node baselines, found %d", cnt)
+	}
+}
+
+func TestDeleteByEmail_FallbackCleansNodeBaselines(t *testing.T) {
+	db := initTrafficTestDB(t)
+
+	const email = "[email protected]"
+	ib := &model.Inbound{
+		UserId:   1,
+		Tag:      "local-fallback",
+		Enable:   true,
+		Port:     41001,
+		Protocol: model.VLESS,
+		Settings: `{"clients": [{"id": "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a001", "email": "[email protected]", "enable": true}]}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: 3, Email: email, Up: 5, Down: 6}).Error; err != nil {
+		t.Fatalf("seed node baseline: %v", err)
+	}
+
+	svc := &ClientService{}
+	if _, err := svc.DeleteByEmail(&InboundService{}, email, false); err != nil {
+		t.Fatalf("DeleteByEmail: %v", err)
+	}
+
+	var cnt int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if cnt != 0 {
+		t.Errorf("expected node baselines cleaned on record-less delete, found %d", cnt)
+	}
+}

+ 6 - 0
internal/web/service/client_inbound_apply.go

@@ -388,6 +388,9 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 			if client.Secret == "" {
 				return false, common.NewError("mtproto client requires a secret")
 			}
+			if client.AdTag != "" && !model.ValidMtprotoAdTag(client.AdTag) {
+				return false, common.NewError("mtproto client ad tag must be 32 hex characters")
+			}
 		default:
 			if client.ID == "" {
 				return false, common.NewError("empty client ID")
@@ -579,6 +582,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	if strings.TrimSpace(clients[0].Email) == "" {
 		return false, common.NewError("client email is required")
 	}
+	if oldInbound.Protocol == model.MTProto && clients[0].AdTag != "" && !model.ValidMtprotoAdTag(clients[0].AdTag) {
+		return false, common.NewError("mtproto client ad tag must be 32 hex characters")
+	}
 
 	if clients[0].Email != oldEmail {
 		existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, nil)

+ 6 - 0
internal/web/service/client_link.go

@@ -75,6 +75,12 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 		if incoming.Auth != "" {
 			row.Auth = incoming.Auth
 		}
+		if incoming.Secret != "" {
+			row.Secret = incoming.Secret
+		}
+		if incoming.AdTag != "" {
+			row.AdTag = incoming.AdTag
+		}
 		row.Flow = incoming.Flow
 		if incoming.Security != "" {
 			row.Security = incoming.Security

+ 70 - 0
internal/web/service/client_sync_mtproto_test.go

@@ -0,0 +1,70 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestSyncInbound_UpdatesMtprotoSecretAndAdTag(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	mtproto := &model.Inbound{Tag: "mtproto-in", Enable: true, Port: 10004, Protocol: model.MTProto}
+	if err := db.Create(mtproto).Error; err != nil {
+		t.Fatalf("create mtproto inbound: %v", err)
+	}
+
+	svc := ClientService{}
+	const email = "[email protected]"
+	const firstSecret = "ee0123456789abcdef0123456789abcdef6578616d706c652e636f6d"
+	const rekeyedSecret = "eefedcba9876543210fedcba98765432106578616d706c652e636f6d"
+	const firstTag = "0123456789abcdef0123456789abcdef"
+	const retaggedTag = "fedcba9876543210fedcba9876543210"
+
+	first := model.Client{Email: email, Secret: firstSecret, AdTag: firstTag, Enable: true}
+	if err := svc.SyncInbound(nil, mtproto.Id, []model.Client{first}); err != nil {
+		t.Fatalf("SyncInbound (create): %v", err)
+	}
+
+	var row model.ClientRecord
+	if err := db.Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("lookup client row: %v", err)
+	}
+	if row.Secret != firstSecret || row.AdTag != firstTag {
+		t.Fatalf("create must store secret and ad tag: got secret=%q adTag=%q", row.Secret, row.AdTag)
+	}
+
+	rekeyed := model.Client{Email: email, Secret: rekeyedSecret, AdTag: retaggedTag, Enable: true}
+	if err := svc.SyncInbound(nil, mtproto.Id, []model.Client{rekeyed}); err != nil {
+		t.Fatalf("SyncInbound (rekey): %v", err)
+	}
+	if err := db.Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("lookup client row after rekey: %v", err)
+	}
+	if row.Secret != rekeyedSecret {
+		t.Errorf("a re-keyed secret must reach the client record (sub links and the clients page read it), got %q", row.Secret)
+	}
+	if row.AdTag != retaggedTag {
+		t.Errorf("a changed ad tag must reach the client record, got %q", row.AdTag)
+	}
+
+	secretless := model.Client{Email: email, Enable: true}
+	if err := svc.SyncInbound(nil, mtproto.Id, []model.Client{secretless}); err != nil {
+		t.Fatalf("SyncInbound (secretless): %v", err)
+	}
+	if err := db.Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("lookup client row after secretless sync: %v", err)
+	}
+	if row.Secret != rekeyedSecret || row.AdTag != retaggedTag {
+		t.Errorf("a payload without mtproto fields must not wipe them: got secret=%q adTag=%q", row.Secret, row.AdTag)
+	}
+}

+ 9 - 3
internal/web/service/inbound.go

@@ -532,9 +532,9 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 
 // normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
 // always valid before the row is persisted, and drops the vestigial inbound-level
-// secret: MTProto is multi-client, so mtg and every share link read only the
-// per-client secrets. Leaving an inbound-level secret behind is what produced
-// stale links that failed with "incorrect client random".
+// secret and adTag: MTProto is multi-client, so mtg and every share link read
+// only the per-client values. Leaving an inbound-level secret behind is what
+// produced stale links that failed with "incorrect client random".
 func (s *InboundService) normalizeMtprotoSecret(inbound *model.Inbound) {
 	if inbound.Protocol != model.MTProto {
 		return
@@ -542,6 +542,9 @@ func (s *InboundService) normalizeMtprotoSecret(inbound *model.Inbound) {
 	if stripped, ok := model.StripMtprotoInboundSecret(inbound.Settings); ok {
 		inbound.Settings = stripped
 	}
+	if stripped, ok := model.StripMtprotoInboundAdTag(inbound.Settings); ok {
+		inbound.Settings = stripped
+	}
 	if healed, ok := model.HealMtprotoClientSecrets(inbound.Settings); ok {
 		inbound.Settings = healed
 	}
@@ -739,6 +742,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 			if client.Secret == "" {
 				return inbound, false, common.NewError("mtproto client requires a secret")
 			}
+			if client.AdTag != "" && !model.ValidMtprotoAdTag(client.AdTag) {
+				return inbound, false, common.NewError("mtproto client ad tag must be 32 hex characters")
+			}
 		default:
 			if client.ID == "" {
 				return inbound, false, common.NewError("empty client ID")

+ 61 - 26
internal/web/service/inbound_node.go

@@ -196,6 +196,18 @@ func mergeActivationExpiry(existing, node int64) int64 {
 	return node
 }
 
+// nodeClientRenewed reports a node-side auto-renew: an absolute deadline moved
+// forward while the node's cumulative counter fell below the stored baseline.
+func nodeClientRenewed(existing *xray.ClientTraffic, cs xray.ClientTraffic, canon, base nodeTrafficCounter) bool {
+	if cs.Reset <= 0 || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 {
+		return false
+	}
+	if cs.ExpiryTime <= existing.ExpiryTime {
+		return false
+	}
+	return canon.Up < base.Up || canon.Down < base.Down
+}
+
 // liftActivatedClientRecordExpiries copies a node-activated deadline from
 // client_traffics onto client records still holding the negative duration (#5714).
 func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
@@ -728,7 +740,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				continue
 			}
 
-			if existing := centralCSByEmail[cs.Email]; existing != nil &&
+			existing := centralCSByEmail[cs.Email]
+			if existing != nil &&
 				(existing.Enable != cs.Enable ||
 					existing.Total != cs.Total ||
 					existing.ExpiryTime != mergeActivationExpiry(existing.ExpiryTime, cs.ExpiryTime) ||
@@ -736,31 +749,53 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				structuralChange = true
 			}
 
-			enableExpr := database.ClientTrafficEnableMergeExpr()
-			// expiry_time merge mirrors mergeActivationExpiry: a node that has not
-			// yet seen the client's first connection keeps reporting the negative
-			// "start after first connect" duration, which must never reset the
-			// absolute deadline another node already activated. A positive node
-			// value is still adopted (e.g. auto-renew moves the deadline forward).
-			// CAST(? AS BIGINT): in the `<= 0` comparison Postgres would otherwise
-			// infer int4 from the literal and overflow on real expiry values.
-			if err := tx.Exec(
-				fmt.Sprintf(
-					`UPDATE client_traffics
-					 SET up = %s, down = %s, enable = %s, total = ?,
-					     expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
-					     reset = ?, last_online = %s
-					 WHERE email = ?`,
-					database.ClampedAddExpr("up"),
-					database.ClampedAddExpr("down"),
-					enableExpr,
-					database.GreatestExpr("last_online", "?"),
-				),
-				deltaUp, deltaDown, cs.Enable, cs.Total,
-				cs.ExpiryTime, cs.ExpiryTime, cs.Reset,
-				cs.LastOnline, cs.Email,
-			).Error; err != nil {
-				return false, err
+			if seen && existing != nil && nodeClientRenewed(existing, cs, canon, base) {
+				// A renewal starts a fresh quota window: adopt the node's counters
+				// and enable state, drop stale pushes (mirrors autoRenewClients).
+				if err := tx.Exec(
+					fmt.Sprintf(
+						`UPDATE client_traffics
+						 SET up = ?, down = ?, enable = ?, total = ?,
+						     expiry_time = ?, reset = ?, last_online = %s
+						 WHERE email = ?`,
+						database.GreatestExpr("last_online", "?"),
+					),
+					canon.Up, canon.Down, cs.Enable, cs.Total,
+					cs.ExpiryTime, cs.Reset,
+					cs.LastOnline, cs.Email,
+				).Error; err != nil {
+					return false, err
+				}
+				if err := clearGlobalTraffic(tx, cs.Email); err != nil {
+					return false, err
+				}
+			} else {
+				enableExpr := database.ClientTrafficEnableMergeExpr()
+				// expiry_time merge mirrors mergeActivationExpiry: a node that has not
+				// yet seen the client's first connection keeps reporting the negative
+				// "start after first connect" duration, which must never reset the
+				// absolute deadline another node already activated. A positive node
+				// value is still adopted (e.g. auto-renew moves the deadline forward).
+				// CAST(? AS BIGINT): in the `<= 0` comparison Postgres would otherwise
+				// infer int4 from the literal and overflow on real expiry values.
+				if err := tx.Exec(
+					fmt.Sprintf(
+						`UPDATE client_traffics
+						 SET up = %s, down = %s, enable = %s, total = ?,
+						     expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
+						     reset = ?, last_online = %s
+						 WHERE email = ?`,
+						database.ClampedAddExpr("up"),
+						database.ClampedAddExpr("down"),
+						enableExpr,
+						database.GreatestExpr("last_online", "?"),
+					),
+					deltaUp, deltaDown, cs.Enable, cs.Total,
+					cs.ExpiryTime, cs.ExpiryTime, cs.Reset,
+					cs.LastOnline, cs.Email,
+				).Error; err != nil {
+					return false, err
+				}
 			}
 			if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
 				return false, err

+ 109 - 0
internal/web/service/node_client_renew_sync_test.go

@@ -0,0 +1,109 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+const (
+	renewFirstExpiry  = int64(1893456000000)
+	renewSecondExpiry = renewFirstExpiry + int64(2592000000)
+	renewPeriodDays   = 30
+)
+
+// TestNodeRenew_ResetsMasterTraffic reproduces #5843: a node-side auto-renew
+// extends the deadline and resets the node's counters, and the master must
+// start a fresh quota window instead of keeping the old period's usage.
+func TestNodeRenew_ResetsMasterTraffic(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "renew-reset"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 950, 150, "before renewal")
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 5, Down: 2, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
+	ct := readTraffic(t, db, email)
+	assertUpDown(t, ct, 5, 2, "after renewal")
+	if ct.ExpiryTime != renewSecondExpiry {
+		t.Errorf("renewal expiry = %d, want %d", ct.ExpiryTime, renewSecondExpiry)
+	}
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 15, Down: 12, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 15, 12, "accumulating in the new period")
+}
+
+// TestNodeRenew_ReenablesDepletedClient: the node re-enables a client it
+// renews; the master's depleted-disable must lift with the fresh window.
+func TestNodeRenew_ReenablesDepletedClient(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "renew-enable"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("enable", false).Error; err != nil {
+		t.Fatalf("force-disable master row: %v", err)
+	}
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
+	if ct := readTraffic(t, db, email); !ct.Enable {
+		t.Error("renewal must re-enable the master row when the node reports the client enabled")
+	}
+}
+
+// TestNodeRenew_ClearsGlobalTraffic mirrors autoRenewClients: stale cross-panel
+// pushes for the renewed email must not re-deplete the fresh window.
+func TestNodeRenew_ClearsGlobalTraffic(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "renew-global"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	if err := db.Create(&model.ClientGlobalTraffic{MasterGuid: "m1", Email: email, Up: 800, Down: 90}).Error; err != nil {
+		t.Fatalf("seed global traffic: %v", err)
+	}
+
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 3, Down: 1, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
+
+	var cnt int64
+	if err := db.Model(&model.ClientGlobalTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
+		t.Fatalf("count global traffic: %v", err)
+	}
+	if cnt != 0 {
+		t.Errorf("renewal must clear stale global-traffic rows, found %d", cnt)
+	}
+}
+
+// TestNodeCounterDip_SameExpiry_KeepsTraffic: a plain counter dip (#5456) with
+// no deadline movement stays on the clamp path even for a renewable client.
+func TestNodeCounterDip_SameExpiry_KeepsTraffic(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "dip-only"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 10, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 950, 150, "dip without renewal")
+}
+
+// TestNodeExpiryAdvance_RisingCounters_Accumulates: a manual deadline extension
+// while traffic keeps flowing is not a renewal and must keep accumulating.
+func TestNodeExpiryAdvance_RisingCounters_Accumulates(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "n1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "extend-only"
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 50, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true})
+	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 160, Down: 90, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 160, 90, "extension while accumulating")
+}

+ 4 - 2
internal/web/translation/ar-EG.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
         "mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
         "mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
-        "mtgAdTag": "علامة إعلانية (قناة مموّلة)",
-        "mtgAdTagHint": "علامة اختيارية مكوّنة من 32 حرفًا ست عشريًا يتم الحصول عليها عند تسجيل البروكسي في تلغرام. عند تعيينها، يتم توجيه العملاء عبر البروكسيات الوسيطة في تلغرام وتظهر قناة مموّلة أعلى قائمة محادثاتهم. تتطلب عنوان IP عامًا يمكن الوصول إليه - عيّنه أدناه أو اترك mtg يكتشفه تلقائيًا.",
         "mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
         "mtgPublicIpv4": "عنوان IPv4 العام",
         "mtgPublicIpv6": "عنوان IPv6 العام",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.",
       "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.",
       "password": "كلمة المرور",
+      "passwordDesc": "تُستخدم فقط من قبل عملاء Trojan و Shadowsocks؛ ويتم تجاهلها لـ VLESS و VMess و Hysteria و WireGuard.",
       "subId": "معرّف الاشتراك",
       "online": "متصل",
       "email": "البريد",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "يزيل كل عميل غير مرتبط بأي اتصال وارد مع سجل حركة مروره. لا يمكن التراجع.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "بيانات اعتماد يستخدمها عملاء Hysteria فقط. أما Trojan و Shadowsocks فيستخدمان حقل «كلمة المرور» بدلاً منها.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "أمان VMess",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
       "mtprotoSecret": "سر MTProto",
       "mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",
+      "mtprotoAdTag": "علامة إعلانية (قناة مموّلة)",
+      "mtprotoAdTagHint": "علامة اختيارية مكوّنة من 32 حرفًا ست عشريًا يتم الحصول عليها عند تسجيل البروكسي في تلغرام. عند تعيينها، يتم توجيه هذا العميل عبر البروكسيات الوسيطة في تلغرام وتظهر قناة مموّلة أعلى قائمة محادثاته.",
       "reverseTag": "وسم عكسي",
       "reverseTagPlaceholder": "Reverse tag اختياري",
       "telegramId": "معرّف مستخدم تلغرام",

+ 4 - 2
internal/web/translation/en-US.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "Default FakeTLS domain used to generate a new client's secret. Each client can front its own domain.",
         "mtgThrottleMaxConnections": "Max connections",
         "mtgThrottleMaxConnectionsHint": "Cap concurrent connections across all users with a fair-share limit. 0 disables throttling.",
-        "mtgAdTag": "Ad-tag (sponsored channel)",
-        "mtgAdTagHint": "Optional 32-character hex tag from Telegram's proxy registration. When set, clients are routed through Telegram middle proxies and a sponsored channel appears at the top of their chat list. Requires a reachable public IP - set one below or let mtg auto-detect it.",
         "mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
         "mtgPublicIpv4": "Public IPv4",
         "mtgPublicIpv6": "Public IPv6",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
       "limitIpDisabled": "The IP limit feature is disabled on this server.",
       "password": "Password",
+      "passwordDesc": "Only used by Trojan and Shadowsocks clients; ignored for VLESS, VMess, Hysteria, and WireGuard.",
       "subId": "Subscription ID",
       "online": "Online",
       "email": "Email",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Removes every client that is not attached to any inbound, along with its traffic record. This cannot be undone.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Credential used only by Hysteria clients. Trojan and Shadowsocks use the Password field instead.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess Security",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
       "mtprotoSecret": "MTProto secret",
       "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.",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Optional reverse tag",
       "telegramId": "Telegram user ID",

+ 4 - 2
internal/web/translation/es-ES.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "Dominio FakeTLS predeterminado para generar el secreto de un nuevo cliente. Cada cliente puede usar su propio dominio.",
         "mtgThrottleMaxConnections": "Conexiones máximas",
         "mtgThrottleMaxConnectionsHint": "Limita las conexiones simultáneas de todos los usuarios con reparto equitativo. 0 desactiva el límite.",
-        "mtgAdTag": "Ad-tag (canal patrocinado)",
-        "mtgAdTagHint": "Etiqueta hexadecimal opcional de 32 caracteres del registro de proxy de Telegram. Si se establece, los clientes se enrutan a través de los proxies intermedios de Telegram y aparece un canal patrocinado en la parte superior de su lista de chats. Requiere una IP pública accesible: configúrala abajo o deja que mtg la detecte automáticamente.",
         "mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
         "mtgPublicIpv4": "IPv4 pública",
         "mtgPublicIpv6": "IPv6 pública",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.",
       "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.",
       "password": "Contraseña",
+      "passwordDesc": "Solo la usan los clientes Trojan y Shadowsocks; se ignora para VLESS, VMess, Hysteria y WireGuard.",
       "subId": "ID de suscripción",
       "online": "En línea",
       "email": "Email",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Elimina todos los clientes que no están vinculados a ningún entrante, junto con su registro de tráfico. No se puede deshacer.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Credencial usada únicamente por los clientes Hysteria. Trojan y Shadowsocks usan el campo «Contraseña» en su lugar.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Seguridad VMess",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
       "mtprotoSecret": "Secreto MTProto",
       "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.",
       "reverseTag": "Etiqueta inversa",
       "reverseTagPlaceholder": "Reverse tag opcional",
       "telegramId": "ID de usuario de Telegram",

+ 4 - 2
internal/web/translation/fa-IR.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "دامنه پیش‌فرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت می‌تواند دامنه مخصوص خود را داشته باشد.",
         "mtgThrottleMaxConnections": "حداکثر اتصالات",
         "mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
-        "mtgAdTag": "برچسب تبلیغاتی (کانال حامی)",
-        "mtgAdTagHint": "برچسب هگزادسیمال اختیاری ۳۲ کاراکتری که هنگام ثبت پروکسی در تلگرام دریافت می‌شود. با تنظیم آن، کاربران از طریق پروکسی‌های میانی تلگرام هدایت می‌شوند و یک کانال حامی در بالای فهرست گفتگوهایشان نمایش داده می‌شود. به یک IP عمومی در دسترس نیاز دارد - آن را در پایین تنظیم کنید یا بگذارید mtg به‌طور خودکار آن را تشخیص دهد.",
         "mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
         "mtgPublicIpv4": "IPv4 عمومی",
         "mtgPublicIpv6": "IPv6 عمومی",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
       "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",
       "password": "رمز عبور",
+      "passwordDesc": "فقط توسط کلاینت‌های Trojan و Shadowsocks استفاده می‌شود؛ برای VLESS، VMess، Hysteria و WireGuard نادیده گرفته می‌شود.",
       "subId": "شناسه اشتراک",
       "online": "آنلاین",
       "email": "ایمیل",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "هر کلاینتی که به هیچ اینباندی متصل نیست، همراه با رکورد ترافیک‌اش حذف می‌شود. این عمل غیرقابل بازگشت است.",
       "auth": "احراز",
       "hysteriaAuth": "احراز Hysteria",
+      "hysteriaAuthDesc": "اعتباری که فقط کلاینت‌های Hysteria از آن استفاده می‌کنند. Trojan و Shadowsocks به‌جای آن از فیلد «رمز عبور» استفاده می‌کنند.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "امنیت VMess",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
       "mtprotoSecret": "سکرت MTProto",
       "mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",
+      "mtprotoAdTag": "برچسب تبلیغاتی (کانال حامی)",
+      "mtprotoAdTagHint": "برچسب هگزادسیمال اختیاری ۳۲ کاراکتری که هنگام ثبت پروکسی در تلگرام دریافت می‌شود. با تنظیم آن، این کلاینت از طریق پروکسی‌های میانی تلگرام هدایت می‌شود و یک کانال حامی در بالای فهرست گفتگوهایش نمایش داده می‌شود.",
       "reverseTag": "تگ معکوس",
       "reverseTagPlaceholder": "Reverse tag اختیاری",
       "telegramId": "شناسه کاربر تلگرام",

+ 4 - 2
internal/web/translation/id-ID.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "Domain FakeTLS default untuk membuat secret klien baru. Setiap klien bisa memakai domainnya sendiri.",
         "mtgThrottleMaxConnections": "Koneksi maksimum",
         "mtgThrottleMaxConnectionsHint": "Batasi koneksi bersamaan semua pengguna dengan pembagian adil. 0 menonaktifkan.",
-        "mtgAdTag": "Ad-tag (kanal bersponsor)",
-        "mtgAdTagHint": "Tag heksadesimal 32 karakter opsional dari pendaftaran proxy Telegram. Jika diatur, klien dirutekan melalui proxy perantara Telegram dan kanal bersponsor muncul di bagian atas daftar obrolan mereka. Memerlukan IP publik yang dapat dijangkau - atur di bawah atau biarkan mtg mendeteksinya secara otomatis.",
         "mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
         "mtgPublicIpv4": "IPv4 Publik",
         "mtgPublicIpv6": "IPv6 Publik",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.",
       "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.",
       "password": "Kata sandi",
+      "passwordDesc": "Hanya digunakan oleh klien Trojan dan Shadowsocks; diabaikan untuk VLESS, VMess, Hysteria, dan WireGuard.",
       "subId": "ID Langganan",
       "online": "Online",
       "email": "Email",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Menghapus setiap klien yang tidak terhubung ke inbound mana pun, beserta catatan lalu lintasnya. Tidak dapat dibatalkan.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Kredensial yang hanya digunakan oleh klien Hysteria. Trojan dan Shadowsocks menggunakan kolom \"Kata sandi\" sebagai gantinya.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Keamanan VMess",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
       "mtprotoSecret": "Secret MTProto",
       "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.",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag opsional",
       "telegramId": "ID pengguna Telegram",

+ 4 - 2
internal/web/translation/ja-JP.json

@@ -546,8 +546,6 @@
         "mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
         "mtgThrottleMaxConnections": "最大接続数",
         "mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
-        "mtgAdTag": "広告タグ(スポンサーチャンネル)",
-        "mtgAdTagHint": "Telegram のプロキシ登録で取得する任意の 32 文字の 16 進数タグ。設定すると、クライアントは Telegram の中間プロキシ経由でルーティングされ、スポンサーチャンネルがチャット一覧の先頭に表示されます。到達可能なパブリック IP が必要です。下で設定するか、mtg に自動検出させてください。",
         "mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
         "mtgPublicIpv4": "パブリック IPv4",
         "mtgPublicIpv6": "パブリック IPv6",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。",
       "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。",
       "password": "パスワード",
+      "passwordDesc": "Trojan と Shadowsocks のクライアントのみが使用します。VLESS、VMess、Hysteria、WireGuard では無視されます。",
       "subId": "サブスクリプション ID",
       "online": "オンライン",
       "email": "メール",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "どのインバウンドにもアタッチされていないクライアントを、そのトラフィック記録とともにすべて削除します。元に戻せません。",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Hysteria クライアントのみが使用する認証情報です。Trojan と Shadowsocks は代わりに「パスワード」フィールドを使用します。",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess セキュリティ",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
       "mtprotoSecret": "MTProto シークレット",
       "mtprotoSecretHint": "このクライアントの FakeTLS シークレット。変更するには再生成します。",
+      "mtprotoAdTag": "広告タグ(スポンサーチャンネル)",
+      "mtprotoAdTagHint": "Telegram のプロキシ登録で取得する任意の 32 文字の 16 進数タグ。設定すると、このクライアントは Telegram の中間プロキシ経由でルーティングされ、スポンサーチャンネルがチャット一覧の先頭に表示されます。",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "任意の Reverse tag",
       "telegramId": "Telegram ユーザー ID",

+ 4 - 2
internal/web/translation/pt-BR.json

@@ -546,8 +546,6 @@
         "mtprotoFakeTlsDomainHint": "Domínio FakeTLS padrão usado para gerar o segredo de um novo cliente. Cada cliente pode usar seu próprio domínio.",
         "mtgThrottleMaxConnections": "Conexões máximas",
         "mtgThrottleMaxConnectionsHint": "Limita conexões simultâneas de todos os usuários com distribuição justa. 0 desativa o limite.",
-        "mtgAdTag": "Ad-tag (canal patrocinado)",
-        "mtgAdTagHint": "Tag hexadecimal opcional de 32 caracteres do registro de proxy do Telegram. Quando definida, os clientes são roteados pelos proxies intermediários do Telegram e um canal patrocinado aparece no topo da lista de conversas. Requer um IP público acessível - defina um abaixo ou deixe o mtg detectá-lo automaticamente.",
         "mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
         "mtgPublicIpv4": "IPv4 público",
         "mtgPublicIpv6": "IPv6 público",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.",
       "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.",
       "password": "Senha",
+      "passwordDesc": "Usada apenas pelos clientes Trojan e Shadowsocks; ignorada para VLESS, VMess, Hysteria e WireGuard.",
       "subId": "ID da assinatura",
       "online": "Online",
       "email": "Email",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Remove todos os clientes que não estão vinculados a nenhum inbound, junto com seu registro de tráfego. Não é possível desfazer.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Credencial usada apenas pelos clientes Hysteria. Trojan e Shadowsocks usam o campo \"Senha\" em vez disso.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Segurança VMess",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
       "mtprotoSecret": "Segredo MTProto",
       "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.",
       "reverseTag": "Tag reversa",
       "reverseTagPlaceholder": "Reverse tag opcional",
       "telegramId": "ID de usuário do Telegram",

+ 4 - 2
internal/web/translation/ru-RU.json

@@ -546,8 +546,6 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
         "mtgThrottleMaxConnections": "Макс. подключений",
         "mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
-        "mtgAdTag": "Рекламный тег (спонсорский канал)",
-        "mtgAdTagHint": "Необязательный шестнадцатеричный тег из 32 символов, получаемый при регистрации прокси в Telegram. Если задан, клиенты маршрутизируются через промежуточные прокси Telegram, и спонсорский канал появляется вверху списка чатов. Требуется доступный публичный IP - укажите его ниже или позвольте mtg определить его автоматически.",
         "mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
         "mtgPublicIpv4": "Публичный IPv4",
         "mtgPublicIpv6": "Публичный IPv6",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
       "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",
       "password": "Пароль",
+      "passwordDesc": "Используется только клиентами Trojan и Shadowsocks; игнорируется для VLESS, VMess, Hysteria и WireGuard.",
       "subId": "ID подписки",
       "online": "В сети",
       "email": "Email",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Удаляются все клиенты, не привязанные ни к одному входящему, вместе с их записями трафика. Это действие нельзя отменить.",
       "auth": "Авторизация",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Учётные данные, используемые только клиентами Hysteria. Для Trojan и Shadowsocks используйте поле «Пароль».",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess Security",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
       "mtprotoSecret": "Секрет MTProto",
       "mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
+      "mtprotoAdTag": "Рекламный тег (спонсорский канал)",
+      "mtprotoAdTagHint": "Необязательный шестнадцатеричный тег из 32 символов, получаемый при регистрации прокси в Telegram. Если задан, этот клиент маршрутизируется через промежуточные прокси Telegram, и спонсорский канал появляется вверху списка чатов.",
       "reverseTag": "Обратный тег",
       "reverseTagPlaceholder": "Необязательный Reverse tag",
       "telegramId": "ID пользователя Telegram",

+ 4 - 2
internal/web/translation/tr-TR.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "Yeni bir istemcinin sırrını oluştururken kullanılan varsayılan FakeTLS alan adı. Her istemci kendi alan adını kullanabilir.",
         "mtgThrottleMaxConnections": "Maks. bağlantı",
         "mtgThrottleMaxConnectionsHint": "Tüm kullanıcıların eşzamanlı bağlantılarını adil paylaşımla sınırlar. 0 devre dışı bırakır.",
-        "mtgAdTag": "Reklam etiketi (sponsorlu kanal)",
-        "mtgAdTagHint": "Telegram proxy kaydından alınan isteğe bağlı 32 karakterlik onaltılık etiket. Ayarlandığında istemciler Telegram ara proxy'leri üzerinden yönlendirilir ve sohbet listelerinin en üstünde sponsorlu bir kanal görünür. Erişilebilir bir genel IP gerektirir - aşağıdan ayarlayın veya mtg'nin otomatik algılamasına izin verin.",
         "mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
         "mtgPublicIpv4": "Genel IPv4",
         "mtgPublicIpv6": "Genel IPv6",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.",
       "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.",
       "password": "Şifre",
+      "passwordDesc": "Yalnızca Trojan ve Shadowsocks istemcileri tarafından kullanılır; VLESS, VMess, Hysteria ve WireGuard için yok sayılır.",
       "subId": "Abonelik ID'si",
       "online": "Çevrimiçi",
       "email": "E-posta",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Hiçbir gelen bağlantıya bağlı olmayan her kullanıcı, trafik kaydıyla birlikte silinir. Geri alınamaz.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Yalnızca Hysteria istemcilerinin kullandığı kimlik bilgisi. Trojan ve Shadowsocks bunun yerine \"Şifre\" alanını kullanır.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess Güvenlik",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
       "mtprotoSecret": "MTProto sırrı",
       "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.",
       "reverseTag": "Reverse Tag",
       "reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
       "telegramId": "Telegram Kullanıcı ID'si",

+ 4 - 2
internal/web/translation/uk-UA.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
         "mtgThrottleMaxConnections": "Макс. з'єднань",
         "mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
-        "mtgAdTag": "Рекламний тег (спонсорський канал)",
-        "mtgAdTagHint": "Необовʼязковий шістнадцятковий тег із 32 символів, який видається під час реєстрації проксі в Telegram. Якщо задано, клієнти маршрутизуються через проміжні проксі Telegram, а спонсорський канал зʼявляється вгорі списку чатів. Потрібна доступна публічна IP-адреса - вкажіть її нижче або дозвольте mtg визначити її автоматично.",
         "mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
         "mtgPublicIpv4": "Публічний IPv4",
         "mtgPublicIpv6": "Публічний IPv6",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.",
       "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.",
       "password": "Пароль",
+      "passwordDesc": "Використовується лише клієнтами Trojan і Shadowsocks; ігнорується для VLESS, VMess, Hysteria та WireGuard.",
       "subId": "ID підписки",
       "online": "У мережі",
       "email": "Email",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Видаляється кожен клієнт, не прив'язаний до жодного вхідного, разом із його записом трафіку. Цю дію неможливо скасувати.",
       "auth": "Авторизація",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Облікові дані, які використовують лише клієнти Hysteria. Для Trojan і Shadowsocks використовуйте поле «Пароль».",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Безпека VMess",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
       "mtprotoSecret": "Секрет MTProto",
       "mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",
+      "mtprotoAdTag": "Рекламний тег (спонсорський канал)",
+      "mtprotoAdTagHint": "Необовʼязковий шістнадцятковий тег із 32 символів, який видається під час реєстрації проксі в Telegram. Якщо задано, цей клієнт маршрутизується через проміжні проксі Telegram, а спонсорський канал зʼявляється вгорі списку чатів.",
       "reverseTag": "Зворотний тег",
       "reverseTagPlaceholder": "Необов'язковий Reverse tag",
       "telegramId": "ID користувача Telegram",

+ 4 - 2
internal/web/translation/vi-VN.json

@@ -546,8 +546,6 @@
         "mtprotoFakeTlsDomainHint": "Tên miền FakeTLS mặc định dùng để tạo secret cho client mới. Mỗi client có thể dùng tên miền riêng.",
         "mtgThrottleMaxConnections": "Số kết nối tối đa",
         "mtgThrottleMaxConnectionsHint": "Giới hạn kết nối đồng thời của tất cả người dùng theo phân bổ công bằng. 0 để tắt.",
-        "mtgAdTag": "Ad-tag (kênh tài trợ)",
-        "mtgAdTagHint": "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 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. Cần một IP công khai có thể truy cập - đặt bên dưới hoặc để mtg tự phát hiện.",
         "mtgAdTagInvalid": "Ad-tag phải có đúng 32 ký tự thập lục phân.",
         "mtgPublicIpv4": "IPv4 công khai",
         "mtgPublicIpv6": "IPv6 công khai",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.",
       "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.",
       "password": "Mật khẩu",
+      "passwordDesc": "Chỉ được dùng bởi các client Trojan và Shadowsocks; bị bỏ qua đối với VLESS, VMess, Hysteria và WireGuard.",
       "subId": "ID đăng ký",
       "online": "Trực tuyến",
       "email": "Email",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "Gỡ tất cả khách hàng không được gắn vào bất kỳ inbound nào, cùng với bản ghi lưu lượng của họ. Không thể hoàn tác.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
+      "hysteriaAuthDesc": "Thông tin xác thực chỉ dùng cho client Hysteria. Trojan và Shadowsocks dùng trường \"Mật khẩu\" thay thế.",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "Bảo mật VMess",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
       "mtprotoSecret": "Secret MTProto",
       "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.",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag tùy chọn",
       "telegramId": "ID người dùng Telegram",

+ 4 - 2
internal/web/translation/zh-CN.json

@@ -545,8 +545,6 @@
         "mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
         "mtgThrottleMaxConnections": "最大连接数",
         "mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
-        "mtgAdTag": "广告标签(赞助频道)",
-        "mtgAdTagHint": "可选的 32 位十六进制标签,从 Telegram 代理注册处获取。设置后,客户端将通过 Telegram 中间代理路由,赞助频道会显示在其聊天列表顶部。需要可访问的公网 IP —— 在下方设置,或让 mtg 自动检测。",
         "mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
         "mtgPublicIpv4": "公网 IPv4",
         "mtgPublicIpv6": "公网 IPv6",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
       "limitIpDisabled": "此服务器已禁用 IP 限制功能。",
       "password": "密码",
+      "passwordDesc": "仅 Trojan 和 Shadowsocks 客户端使用;VLESS、VMess、Hysteria 和 WireGuard 会忽略此项。",
       "subId": "订阅 ID",
       "online": "在线",
       "email": "邮箱",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "删除所有未关联到任何入站的客户端及其流量记录。该操作不可撤销。",
       "auth": "认证",
       "hysteriaAuth": "Hysteria 认证",
+      "hysteriaAuthDesc": "仅 Hysteria 客户端使用的凭据。Trojan 和 Shadowsocks 请改用上方的“密码”字段。",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess 加密",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
       "mtprotoSecret": "MTProto 密钥",
       "mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",
+      "mtprotoAdTag": "广告标签(赞助频道)",
+      "mtprotoAdTagHint": "可选的 32 位十六进制标签,从 Telegram 代理注册处获取。设置后,该客户端将通过 Telegram 中间代理路由,赞助频道会显示在其聊天列表顶部。",
       "reverseTag": "反向标签",
       "reverseTagPlaceholder": "可选 Reverse tag",
       "telegramId": "Telegram 用户 ID",

+ 4 - 2
internal/web/translation/zh-TW.json

@@ -525,8 +525,6 @@
         "mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
         "mtgThrottleMaxConnections": "最大連線數",
         "mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
-        "mtgAdTag": "廣告標籤(贊助頻道)",
-        "mtgAdTagHint": "選填的 32 位十六進位標籤,從 Telegram 代理註冊處取得。設定後,用戶端會透過 Telegram 中間代理路由,贊助頻道會顯示在其聊天清單頂端。需要可存取的公網 IP —— 在下方設定,或讓 mtg 自動偵測。",
         "mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
         "mtgPublicIpv4": "公網 IPv4",
         "mtgPublicIpv6": "公網 IPv6",
@@ -808,6 +806,7 @@
       "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。",
       "limitIpDisabled": "此伺服器已停用 IP 限制功能。",
       "password": "密碼",
+      "passwordDesc": "僅 Trojan 與 Shadowsocks 用戶端使用;VLESS、VMess、Hysteria 和 WireGuard 會忽略此項。",
       "subId": "訂閱 ID",
       "online": "上線",
       "email": "電子郵件",
@@ -908,6 +907,7 @@
       "delOrphansConfirmContent": "移除所有未關聯任何入站的客戶端,連同其流量紀錄一併刪除。此操作無法復原。",
       "auth": "認證",
       "hysteriaAuth": "Hysteria 認證",
+      "hysteriaAuthDesc": "僅 Hysteria 用戶端使用的憑證。Trojan 與 Shadowsocks 請改用上方的「密碼」欄位。",
       "uuid": "UUID",
       "flow": "Flow",
       "vmessSecurity": "VMess 加密",
@@ -918,6 +918,8 @@
       "wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
       "mtprotoSecret": "MTProto 金鑰",
       "mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",
+      "mtprotoAdTag": "廣告標籤(贊助頻道)",
+      "mtprotoAdTagHint": "選填的 32 位十六進位標籤,從 Telegram 代理註冊處取得。設定後,該用戶端會透過 Telegram 中間代理路由,贊助頻道會顯示在其聊天清單頂端。",
       "reverseTag": "反向標籤",
       "reverseTagPlaceholder": "選用 Reverse tag",
       "telegramId": "Telegram 使用者 ID",