From 43500a5470fea721123909cffca760651e70b266 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 7 Jul 2026 12:00:43 +0200 Subject: [PATCH] feat(mtproto): per-client ad-tags, management-API auth, and record secret sync Catch the panel up to the mtg-multi README (v1.14.0): - Each client can now carry its own 32-hex advertising tag overriding the inbound-level one. The tag lives on the client (settings JSON is the source of truth, clients.ad_tag is the UI projection), is rendered into the fork's [secret-ad-tags] section for active secrets only (mtg rejects a config whose override names an unknown secret), is pushed per entry through PUT /secrets, and is part of the reload fingerprint so a tag edit hot-applies without dropping connections. - The loopback management API can replace the whole secret set, so every mtg process now gets a random per-process api-token; the manager sends it as a bearer token on PUT /secrets and GET /stats and reuses it across config rewrites, because mtg reads the token only at startup. - Malformed tags are rejected at every save path and additionally dropped in InstanceFromInbound: one bad tag would otherwise fail the whole generated config and take every client of the inbound down with it. - SyncInbound never copied a re-keyed mtproto secret into the canonical clients table, so the clients page and subscription links kept serving the old secret, which mtg then rejects. It is now guarded-copied like the other credentials. --- CLAUDE.md | 8 +- docs/architecture.md | 7 +- frontend/public/openapi.json | 8 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 8 ++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + .../src/pages/clients/ClientFormModal.tsx | 31 ++++- frontend/src/schemas/client.ts | 1 + .../src/schemas/protocols/inbound/mtproto.ts | 5 + internal/database/model/model.go | 16 +++ internal/mtproto/manager.go | 118 ++++++++++++++---- internal/mtproto/manager_mutation_test.go | 8 +- internal/mtproto/manager_reload_test.go | 25 +++- internal/mtproto/manager_test.go | 54 ++++++-- internal/web/service/client_crud.go | 9 ++ internal/web/service/client_inbound_apply.go | 6 + internal/web/service/client_link.go | 6 + .../web/service/client_sync_mtproto_test.go | 70 +++++++++++ internal/web/service/inbound.go | 3 + internal/web/translation/ar-EG.json | 2 + internal/web/translation/en-US.json | 2 + internal/web/translation/es-ES.json | 2 + internal/web/translation/fa-IR.json | 2 + internal/web/translation/id-ID.json | 2 + internal/web/translation/ja-JP.json | 2 + internal/web/translation/pt-BR.json | 2 + internal/web/translation/ru-RU.json | 2 + internal/web/translation/tr-TR.json | 2 + internal/web/translation/uk-UA.json | 2 + internal/web/translation/vi-VN.json | 2 + internal/web/translation/zh-CN.json | 2 + internal/web/translation/zh-TW.json | 2 + 33 files changed, 361 insertions(+), 54 deletions(-) create mode 100644 internal/web/service/client_sync_mtproto_test.go diff --git a/CLAUDE.md b/CLAUDE.md index cb33903fc..fe2b10c2b 100644 --- a/CLAUDE.md +++ b/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. diff --git a/docs/architecture.md b/docs/architecture.md index b31cf9296..985db50d7 100644 --- a/docs/architecture.md +++ b/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`: diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 78e29e0a2..feaf1e2e9 100644 --- a/frontend/public/openapi.json +++ b/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", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index d5d241860..1d48a2348 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -214,6 +214,7 @@ export const EXAMPLES: Record = { "token": "new-token-string" }, "Client": { + "adTag": "0123456789abcdef0123456789abcdef", "allowedIPs": [ "" ], @@ -248,6 +249,7 @@ export const EXAMPLES: Record = { "inboundId": 0 }, "ClientRecord": { + "adTag": "", "allowedIPs": "", "auth": "", "comment": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 3d7fcddca..e644df455 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1066,6 +1066,10 @@ export const SCHEMAS: Record = { "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 = { }, "ClientRecord": { "properties": { + "adTag": { + "type": "string" + }, "allowedIPs": { "type": "string" }, @@ -1280,6 +1287,7 @@ export const SCHEMAS: Record = { } }, "required": [ + "adTag", "allowedIPs", "auth", "comment", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 256e83a53..09606d08a 100644 --- a/frontend/src/generated/types.ts +++ b/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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 0cf29f6c5..e57e2e1ad 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -240,6 +240,7 @@ export const ApiTokenViewSchema = z.object({ export type ApiTokenView = z.infer; 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; export const ClientRecordSchema = z.object({ + adTag: z.string(), allowedIPs: z.string(), auth: z.string(), comment: z.string(), diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 6143d7d0b..a76c61c47 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/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 @@ -862,12 +871,22 @@ export default function ClientFormModal({ )} {showMtproto && ( - - - update('secret', e.target.value)} /> -