diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7133b93be..ac74684cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,14 +156,14 @@ jobs: wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat mv xray xray-linux-${{ matrix.platform }} - # mtg (MTProto sidecar) - only for arches mtg publishes - MTG_VER="2.2.8" + # mtg-multi (MTProto sidecar) is pure Go, so build it from source for the + # target arch (GOOS/GOARCH/GOARM are already exported above). Its release + # binaries only cover linux/darwin amd64/arm64; skip s390x/armv5 as before. + MTG_MULTI_VER="v1.11.0" case "${{ matrix.platform }}" in amd64|arm64|armv7|armv6|386) - wget -q "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz" - tar -xzf "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz" - mv "mtg-${MTG_VER}-linux-${{ matrix.platform }}/mtg" "mtg-linux-${{ matrix.platform }}" 2>/dev/null || mv mtg "mtg-linux-${{ matrix.platform }}" - rm -rf "mtg-${MTG_VER}-linux-${{ matrix.platform }}" "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz" + CGO_ENABLED=0 GOBIN="$(pwd)" go install -trimpath -ldflags "-s -w" "github.com/dolonet/mtg-multi@${MTG_MULTI_VER}" + mv mtg-multi "mtg-linux-${{ matrix.platform }}" ;; esac cd ../.. @@ -280,13 +280,15 @@ jobs: Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat" Rename-Item xray.exe xray-windows-amd64.exe - # Download mtg (MTProto sidecar) for Windows - $MTG_VER = "2.2.8" - Invoke-WebRequest -Uri "https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-windows-amd64.zip" -OutFile "mtg-windows-amd64.zip" - Expand-Archive -Path "mtg-windows-amd64.zip" -DestinationPath "mtg-tmp" - $mtgExe = Get-ChildItem -Path "mtg-tmp" -Recurse -Filter "mtg.exe" | Select-Object -First 1 - Move-Item $mtgExe.FullName "mtg-windows-amd64.exe" - Remove-Item "mtg-windows-amd64.zip", "mtg-tmp" -Recurse -Force + # mtg-multi (MTProto sidecar) is pure Go; build it from source since the + # fork publishes no Windows release binary. + $MTG_MULTI_VER = "v1.11.0" + $env:CGO_ENABLED = "0" + $env:GOOS = "windows" + $env:GOARCH = "amd64" + $env:GOBIN = (Get-Location).Path + go install -trimpath -ldflags "-s -w" "github.com/dolonet/mtg-multi@$MTG_MULTI_VER" + Move-Item "mtg-multi.exe" "mtg-windows-amd64.exe" cd .. Copy-Item -Path ..\windows_files\* -Destination . -Recurse diff --git a/CLAUDE.md b/CLAUDE.md index 8da839942..99201a431 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,10 @@ file locations when it can answer in one hop. - Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM. Runs Xray-core as a managed child process (`internal/xray/process.go`) and imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router - API. MTProto inbounds run a second managed child — the `mtg` binary - (`internal/mtproto/`) — outside Xray. + API. MTProto inbounds run a second managed child — the `mtg-multi` binary + (`github.com/dolonet/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. - 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. @@ -27,7 +29,7 @@ file locations when it can answer in one hop. Client, Setting, User), inbound Protocol enum, AutoMigrate + hand-written migrations in `db.go`. - `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API. -- `internal/mtproto/` — MTProto inbounds via the bundled `mtg` binary. +- `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary. - `internal/sub/` — subscription server (raw / JSON / Clash). - `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash, cpu.high, memory.high, login.attempt). diff --git a/DockerInit.sh b/DockerInit.sh index 46ec1706b..22b09bbb7 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -3,45 +3,39 @@ case $1 in amd64) ARCH="64" FNAME="amd64" - MTG_ARCH="amd64" ;; i386) ARCH="32" FNAME="i386" - MTG_ARCH="386" ;; armv8 | arm64 | aarch64) ARCH="arm64-v8a" FNAME="arm64" - MTG_ARCH="arm64" ;; armv7 | arm | arm32) ARCH="arm32-v7a" FNAME="arm32" - MTG_ARCH="armv7" ;; armv6) ARCH="arm32-v6" FNAME="armv6" - MTG_ARCH="armv6" ;; *) ARCH="64" FNAME="amd64" - MTG_ARCH="amd64" ;; esac -MTG_VER="2.2.8" +MTG_MULTI_VER="v1.11.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" unzip "Xray-linux-${ARCH}.zip" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" -curl -sfLRO "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz" -tar -xzf "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz" -mv "mtg-${MTG_VER}-linux-${MTG_ARCH}/mtg" "mtg-linux-${FNAME}" 2>/dev/null || mv mtg "mtg-linux-${FNAME}" -rm -rf "mtg-${MTG_VER}-linux-${MTG_ARCH}" "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz" +# mtg-multi (MTProto sidecar) is pure Go, so build it from source for the target +# arch — its release binaries only cover linux/darwin amd64/arm64. +CGO_ENABLED=0 GOBIN="$(pwd)" go install -trimpath -ldflags "-s -w" "github.com/dolonet/mtg-multi@${MTG_MULTI_VER}" +mv mtg-multi "mtg-linux-${FNAME}" chmod +x "mtg-linux-${FNAME}" curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat diff --git a/docs/architecture.md b/docs/architecture.md index 9aee6afff..2290e392a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,9 @@ WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the U 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` Telegram-proxy binary (`internal/mtproto/`). +inbounds exist — the `mtg-multi` Telegram-proxy binary (`github.com/dolonet/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. Servers and processes, all launched from `main.go`: @@ -29,7 +31,7 @@ Servers and processes, all launched from `main.go`: | **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 | | **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting | | **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` | -| **mtg** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds | per inbound | +| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound | Two key ideas that explain most of the complexity: diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c5ecfd17a..571d9d0d4 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1167,6 +1167,11 @@ "description": "VLESS simple reverse proxy settings", "nullable": true }, + "secret": { + "description": "MTProto FakeTLS secret", + "example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", + "type": "string" + }, "security": { "description": "Security method (e.g., \"auto\", \"aes-128-gcm\")", "type": "string" @@ -1279,6 +1284,9 @@ "type": "integer" }, "reverse": {}, + "secret": { + "type": "string" + }, "security": { "type": "string" }, @@ -1317,6 +1325,7 @@ "publicKey", "reset", "reverse", + "secret", "security", "subId", "tgId", @@ -1835,6 +1844,9 @@ "listen": { "type": "string" }, + "mtprotoDomain": { + "type": "string" + }, "nodeAddress": { "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", "type": "string" @@ -2803,6 +2815,7 @@ "enable": true, "id": 1, "listen": "", + "mtprotoDomain": "", "nodeAddress": "", "nodeId": null, "port": 443, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index ec63d47ea..d5d241860 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -234,6 +234,7 @@ export const EXAMPLES: Record = { "publicKey": "", "reset": 0, "reverse": null, + "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", "security": "", "subId": "", "tgId": 0, @@ -265,6 +266,7 @@ export const EXAMPLES: Record = { "publicKey": "", "reset": 0, "reverse": null, + "secret": "", "security": "", "subId": "", "tgId": 0, @@ -402,6 +404,7 @@ export const EXAMPLES: Record = { "enable": true, "id": 1, "listen": "", + "mtprotoDomain": "", "nodeAddress": "", "nodeId": null, "port": 443, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 81df25b3e..0585a1362 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1141,6 +1141,11 @@ export const SCHEMAS: Record = { "description": "VLESS simple reverse proxy settings", "nullable": true }, + "secret": { + "description": "MTProto FakeTLS secret", + "example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", + "type": "string" + }, "security": { "description": "Security method (e.g., \"auto\", \"aes-128-gcm\")", "type": "string" @@ -1253,6 +1258,9 @@ export const SCHEMAS: Record = { "type": "integer" }, "reverse": {}, + "secret": { + "type": "string" + }, "security": { "type": "string" }, @@ -1291,6 +1299,7 @@ export const SCHEMAS: Record = { "publicKey", "reset", "reverse", + "secret", "security", "subId", "tgId", @@ -1809,6 +1818,9 @@ export const SCHEMAS: Record = { "listen": { "type": "string" }, + "mtprotoDomain": { + "type": "string" + }, "nodeAddress": { "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", "type": "string" diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index bcb934360..256e83a53 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -242,6 +242,7 @@ export interface Client { publicKey?: string; reset: number; reverse?: ClientReverse | null; + secret?: string; security: string; subId: string; tgId: number; @@ -275,6 +276,7 @@ export interface ClientRecord { publicKey: string; reset: number; reverse: unknown; + secret: string; security: string; subId: string; tgId: number; @@ -396,6 +398,7 @@ export interface InboundOption { enable: boolean; id: number; listen?: string; + mtprotoDomain?: string; nodeAddress?: string; nodeId?: number | null; port: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index c968699d7..0cf29f6c5 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -258,6 +258,7 @@ export const ClientSchema = z.object({ publicKey: z.string().optional(), reset: z.number().int(), reverse: z.lazy(() => ClientReverseSchema).nullable().optional(), + secret: z.string().optional(), security: z.string(), subId: z.string(), tgId: z.number().int(), @@ -293,6 +294,7 @@ export const ClientRecordSchema = z.object({ publicKey: z.string(), reset: z.number().int(), reverse: z.unknown(), + secret: z.string(), security: z.string(), subId: z.string(), tgId: z.number().int(), @@ -423,6 +425,7 @@ export const InboundOptionSchema = z.object({ enable: z.boolean(), id: z.number().int(), listen: z.string().optional(), + mtprotoDomain: z.string().optional(), nodeAddress: z.string().optional(), nodeId: z.number().int().nullable().optional(), port: z.number().int(), diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index ad5b5be7f..1ec9bf3bf 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -3,7 +3,7 @@ import { RandomUtil, Wireguard } from '@/utils'; import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http'; import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria'; import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed'; -import type { MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto'; +import type { MtprotoClient, MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto'; import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks'; import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan'; import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun'; @@ -216,26 +216,19 @@ export function generateMtprotoSecret(domain: string): string { return `ee${RandomUtil.randomSeq(32, { type: 'hex' })}${domainToHex(domain)}`; } -// mtprotoSecretForDomain rewrites only the domain suffix of an existing secret, -// preserving its 16-byte random middle when valid (generating one otherwise). -// Mirrors the Go model.HealMtprotoSecret so editing the FakeTLS domain doesn't -// needlessly rotate the secret's identity. -export function mtprotoSecretForDomain(currentSecret: string, domain: string): string { - let body = currentSecret; - if (body.startsWith('ee') || body.startsWith('dd')) { - body = body.slice(2); - } - const middle = /^[0-9a-f]{32}/i.test(body) - ? body.slice(0, 32) - : RandomUtil.randomSeq(32, { type: 'hex' }); - return `ee${middle}${domainToHex(domain)}`; +export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings { + return { + fakeTlsDomain: 'www.cloudflare.com', + clients: [], + }; } -export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings { - const fakeTlsDomain = 'www.cloudflare.com'; +// createDefaultMtprotoClient seeds a new MTProto client with a fresh FakeTLS +// secret fronting the given domain. Mirrors the WireGuard client default: the +// backend re-derives the secret on save, so this is only for immediate display. +export function createDefaultMtprotoClient(domain: string): Partial { return { - fakeTlsDomain, - secret: generateMtprotoSecret(fakeTlsDomain), + secret: generateMtprotoSecret(domain || 'www.cloudflare.com'), }; } diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index dc053b828..56464515f 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -2,6 +2,7 @@ import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schem import type { InboundSettings } from '@/schemas/protocols/inbound'; import { HysteriaClientSchema, + MtprotoClientSchema, ShadowsocksClientSchema, TrojanClientSchema, VlessClientSchema, @@ -238,6 +239,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null { case 'shadowsocks': return ShadowsocksClientSchema; case 'hysteria': return HysteriaClientSchema; case 'wireguard': return WireguardClientSchema; + case 'mtproto': return MtprotoClientSchema; default: return null; } } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index abd64026c..e73e21a4d 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -778,18 +778,21 @@ export interface GenMtprotoLinkInput { inbound: Inbound; address: string; port?: number; + clientSecret?: string; + remark?: string; } -// Builds a Telegram proxy deep link for an mtproto inbound: +// Builds a per-client Telegram proxy deep link for an mtproto inbound from the +// client's own FakeTLS secret. export function genMtprotoLink(input: GenMtprotoLinkInput): string { - const { inbound, address, port = inbound.port } = input; + const { inbound, address, port = inbound.port, clientSecret = '', remark = '' } = input; if (inbound.protocol !== 'mtproto') return ''; - const secret = inbound.settings.secret ?? ''; - if (secret.length === 0) return ''; + if (clientSecret.length === 0) return ''; const url = new URL('tg://proxy'); url.searchParams.set('server', address); url.searchParams.set('port', String(port)); - url.searchParams.set('secret', secret); + url.searchParams.set('secret', clientSecret); + if (remark) url.hash = encodeURIComponent(remark); return url.toString(); } @@ -1044,7 +1047,7 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin // `this.clients` getter, which used isSSMultiUser to gate). Returns null // for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without- // clients, and any protocol without a clients array. -type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string; subId?: string }; +type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; secret?: string; email?: string; subId?: string }; // Mirror of the Go subKey: the stable per-client identity spx derivation // keys on — subscription id first, unique email as the fallback. @@ -1062,6 +1065,8 @@ export function getInboundClients(inbound: Inbound): ClientShape[] | null { return (inbound.settings.clients ?? []) as ClientShape[]; case 'hysteria': return (inbound.settings.clients ?? []) as ClientShape[]; + case 'mtproto': + return (inbound.settings.clients ?? []) as ClientShape[]; case 'shadowsocks': { const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305'; return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null; @@ -1125,7 +1130,7 @@ export function genLink(input: GenLinkInput): string { externalProxy, }); case 'mtproto': - return genMtprotoLink({ inbound, address, port }); + return genMtprotoLink({ inbound, address, port, clientSecret: client.secret ?? '', remark }); default: return ''; } diff --git a/frontend/src/lib/xray/link-label.tsx b/frontend/src/lib/xray/link-label.tsx index bd79a4614..ab42f2032 100644 --- a/frontend/src/lib/xray/link-label.tsx +++ b/frontend/src/lib/xray/link-label.tsx @@ -25,6 +25,7 @@ const PROTOCOL_LABELS: Record = { hysteria: 'Hysteria', wireguard: 'WireGuard', wg: 'WireGuard', + tg: 'MTProto', }; const PROTOCOL_COLORS: Record = { @@ -35,12 +36,14 @@ const PROTOCOL_COLORS: Record = { Hysteria: 'magenta', Hysteria2: 'magenta', WireGuard: 'cyan', + MTProto: 'blue', }; const SECURITY_COLORS: Record = { TLS: 'green', XTLS: 'green', REALITY: 'purple', + FAKETLS: 'green', }; const TRANSPORT_COLOR = 'gold'; @@ -83,10 +86,13 @@ export function parseLinkParts(link: string): LinkParts | null { const url = new URL(trimmed); network = url.searchParams.get('type') ?? ''; security = url.searchParams.get('security') ?? ''; - port = url.port; + /* tg://proxy links (mtproto) carry the port in a `port` query param, not + the URL authority, so fall back to it when there is no authority port. */ + port = url.port || (url.searchParams.get('port') ?? ''); const hash = url.hash.replace(/^#/, ''); try { remark = decodeURIComponent(hash); } catch { remark = hash; } } catch { /* not URL-shaped, fall back to protocol only */ } + if (scheme === 'tg') security = 'FakeTLS'; } if (security === 'none') security = ''; return { diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 633ab32d9..6143d7d0b 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -24,6 +24,7 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import { HttpUtil, RandomUtil, Wireguard } from '@/utils'; import { formatInboundLabel } from '@/lib/inbounds/label'; +import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults'; import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log'; import { DateTimePicker, SelectAllClearButtons } from '@/components/form'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; @@ -35,7 +36,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const; const MULTI_CLIENT_PROTOCOLS = new Set([ - 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', + 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto', ]); const CLIENT_FORM_MODAL_Z_INDEX = 1000; @@ -117,6 +118,7 @@ interface FormState { wgPublicKey: string; wgPreSharedKey: string; wgAllowedIPs: string; + secret: string; } function emptyForm(): FormState { @@ -145,6 +147,7 @@ function emptyForm(): FormState { wgPublicKey: '', wgPreSharedKey: '', wgAllowedIPs: '', + secret: '', }; } @@ -249,6 +252,7 @@ export default function ClientFormModal({ wgPublicKey: client.publicKey || '', wgPreSharedKey: client.preSharedKey || '', wgAllowedIPs: client.allowedIPs || '', + secret: client.secret || '', }; if (et < 0) { next.delayedStart = true; @@ -310,6 +314,22 @@ export default function ClientFormModal({ return ids; }, [inbounds]); + const mtprotoIds = useMemo(() => { + const ids = new Set(); + for (const row of inbounds || []) { + if (row && row.protocol === 'mtproto') ids.add(row.id); + } + return ids; + }, [inbounds]); + + const mtprotoDomain = useMemo(() => { + for (const id of form.inboundIds || []) { + const ib = (inbounds || []).find((row) => row.id === id); + if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain; + } + return 'www.cloudflare.com'; + }, [form.inboundIds, inbounds]); + const ss2022Method = useMemo(() => { for (const id of form.inboundIds || []) { const ib = (inbounds || []).find((row) => row.id === id); @@ -345,11 +365,20 @@ export default function ClientFormModal({ [form.inboundIds, wireguardIds], ); + const showMtproto = useMemo( + () => (form.inboundIds || []).some((id) => mtprotoIds.has(id)), + [form.inboundIds, mtprotoIds], + ); + function regenerateWireguardKeys() { const kp = Wireguard.generateKeypair(); setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey })); } + function regenerateMtprotoSecret() { + update('secret', generateMtprotoSecret(mtprotoDomain)); + } + useEffect(() => { if (!showFlow && form.flow) { @@ -373,6 +402,12 @@ export default function ClientFormModal({ )); }, [ss2022Method]); + useEffect(() => { + if (showMtproto && !form.secret) { + update('secret', generateMtprotoSecret(mtprotoDomain)); + } + }, [showMtproto, form.secret, mtprotoDomain]); + const inboundOptions = useMemo( () => (inbounds || []) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) @@ -502,6 +537,10 @@ export default function ClientFormModal({ } } + if (showMtproto) { + clientPayload.secret = form.secret; + } + const externalLinks: ExternalLinkInput[] = form.externalLinks .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' })) .filter((r) => r.value !== ''); @@ -822,6 +861,14 @@ export default function ClientFormModal({ )} + {showMtproto && ( + + + update('secret', e.target.value)} /> +