From 66234315e4aaceabb580e7862d6c432e87cccdfc Mon Sep 17 00:00:00 2001 From: Leslie Alexander Date: Sun, 11 Oct 2026 13:17:05 +0800 Subject: [PATCH 01/13] fix(panel): default TLS ALPN to http/1.1 for WebSocket transport (#6787) * fix(panel): default TLS ALPN to http/1.1 for WebSocket transport WebSocket bootstraps over HTTP/1.1: leaving ALPN at the schema default ['h2','http/1.1'] makes the server negotiate h2 and the WS handshake fails. See issue #6782. * style(frontend): keep comments within the two-line limit * style(frontend): keep comments within the two-line limit --- frontend/src/lib/xray/inbound-tls-defaults.ts | 7 +++++- .../pages/inbounds/form/InboundFormModal.tsx | 6 +++++ .../pages/inbounds/form/useSecurityActions.ts | 3 ++- frontend/src/test/inbound-defaults.test.ts | 22 ++++++++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/xray/inbound-tls-defaults.ts b/frontend/src/lib/xray/inbound-tls-defaults.ts index 87e1e3115..3861e3fc9 100644 --- a/frontend/src/lib/xray/inbound-tls-defaults.ts +++ b/frontend/src/lib/xray/inbound-tls-defaults.ts @@ -14,7 +14,7 @@ function defaultCertificate(): Record { }; } -export function createTlsSettingsWithDefaultCert(): Record { +export function createTlsSettingsWithDefaultCert(network?: string): Record { const tls = TlsStreamSettingsSchema.parse({}) as Record; tls.certificates = [defaultCertificate()]; const settings = @@ -23,6 +23,11 @@ export function createTlsSettingsWithDefaultCert(): Record { : {}; settings.fingerprint = 'chrome'; tls.settings = settings; + /* WebSocket bootstraps over HTTP/1.1: the schema default ALPN + * ['h2','http/1.1'] makes the server negotiate h2 (see issue #6782). */ + if (network === 'ws') { + tls.alpn = ['http/1.1']; + } return tls; } diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index ac0954ea4..e5c8f7b55 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -894,6 +894,12 @@ export default function InboundFormModal({ cleaned.finalmask = { ...fm, udp }; } } + /* WebSocket needs HTTP/1.1 for its handshake; the TLS default + * ALPN ['h2','http/1.1'] makes the server negotiate h2 (see #6782). */ + if (next === 'ws' && cleaned.security === 'tls') { + const tls = (cleaned.tlsSettings as Record | undefined) ?? {}; + cleaned.tlsSettings = { ...tls, alpn: ['http/1.1'] }; + } setV('streamSettings', cleaned); }; diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index 85802e969..074ae86df 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -344,7 +344,8 @@ export function useSecurityActions({ delete cleaned.tlsSettings; delete cleaned.realitySettings; if (next === 'tls') { - cleaned.tlsSettings = createTlsSettingsWithDefaultCert(); + const network = (current.network as string | undefined) ?? ''; + cleaned.tlsSettings = createTlsSettingsWithDefaultCert(network); } if (next === 'reality') { const reality = RealityStreamSettingsSchema.parse({}) as Record; diff --git a/frontend/src/test/inbound-defaults.test.ts b/frontend/src/test/inbound-defaults.test.ts index 1422e6ea2..7f8f72ea4 100644 --- a/frontend/src/test/inbound-defaults.test.ts +++ b/frontend/src/test/inbound-defaults.test.ts @@ -18,12 +18,16 @@ import { createDefaultVmessInboundSettings, createDefaultWireguardInboundSettings, } from '@/lib/xray/inbound-defaults'; -import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults'; +import { + createHysteriaTlsSettingsWithDefaultCert, + createTlsSettingsWithDefaultCert, +} from '@/lib/xray/inbound-tls-defaults'; import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http'; import { HysteriaClientSchema, HysteriaInboundSettingsSchema, } from '@/schemas/protocols/inbound/hysteria'; +import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed'; import { ShadowsocksClientSchema, @@ -234,3 +238,19 @@ describe('createHysteriaTlsSettingsWithDefaultCert', () => { ]); }); }); + +describe('createTlsSettingsWithDefaultCert', () => { + it('keeps the schema ALPN default for non-WebSocket transports', () => { + for (const network of [undefined, '', 'tcp', 'kcp', 'grpc', 'httpupgrade', 'xhttp']) { + const tls = createTlsSettingsWithDefaultCert(network); + expect(tls.alpn).toEqual(['h2', 'http/1.1']); + } + }); + + it("defaults ALPN to http/1.1 for WebSocket (issue #6782)", () => { + const tls = createTlsSettingsWithDefaultCert('ws'); + expect(tls.alpn).toEqual(['http/1.1']); + // The overridden value must still satisfy the TLS settings schema. + expect(TlsStreamSettingsSchema.parse(tls).alpn).toEqual(['http/1.1']); + }); +}); From 82c77111893c8fb386bb7955cfb55186f47ad2ba Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 07:25:13 +0200 Subject: [PATCH 02/13] chore(deps): update Go and dependencies Raise the Go version to 1.27.2 and refresh Xray, LDAP, and related dependencies. --- go.mod | 33 ++++++++++++++------------- go.sum | 71 +++++++++++++++++++++++++++++++++------------------------- 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 29283408e..70afbf3a9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mhsanaei/3x-ui/v3 -go 1.27.1 +go 1.27.2 require ( github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 @@ -8,7 +8,7 @@ require ( github.com/gin-contrib/gzip v1.2.8 github.com/gin-contrib/sessions v1.1.2 github.com/gin-gonic/gin v1.12.0 - github.com/go-ldap/ldap/v3 v3.4.14 + github.com/go-ldap/ldap/v3 v3.4.15 github.com/go-playground/validator/v10 v10.30.5 github.com/goccy/go-json v0.11.2 github.com/goccy/go-yaml v1.19.2 @@ -28,12 +28,12 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/valyala/fasthttp v1.75.0 github.com/xlzd/gotp v0.1.0 - github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 + github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd go.uber.org/atomic v1.12.0 - golang.org/x/crypto v0.57.0 - golang.org/x/net v0.59.0 - golang.org/x/sys v0.48.0 - golang.org/x/text v0.42.0 + golang.org/x/crypto v0.58.0 + golang.org/x/net v0.61.0 + golang.org/x/sys v0.49.0 + golang.org/x/text v0.43.0 google.golang.org/grpc v1.86.0-dev google.golang.org/protobuf v1.36.12 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -67,7 +67,7 @@ require ( github.com/huin/goupnp v1.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jackc/puddle/v2 v2.2.3 // indirect github.com/jackpal/go-nat-pmp v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -83,7 +83,7 @@ require ( github.com/miekg/dns v1.1.73 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/molecule-man/go-brrr v1.1.1 // indirect + github.com/molecule-man/go-brrr v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pion/dtls/v3 v3.1.10 // indirect github.com/pion/logging v0.2.4 // indirect @@ -104,16 +104,19 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.mongodb.org/mongo-driver/v2 v2.9.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.9.2 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect - golang.org/x/arch v0.31.0 // indirect - golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/sync v0.23.0 // indirect + golang.org/x/arch v0.32.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e // indirect + golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 // indirect + golang.org/x/sync v0.24.0 // indirect golang.org/x/time v0.16.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect + golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect golang.zx2c4.com/wireguard/windows v1.1.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + layeh.com/gopher-luar v1.0.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 51ebfcce8..913f2751d 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,9 @@ github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= @@ -39,8 +42,8 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= -github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= +github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY= +github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -89,8 +92,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao= +github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1EdCs= github.com/jackpal/go-nat-pmp v1.1.0/go.mod h1:m9o4DK1wHA4h2pPpErD5vwzWLf91tJcfNQ3QyUIbh5A= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -144,8 +147,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY= -github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= +github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw= +github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/mymmrac/telego v1.12.1 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88= github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= @@ -223,14 +226,17 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 h1:Q5KwiMm+WRyw2kwPW4+fIQxNio1CyrWo/eAEaAHxl/Q= github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38/go.mod h1:/YQ6FwmAtkDuo8K3ujKQH1Br5eGNFauBipdl1gdiebk= -github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 h1:Bxo6+07IvdWlqxugsn2/sQrFbR7hjrxRRDMjbOInRho= -github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32/go.mod h1:FAjlDAzHXXyki7R1BxruCHFx19B3i8TvQGEy+wsqZWU= +github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd h1:Am/9Pzw5PZr7+hMN1Vs5faeSXRoutnrwMAgtUDnh/J8= +github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd/go.mod h1:YAACF9L/Pu1GznixHOBtrBqVcE95nhFjgU+gIEdWKlo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ= -go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.mongodb.org/mongo-driver/v2 v2.9.2 h1:OCAdjI37pC/SVxucz/9CM8zD4jCdegtsY5QcKzYFCgE= +go.mongodb.org/mongo-driver/v2 v2.9.2/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -239,40 +245,43 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk= -golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/arch v0.32.0 h1:NjGf8/xGf5JgAJ4dw4NtME5R1L7xatagqAg95T3LgII= +golang.org/x/arch v0.32.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e h1:jsB4A6J5vF93GeMqp72mLAOcFfGXgHtxpgeLk7OJB4M= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 h1:o259es6R0mYtmCeID0xv+dkp5kQSf6qp8zs0j4MdUaI= +golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= -golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= -golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE= golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.86.0-dev h1:FLq7hcApuJeJ70J9+k0FvmXsNCwS8B2/MeAfYdy3GHY= google.golang.org/grpc v1.86.0-dev/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= @@ -294,6 +303,8 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +layeh.com/gopher-luar v1.0.11 h1:8zJudpKI6HWkoh9eyyNFaTM79PY6CAPcIr6X/KTiliw= +layeh.com/gopher-luar v1.0.11/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc= From 73a028f52dd60b6666bd61712cf0dabdda395198 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 07:55:12 +0200 Subject: [PATCH 03/13] feat(xray): update xray-core to v26.10.10 and adapt panel Move the three Xray binary pins (DockerInit.sh, both release.yml fetches) to v26.10.10, in lockstep with the xtls/xray-core module that 82c77111 already bumped to 701af60772cd. xdns finalmask (#7090): a domain now takes a "names" list instead of a single "name", and a resolver is an "addrs" list of udp:// or tcp:// URLs instead of {type, settings:{addr}}. The loader ignores the old keys rather than failing, so every mask the 26.9.30 seeder wrote would load with no domain and no resolver and the tunnel would carry nothing. maskcompat.UpgradeLegacyXdns now lifts both the pre-26.9.30 string lists and the 26.9.30 objects straight to names/addrs, dropping entries the old loader refused. A new seeder (XdnsFinalmaskNamesAddrsFix) reruns it over every stored finalmask, and the save path, GetXrayConfig, both link importers and the mask editor's mount lift go through it. Template outbounds now get the build-time heal too: a 26.9.30 template pasted after the seeder passes the save check, since the core loads that shape without error. The editor edits names and addrs as tag lists and no longer requires record types, which the core now defaults (TXT on a client, A/CNAME/TXT/AAAA on a server). The wire format did not change, but clients on an older core cannot read the new config shape. Lua scripts (#6823): dns.script, routing.script and dns servers[].id are new. The xray page parsed the dns block with a strict schema, so loading it dropped script and id and the next save deleted them; the schema now keeps both and the DNS server modal carries the id through an edit. The hot routing apply built routing in-process, and that build now resolves the script file from the panel's working directory, so a script beside the xray binary failed every rule change into a full restart and broke the node bridge. RoutingService cannot change the script anyway, so the panel no longer resolves it there. TLS client roots (#7105): the core now verifies against its bundled Mozilla roots instead of the OS store unless tlsSettings.useSystemCA is set. The outbound TLS form gets a switch for it, so an outbound to a server signed by a privately installed CA can opt back in. --- .github/workflows/release.yml | 4 +- DockerInit.sh | 2 +- .../xray/forms/transport/FinalMaskForm.tsx | 41 +++--- frontend/src/lib/xray/xdns-mask.ts | 67 +++++++--- .../src/pages/xray/dns/DnsServerModal.tsx | 5 + .../src/pages/xray/outbounds/security/tls.tsx | 9 +- frontend/src/schemas/dns.ts | 2 + .../test/__snapshots__/finalmask.test.ts.snap | 15 ++- frontend/src/test/dns-server-modal.test.tsx | 30 +++++ .../golden/fixtures/finalmask/udp-mask.json | 6 +- .../src/test/outbound-form-modal.test.tsx | 92 ++++++++++++-- .../src/test/outbound-link-parser.test.ts | 25 ++-- frontend/src/test/use-xray-setting.test.tsx | 26 ++++ internal/database/db.go | 14 +- .../database/xdns_finalmask_migration_test.go | 22 ++-- internal/util/link/outbound_test.go | 8 +- internal/util/maskcompat/xdns.go | 120 +++++++++++++----- internal/util/maskcompat/xdns_test.go | 98 +++++++++----- internal/web/service/inbound.go | 4 +- .../service/inbound_finalmask_xdns_test.go | 72 +++++++---- internal/web/service/xray.go | 28 ++-- .../web/service/xray_xhttp_session_test.go | 6 +- internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + internal/xray/api.go | 27 ++-- internal/xray/api_routing_test.go | 22 ++++ 37 files changed, 553 insertions(+), 205 deletions(-) create mode 100644 frontend/src/test/dns-server-modal.test.tsx create mode 100644 internal/xray/api_routing_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3afb3ef9d..2e277f000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: cd x-ui/bin # Download dependencies - Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.9.30/" + Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.10/" if [ "${{ matrix.platform }}" == "amd64" ]; then fetch ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -278,7 +278,7 @@ jobs: cd x-ui\bin # Download Xray for Windows - $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.9.30/" + $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/" Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/DockerInit.sh b/DockerInit.sh index f488635a2..18fccad21 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -33,7 +33,7 @@ if [ -z "$MTG_MULTI_VER" ]; then fi mkdir -p build/bin cd build/bin -curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.9.30/Xray-linux-${ARCH}.zip" +curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.10/Xray-linux-${ARCH}.zip" unzip "Xray-linux-${ARCH}.zip" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 4029e62dd..6efffd7f6 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -246,7 +246,7 @@ export default function FinalMaskForm({ const base = asPath(name); // Migrate legacy mask shapes once on mount so configs saved before #6334 (fragment - // ranges), #6487 (xmc profiles) and #6718 (xdns objects) render in the list UI. + // ranges), #6487 (xmc profiles) and #7090 (xdns names/addrs) render in the list UI. const migratedRef = useRef(false); useEffect(() => { if (migratedRef.current) return; @@ -1280,7 +1280,7 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { size="small" icon={} aria-label={t('add')} - onClick={() => add({ name: '', types: [16], edns0: XDNS_LEGACY_EDNS0 })} + onClick={() => add({ names: [], edns0: XDNS_LEGACY_EDNS0 })} /> {domains.map((domain, di) => ( @@ -1296,15 +1296,20 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { onKeyDown={activateOnKey(() => remove(domain.name))} /> - - + + + + - - - diff --git a/frontend/src/test/warp-masque-modal.test.tsx b/frontend/src/test/warp-masque-modal.test.tsx new file mode 100644 index 000000000..d440d4e7d --- /dev/null +++ b/frontend/src/test/warp-masque-modal.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; + +import WarpModal from '@/pages/xray/overrides/WarpModal'; +import { HttpUtil, Msg } from '@/utils'; +import { renderWithProviders } from './test-utils'; + +const registration = { + privateKey: '-----BEGIN PRIVATE KEY-----\nAAA\n-----END PRIVATE KEY-----\n', + publicKey: '-----BEGIN PUBLIC KEY-----\nBBB\n-----END PUBLIC KEY-----\n', + address: ['172.16.0.2', '2606:4700:110:8a36::2'], + endpoint: '162.159.198.1', +}; + +function mockApi() { + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/xray/warp/regMasque') { + return new Msg(true, '', JSON.stringify(registration)); + } + if (url === '/panel/api/setting/all') return new Msg(true, '', {}); + return new Msg(true, '', ''); + }); +} + +async function clickAddMasque() { + const button = await screen.findByRole('button', { name: /Add WARP over MASQUE outbound/ }); + // The modal loads its WARP data on open; a loading button swallows the click. + await waitFor(() => expect(button.className).not.toContain('ant-btn-loading')); + await act(async () => { + fireEvent.click(button); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// WARP over MASQUE dials Cloudflare's MASQUE endpoint with the enrolled key, its SNI +// and HTTP/3; an outbound missing any of them fails the handshake at runtime. +describe('WarpModal WARP over MASQUE', () => { + const expected = { + tag: 'warp-masque', + protocol: 'masque', + settings: { address: '162.159.198.1', port: 443 }, + streamSettings: { + network: 'masque', + security: 'tls', + tlsSettings: { serverName: 'consumer-masque.cloudflareclient.com', alpn: ['h3'] }, + masqueSettings: { + warp: { + privateKey: registration.privateKey, + publicKey: registration.publicKey, + address: registration.address, + }, + }, + }, + }; + + it('adds a MASQUE outbound built from a fresh enrollment', async () => { + mockApi(); + const onAddOutbound = vi.fn(); + renderWithProviders( + {}} + onAddOutbound={onAddOutbound} + onResetOutbound={() => {}} + onRemoveOutbound={() => {}} + />, + ); + + await clickAddMasque(); + + await waitFor(() => expect(onAddOutbound).toHaveBeenCalledWith(expected)); + }); + + it('replaces an existing warp-masque outbound instead of adding a second one', async () => { + mockApi(); + const onAddOutbound = vi.fn(); + const onResetOutbound = vi.fn(); + renderWithProviders( + {}} + onAddOutbound={onAddOutbound} + onResetOutbound={onResetOutbound} + onRemoveOutbound={() => {}} + />, + ); + + await clickAddMasque(); + + await waitFor(() => + expect(onResetOutbound).toHaveBeenCalledWith({ index: 1, outbound: expected }), + ); + expect(onAddOutbound).not.toHaveBeenCalled(); + }); +}); diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index ca8b9ac72..16ea5fb36 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -195,6 +195,8 @@ func (a *XraySettingController) warp(c *gin.Context) { skey := c.PostForm("privateKey") pkey := c.PostForm("publicKey") resp, err = a.WarpService.RegWarp(skey, pkey) + case "regMasque": + resp, err = a.WarpService.RegWarpMasque() case "changeIp": resp, err = a.WarpService.ChangeWarpIP() if err == nil { diff --git a/internal/web/service/integration/warp.go b/internal/web/service/integration/warp.go index 86e7d6afc..4c77e8f42 100644 --- a/internal/web/service/integration/warp.go +++ b/internal/web/service/integration/warp.go @@ -3,9 +3,16 @@ package integration import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "io" + "net" "net/http" "os" "time" @@ -30,6 +37,12 @@ const ( // (not a const) so integration tests can point it at a mock server. var warpAPIBase = "https://api.cloudflareclient.com/v0a4005" +// warpMasqueAPIBase and warpMasqueClientVer are the API revision and client the WARP +// apps enroll secp256r1 MASQUE keys with; a var so tests can point it at a mock. +var warpMasqueAPIBase = "https://api.cloudflareclient.com/v0a4471" + +const warpMasqueClientVer = "a-6.35-4471" + func (s *WarpService) GetWarpData() (string, error) { return s.GetWarp() } @@ -134,6 +147,119 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error return string(result), nil } +// RegWarpMasque registers a WARP device of its own and enrolls an ECDSA P-256 key for +// MASQUE: enrolling replaces a device's WireGuard key, so the stored one stays untouched. +func (s *WarpService) RegWarpMasque() (string, error) { + _, wgPublicKey, err := wireguard.GenerateWireguardKeypair() + if err != nil { + return "", err + } + hostName, _ := os.Hostname() + regBody, err := json.Marshal(map[string]any{ + "key": wgPublicKey, + "tos": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + "type": "PC", + "model": "x-ui", + "name": hostName, + }) + if err != nil { + return "", err + } + var registration struct { + ID string `json:"id"` + Token string `json:"token"` + } + if err := s.doWarpMasqueRequest(http.MethodPost, warpMasqueAPIBase+"/reg", "", regBody, ®istration); err != nil { + return "", err + } + if registration.ID == "" || registration.Token == "" { + return "", common.NewError("warp masque register: missing 'id' or 'token' in response") + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", err + } + publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return "", err + } + enrollBody, err := json.Marshal(map[string]string{ + "key": base64.StdEncoding.EncodeToString(publicDER), + "key_type": "secp256r1", + "tunnel_type": "masque", + "name": hostName, + }) + if err != nil { + return "", err + } + var enrolled struct { + Config struct { + Interface struct { + Addresses struct { + V4 string `json:"v4"` + V6 string `json:"v6"` + } `json:"addresses"` + } `json:"interface"` + Peers []struct { + PublicKey string `json:"public_key"` + Endpoint struct { + V4 string `json:"v4"` + } `json:"endpoint"` + } `json:"peers"` + } `json:"config"` + } + if err := s.doWarpMasqueRequest(http.MethodPatch, warpMasqueAPIBase+"/reg/"+registration.ID, registration.Token, enrollBody, &enrolled); err != nil { + return "", err + } + if len(enrolled.Config.Peers) == 0 || enrolled.Config.Peers[0].PublicKey == "" { + return "", common.NewError("warp masque enroll: missing endpoint public key in response") + } + peer := enrolled.Config.Peers[0] + endpoint, _, err := net.SplitHostPort(peer.Endpoint.V4) + if err != nil { + endpoint = peer.Endpoint.V4 + } + privateDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", err + } + var address []string + for _, a := range []string{enrolled.Config.Interface.Addresses.V4, enrolled.Config.Interface.Addresses.V6} { + if a != "" { + address = append(address, a) + } + } + result, err := json.MarshalIndent(map[string]any{ + "privateKey": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER})), + "publicKey": peer.PublicKey, + "address": address, + "endpoint": endpoint, + }, "", " ") + if err != nil { + return "", err + } + return string(result), nil +} + +func (s *WarpService) doWarpMasqueRequest(method, url, token string, body []byte, out any) error { + req, err := http.NewRequestWithContext(context.Background(), method, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("CF-Client-Version", warpMasqueClientVer) + req.Header.Set("User-Agent", "WARP for Android") + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + respBody, err := s.doWarpRequest(req) + if err != nil { + return err + } + return json.Unmarshal(respBody, out) +} + func (s *WarpService) SetWarpLicense(license string) (string, error) { warpData, err := s.loadWarpCreds() if err != nil { diff --git a/internal/web/service/integration/warp_masque_test.go b/internal/web/service/integration/warp_masque_test.go new file mode 100644 index 000000000..7f2953f3d --- /dev/null +++ b/internal/web/service/integration/warp_masque_test.go @@ -0,0 +1,136 @@ +package integration + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/xtls/xray-core/infra/conf" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func withWarpMasqueAPIBase(t *testing.T, base string) { + t.Helper() + orig := warpMasqueAPIBase + warpMasqueAPIBase = base + t.Cleanup(func() { warpMasqueAPIBase = orig }) +} + +// Enrolling an ECDSA key replaces a device's WireGuard key, so MASQUE gets a device of +// its own; the returned warp block must pair the enrolled key and load in xray-core. +func TestRegWarpMasqueEnrollsASeparateP256Device(t *testing.T) { + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + seedWarp(t, "") + + endpointKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate endpoint key: %v", err) + } + endpointDER, err := x509.MarshalPKIXPublicKey(&endpointKey.PublicKey) + if err != nil { + t.Fatalf("marshal endpoint key: %v", err) + } + endpointPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: endpointDER})) + + var enrolledKey *ecdsa.PublicKey + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/reg": + _, _ = w.Write([]byte(`{"id":"masque-device","token":"masque-token","account":{"license":""}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/reg/masque-device": + if got := r.Header.Get("Authorization"); got != "Bearer masque-token" { + t.Errorf("enroll Authorization = %q, want the new device's token", got) + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["key_type"] != "secp256r1" || body["tunnel_type"] != "masque" { + t.Errorf("enroll body = %v, want a secp256r1 key for the masque tunnel", body) + } + der, err := base64.StdEncoding.DecodeString(body["key"]) + if err != nil { + t.Errorf("enroll key is not base64: %v", err) + } + parsed, err := x509.ParsePKIXPublicKey(der) + key, ok := parsed.(*ecdsa.PublicKey) + if err != nil || !ok || key.Curve != elliptic.P256() { + t.Errorf("enroll key is not a PKIX P-256 public key: %v", err) + } + enrolledKey = key + resp, _ := json.Marshal(map[string]any{ + "id": "masque-device", + "config": map[string]any{ + "interface": map[string]any{"addresses": map[string]any{"v4": "172.16.0.2", "v6": "2606:4700:110:8a36::2"}}, + "peers": []any{map[string]any{ + "public_key": endpointPEM, + "endpoint": map[string]any{"v4": "162.159.198.1:0", "v6": "[2606:4700:103::1]:0"}, + }}, + }, + }) + _, _ = w.Write(resp) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + withWarpMasqueAPIBase(t, srv.URL) + + out, err := (&WarpService{}).RegWarpMasque() + if err != nil { + t.Fatalf("RegWarpMasque: %v", err) + } + var result struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` + Address []string `json:"address"` + Endpoint string `json:"endpoint"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Endpoint != "162.159.198.1" || result.PublicKey != endpointPEM { + t.Fatalf("endpoint = %q, publicKey = %q; want the enrolled peer's", result.Endpoint, result.PublicKey) + } + + block, _ := pem.Decode([]byte(result.PrivateKey)) + if block == nil { + t.Fatalf("privateKey %q is not PEM", result.PrivateKey) + } + private, err := x509.ParsePKCS8PrivateKey(block.Bytes) + ecPrivate, ok := private.(*ecdsa.PrivateKey) + if err != nil || !ok || enrolledKey == nil || !ecPrivate.PublicKey.Equal(enrolledKey) { + t.Fatal("the returned private key does not match the enrolled public key") + } + + raw, _ := json.Marshal(map[string]any{"warp": map[string]any{ + "privateKey": result.PrivateKey, "publicKey": result.PublicKey, "address": result.Address, + }}) + var masque conf.MasqueConfig + if err := json.Unmarshal(raw, &masque); err != nil { + t.Fatalf("decode masque config: %v", err) + } + if _, err := masque.Build(); err != nil { + t.Fatalf("xray-core refuses the returned warp block: %v", err) + } + + var stored model.Setting + if err := database.GetDB().Where("key = ?", "warp").First(&stored).Error; err != nil { + t.Fatalf("reload warp setting: %v", err) + } + var data map[string]string + _ = json.Unmarshal([]byte(stored.Value), &data) + if data["device_id"] != "old-device" { + t.Fatalf("stored WireGuard registration = %v, want it untouched", data) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index f1e5aa9f2..47f00497b 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2229,7 +2229,9 @@ "warpPlusData": "بيانات WARP+", "quota": "الحصة", "usage": "الاستخدام", - "addOutbound": "إضافة صادر" + "addOutbound": "إضافة صادر", + "masqueDesc": "يسجّل جهاز WARP منفصلًا مخصصًا لـ MASQUE؛ ولا يتأثر تسجيل WireGuard أعلاه.", + "addMasqueOutbound": "إضافة outbound لـ WARP عبر MASQUE" }, "dns": { "enable": "فعل DNS", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 307c591ca..f1fbf4a17 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -2347,7 +2347,9 @@ "warpPlusData": "WARP+ data", "quota": "Quota", "usage": "Usage", - "addOutbound": "Add outbound" + "addOutbound": "Add outbound", + "masqueDesc": "Registers a separate WARP device enrolled for MASQUE; the WireGuard registration above is not affected.", + "addMasqueOutbound": "Add WARP over MASQUE outbound" }, "dns": { "enable": "Enable DNS", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 118033782..02f29b1c0 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -2229,7 +2229,9 @@ "warpPlusData": "Datos WARP+", "quota": "Cuota", "usage": "Uso", - "addOutbound": "Añadir salida" + "addOutbound": "Añadir salida", + "masqueDesc": "Registra un dispositivo WARP aparte inscrito para MASQUE; el registro de WireGuard de arriba no se ve afectado.", + "addMasqueOutbound": "Añadir outbound de WARP sobre MASQUE" }, "dns": { "enable": "Habilitar DNS", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 4004bd407..9e7f2ac04 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2229,7 +2229,9 @@ "warpPlusData": "داده WARP+", "quota": "سهمیه", "usage": "مصرف", - "addOutbound": "افزودن خروجی" + "addOutbound": "افزودن خروجی", + "masqueDesc": "یک دستگاه WARP جداگانه برای MASQUE ثبت می‌کند؛ ثبت WireGuard بالا تغییری نمی‌کند.", + "addMasqueOutbound": "افزودن outbound برای WARP روی MASQUE" }, "dns": { "enable": "فعال کردن حل دامنه", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 2daeea7d9..8ac58554e 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -2229,7 +2229,9 @@ "warpPlusData": "Data WARP+", "quota": "Kuota", "usage": "Penggunaan", - "addOutbound": "Tambah outbound" + "addOutbound": "Tambah outbound", + "masqueDesc": "Mendaftarkan perangkat WARP terpisah untuk MASQUE; pendaftaran WireGuard di atas tidak terpengaruh.", + "addMasqueOutbound": "Tambah outbound WARP melalui MASQUE" }, "dns": { "enable": "Aktifkan DNS", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 36df3b947..98aee605f 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2229,7 +2229,9 @@ "warpPlusData": "WARP+ データ", "quota": "クォータ", "usage": "使用量", - "addOutbound": "アウトバウンドを追加" + "addOutbound": "アウトバウンドを追加", + "masqueDesc": "MASQUE 用に別の WARP デバイスを登録します。上の WireGuard の登録には影響しません。", + "addMasqueOutbound": "WARP over MASQUE のアウトバウンドを追加" }, "dns": { "enable": "DNSを有効にする", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 64143efe7..3b8e36dcf 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -2229,7 +2229,9 @@ "warpPlusData": "Dados WARP+", "quota": "Quota", "usage": "Uso", - "addOutbound": "Adicionar saída" + "addOutbound": "Adicionar saída", + "masqueDesc": "Registra um dispositivo WARP separado inscrito para MASQUE; o registro WireGuard acima não é afetado.", + "addMasqueOutbound": "Adicionar outbound WARP via MASQUE" }, "dns": { "enable": "Ativar DNS", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 855ccfc5b..a438036da 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2229,7 +2229,9 @@ "warpPlusData": "WARP+ data", "quota": "Квота", "usage": "Использование", - "addOutbound": "Добавить исходящий" + "addOutbound": "Добавить исходящий", + "masqueDesc": "Регистрирует отдельное устройство WARP для MASQUE; регистрация WireGuard выше не затрагивается.", + "addMasqueOutbound": "Добавить аутбаунд WARP через MASQUE" }, "dns": { "enable": "Включить DNS", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 6ecac8b1d..8ce5bac44 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -2229,7 +2229,9 @@ "warpPlusData": "WARP+ Veri", "quota": "Kota", "usage": "Kullanım", - "addOutbound": "Giden Bağlantı Ekle" + "addOutbound": "Giden Bağlantı Ekle", + "masqueDesc": "MASQUE için ayrı bir WARP cihazı kaydeder; yukarıdaki WireGuard kaydı etkilenmez.", + "addMasqueOutbound": "MASQUE üzerinden WARP outbound ekle" }, "dns": { "enable": "DNS'yi Etkinleştir", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 9584b2dc2..9ac1da9b8 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2229,7 +2229,9 @@ "warpPlusData": "WARP+ data", "quota": "Квота", "usage": "Використання", - "addOutbound": "Додати вихідний" + "addOutbound": "Додати вихідний", + "masqueDesc": "Реєструє окремий пристрій WARP для MASQUE; реєстрація WireGuard вище не змінюється.", + "addMasqueOutbound": "Додати аутбаунд WARP через MASQUE" }, "dns": { "enable": "Увімкнути DNS", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index d263a9237..aba0dd526 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -2229,7 +2229,9 @@ "warpPlusData": "Dữ liệu WARP+", "quota": "Hạn ngạch", "usage": "Sử dụng", - "addOutbound": "Thêm outbound" + "addOutbound": "Thêm outbound", + "masqueDesc": "Đăng ký một thiết bị WARP riêng cho MASQUE; đăng ký WireGuard ở trên không bị ảnh hưởng.", + "addMasqueOutbound": "Thêm outbound WARP qua MASQUE" }, "dns": { "enable": "Kích hoạt DNS", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index e7ab80592..2c6e8f1a2 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2229,7 +2229,9 @@ "warpPlusData": "WARP+ 数据", "quota": "配额", "usage": "使用", - "addOutbound": "添加出站" + "addOutbound": "添加出站", + "masqueDesc": "为 MASQUE 单独注册一个 WARP 设备;上方的 WireGuard 注册不受影响。", + "addMasqueOutbound": "添加 WARP over MASQUE 出站" }, "dns": { "enable": "启用 DNS", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 7bde6e021..859569342 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2229,7 +2229,9 @@ "warpPlusData": "WARP+ 資料", "quota": "配額", "usage": "使用", - "addOutbound": "新增出站" + "addOutbound": "新增出站", + "masqueDesc": "為 MASQUE 另外註冊一個 WARP 裝置;上方的 WireGuard 註冊不受影響。", + "addMasqueOutbound": "新增 WARP over MASQUE 出站" }, "dns": { "enable": "啟用 DNS", From da6872a3b8b7cc58669d756f77145abd1c689392 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 10:56:39 +0200 Subject: [PATCH 07/13] chore(frontend): let lint-staged pass commits that stage only generated files lint-staged hands every staged src/**/*.ts file to oxfmt and oxlint, but both ignore src/generated, so a commit whose only TypeScript change is a regenerated schema (a validator tag edit plus make gen) aborts with "Expected at least one target file". Tell both tools an empty selection is not an error. --- frontend/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index abb3c5c1f..861bc6f2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,8 +29,8 @@ }, "lint-staged": { "src/**/*.{ts,tsx}": [ - "oxfmt", - "oxlint --fix" + "oxfmt --no-error-on-unmatched-pattern", + "oxlint --fix --no-error-on-unmatched-pattern" ] }, "dependencies": { From 00aaef386fdc76f5566e83fdccfbe113ce318b1e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 10:56:47 +0200 Subject: [PATCH 08/13] fix(inbounds): accept masque in the inbound protocol validator The inbound add and update handlers bind the body into model.Inbound and validate Protocol against a oneof list that predates MASQUE, so every save of a MASQUE inbound from the panel or the API failed validation even though the rest of the stack (b04039c2) handles the protocol. Add masque to the list and regenerate the schemas and OpenAPI document that mirror it. --- docs/public/openapi.json | 3 ++- frontend/public/openapi.json | 3 ++- frontend/src/generated/schemas.ts | 3 ++- frontend/src/generated/zod.ts | 2 +- internal/database/model/model.go | 2 +- .../web/middleware/validate_protocol_test.go | 18 ++++++++++++++++++ 6 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 internal/web/middleware/validate_protocol_test.go diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 749ec2788..570f87ec3 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -3115,7 +3115,8 @@ "tun", "mtproto", "amneziawg", - "tuic" + "tuic", + "masque" ], "example": "vless", "type": "string" diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 749ec2788..570f87ec3 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -3115,7 +3115,8 @@ "tun", "mtproto", "amneziawg", - "tuic" + "tuic", + "masque" ], "example": "vless", "type": "string" diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 4c72b6ac7..6208b5d3f 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -3089,7 +3089,8 @@ export const SCHEMAS: Record = { "tun", "mtproto", "amneziawg", - "tuic" + "tuic", + "masque" ], "example": "vless", "type": "string" diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index b3fbcd047..744b93c06 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -757,7 +757,7 @@ export const InboundSchema = z.object({ nodeId: z.number().int().nullable().optional(), originNodeGuid: z.string().optional(), port: z.number().int().min(0).max(65535), - protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg', 'tuic']), + protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto', 'amneziawg', 'tuic', 'masque']), remark: z.string(), settings: z.unknown(), shareAddr: z.string(), diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 831f14803..18c61158f 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -65,7 +65,7 @@ type Inbound struct { // Xray configuration fields Listen string `json:"listen" form:"listen"` Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"` - Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg tuic" example:"vless"` + Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg tuic masque" example:"vless"` Settings string `json:"settings" form:"settings"` StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"` diff --git a/internal/web/middleware/validate_protocol_test.go b/internal/web/middleware/validate_protocol_test.go new file mode 100644 index 000000000..be98a5301 --- /dev/null +++ b/internal/web/middleware/validate_protocol_test.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// The inbound add/update handlers validate the body with this validator, so a protocol +// missing from Inbound.Protocol's oneof makes every save of that protocol fail. +func TestValidateAcceptsMasqueInboundProtocol(t *testing.T) { + if err := validate.StructPartial(model.Inbound{Protocol: model.MASQUE}, "Protocol"); err != nil { + t.Fatalf("validate rejected a masque inbound: %v", err) + } + if err := validate.StructPartial(model.Inbound{Protocol: "dokodemo"}, "Protocol"); err == nil { + t.Fatal("validate accepted an unknown protocol; the oneof no longer guards it") + } +} From ec0a57fc4f9d06fe330382691e4321cc267f87a8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 11:20:34 +0200 Subject: [PATCH 09/13] fix(finalmask): hop client UDP through the udphop mask xray-core 26.9.9 dropped finalmask.quicParams.udpHop for the client-only "udphop" UDP mask {mode, interval, remoteIPs, remotePorts}. The panel kept writing the old key, so hysteria/xhttp outbounds and JSON-subscription clients silently stopped hopping ports. An inbound keeps quicParams.udpHop: it is panel metadata advertised as the link's mport, and a udphop mask on a listener fails with "udphop: client only". The conversion (maskcompat.UpgradeLegacyUDPHop, intervalRemote mode) runs only where a client config is produced: - UDPHopClientMaskFix seeder over hosts, sub-JSON finalmask, cached outbound-subscription outbounds and template outbounds (never inbounds); - GetXrayConfig heals template outbounds at build time; - the JSON subscription upgrades each generated client stream; - the Go and frontend link importers upgrade a legacy fm= param. The finalmask editor gains a side prop: the client side lifts the dead key on mount and offers a udphop mask editor (also for xhttp's HTTP/3 dial); the inbound side keeps the UDP Hop switch, labelled as advertised. --- docs/content/docs/en/config/transports.mdx | 13 +- docs/content/docs/fa/config/transports.mdx | 13 +- docs/content/docs/ru/config/transports.mdx | 13 +- docs/content/docs/zh/config/transports.mdx | 9 +- .../lib/xray/forms/fields/FinalMaskField.tsx | 3 + .../xray/forms/transport/FinalMaskForm.tsx | 150 +++++++++++++++--- frontend/src/lib/xray/outbound-link-parser.ts | 7 +- frontend/src/lib/xray/udphop-mask.ts | 29 ++++ .../pages/inbounds/form/InboundFormModal.tsx | 1 + frontend/src/test/inbound-form-modal.test.tsx | 50 ++++++ .../src/test/outbound-form-modal.test.tsx | 64 ++++++++ .../src/test/outbound-link-parser.test.ts | 29 +++- internal/database/db.go | 84 ++++++++-- .../udphop_client_mask_migration_test.go | 117 ++++++++++++++ internal/sub/json_service.go | 4 + internal/sub/masque_test.go | 33 ++++ internal/util/link/outbound.go | 1 + internal/util/link/outbound_test.go | 23 ++- internal/util/maskcompat/udphop.go | 54 +++++++ internal/util/maskcompat/udphop_test.go | 106 +++++++++++++ .../service/inbound_finalmask_xdns_test.go | 35 ++++ internal/web/service/xray.go | 10 +- 22 files changed, 797 insertions(+), 51 deletions(-) create mode 100644 frontend/src/lib/xray/udphop-mask.ts create mode 100644 internal/database/udphop_client_mask_migration_test.go create mode 100644 internal/util/maskcompat/udphop.go create mode 100644 internal/util/maskcompat/udphop_test.go diff --git a/docs/content/docs/en/config/transports.mdx b/docs/content/docs/en/config/transports.mdx index 6033aa7fa..dbf45a460 100644 --- a/docs/content/docs/en/config/transports.mdx +++ b/docs/content/docs/en/config/transports.mdx @@ -147,11 +147,16 @@ TLS. Masks are configured per direction: stream as Minecraft protocol traffic; requires a password, with optional hostname and player usernames). - **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`, - `noise`, `sudoku`, `realm`. (`mkcp-legacy` reproduces the old mKCP header - obfuscation.) + `noise`, `sudoku`, `realm`, plus the client-only `udphop`. (`mkcp-legacy` + reproduces the old mKCP header obfuscation; `udphop` rotates the remote port — + and optionally the remote IP — on an interval or per connection to dodge port + blocking.) - **QUIC params** — congestion control (`reno`, `bbr`, `brutal`, `force-brutal`), - Brutal up/down rates, `udpHop` (rotate the QUIC port across a range to dodge - port blocking), and receive-window tuning. + Brutal up/down rates, and receive-window tuning. On an inbound, **UDP Hop** sets + the port range advertised to clients (the share link's `mport` and the JSON + subscription's `udphop` mask); the server still listens on its own port. Since + Xray 26.9.9 a client hops only through the `udphop` mask, so the panel converts + an older client `quicParams.udpHop` to it. FinalMask replaces the per-transport `header`/`seed` obfuscation that older Xray builds exposed. diff --git a/docs/content/docs/fa/config/transports.mdx b/docs/content/docs/fa/config/transports.mdx index f87f9eda8..0e02eb1b8 100644 --- a/docs/content/docs/fa/config/transports.mdx +++ b/docs/content/docs/fa/config/transports.mdx @@ -148,11 +148,16 @@ TLS/REALITY جداگانه‌ای ندارد — ترافیکش همان HTTPS پروتکل Minecraft استتار می‌کند؛ گذرواژه الزامی است و نام میزبان و نام‌های بازیکن اختیاری‌اند). - **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`، - `noise`، `sudoku`، `realm`. (`mkcp-legacy` همان مبهم‌سازی قدیمی هدر mKCP را - بازتولید می‌کند.) + `noise`، `sudoku`، `realm` و `udphop` که فقط سمت کلاینت است. (`mkcp-legacy` + همان مبهم‌سازی قدیمی هدر mKCP را بازتولید می‌کند؛ `udphop` پورت مقصد — و در صورت + نیاز IP مقصد — را در فواصل زمانی یا به‌ازای هر اتصال عوض می‌کند تا مسدودسازی پورت + دور زده شود.) - **پارامترهای QUIC** — کنترل ازدحام (`reno`، `bbr`، `brutal`، `force-brutal`)، - نرخ‌های آپلود/دانلود Brutal، `udpHop` (چرخاندن پورت QUIC در یک بازه برای دور زدن - مسدودسازی پورت)، و تنظیم پنجره دریافت. + نرخ‌های آپلود/دانلود Brutal، و تنظیم پنجره دریافت. در اینباند، **UDP Hop** بازه‌ی + پورتی را تعیین می‌کند که به کلاینت‌ها اعلام می‌شود (`mport` در لینک اشتراک و ماسک + `udphop` در اشتراک JSON)؛ خود سرور همچنان روی پورت خودش گوش می‌دهد. از Xray + 26.9.9 کلاینت فقط با ماسک `udphop` پورت عوض می‌کند، برای همین پنل `quicParams.udpHop` + قدیمی کلاینت را به آن تبدیل می‌کند. ‏FinalMask جایگزین مبهم‌سازی `header`/`seed` به‌ازای هر انتقال می‌شود که بیلدهای قدیمی‌تر Xray نمایش می‌دادند. diff --git a/docs/content/docs/ru/config/transports.mdx b/docs/content/docs/ru/config/transports.mdx index 72771a9f1..c7b5ad51f 100644 --- a/docs/content/docs/ru/config/transports.mdx +++ b/docs/content/docs/ru/config/transports.mdx @@ -150,11 +150,16 @@ XDRIVE передаёт поток файлами в папке, доступн под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков опциональны). - **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`, - `noise`, `sudoku`, `realm`. (`mkcp-legacy` воспроизводит старую обфускацию - заголовка mKCP.) + `noise`, `sudoku`, `realm`, а также только клиентская `udphop`. (`mkcp-legacy` + воспроизводит старую обфускацию заголовка mKCP; `udphop` меняет удалённый порт — + и при желании удалённый IP — по интервалу или для каждого соединения, чтобы + обходить блокировку портов.) - **Параметры QUIC** — управление перегрузкой (`reno`, `bbr`, `brutal`, - `force-brutal`), скорости отдачи/приёма Brutal, `udpHop` (ротация QUIC-порта в - пределах диапазона для обхода блокировки портов) и настройка окна приёма. + `force-brutal`), скорости отдачи/приёма Brutal и настройка окна приёма. На + инбаунде **UDP Hop** задаёт диапазон портов, который объявляется клиентам + (`mport` в ссылке и маска `udphop` в JSON-подписке); сам сервер слушает свой + порт. Начиная с Xray 26.9.9 клиент меняет порт только через маску `udphop`, + поэтому панель преобразует старый клиентский `quicParams.udpHop` в неё. FinalMask заменяет обфускацию `header`/`seed` на уровне отдельного транспорта, которую предоставляли старые сборки Xray. diff --git a/docs/content/docs/zh/config/transports.mdx b/docs/content/docs/zh/config/transports.mdx index 3326d712e..3e04286ec 100644 --- a/docs/content/docs/zh/config/transports.mdx +++ b/docs/content/docs/zh/config/transports.mdx @@ -138,9 +138,14 @@ XDRIVE 没有自己的 TLS/REALITY 层——其流量就是存储 API 自身的 - **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成 Minecraft 协议;密码必填,主机名和玩家用户名可选)。 - **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、 - `noise`、`sudoku`、`realm`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆。) + `noise`、`sudoku`、`realm`,以及仅限客户端的 `udphop`。(`mkcp-legacy` 重现旧版 + mKCP 的头部混淆;`udphop` 按时间间隔或按连接轮换远端端口——也可轮换远端 IP——以规避 + 端口封锁。) - **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行 - 速率、`udpHop`(在一个范围内轮换 QUIC 端口以规避端口封锁)以及接收窗口调优。 + 速率以及接收窗口调优。在入站上,**UDP Hop** 设置向客户端公布的端口范围(分享链接的 + `mport` 和 JSON 订阅中的 `udphop` 掩码);服务器本身仍只监听自己的端口。自 Xray + 26.9.9 起客户端只通过 `udphop` 掩码跳端口,因此面板会把客户端旧的 + `quicParams.udpHop` 转换为该掩码。 FinalMask 取代了旧版 Xray 构建中暴露的、按每种传输各自实现的 `header`/`seed` 混淆。 diff --git a/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx b/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx index 0ca265f6f..10a83d690 100644 --- a/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx +++ b/frontend/src/lib/xray/forms/fields/FinalMaskField.tsx @@ -10,6 +10,7 @@ interface FinalMaskFieldProps { network: string; protocol: string; showAll?: boolean; + side?: 'server' | 'client'; } const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] }; @@ -20,6 +21,7 @@ export default function FinalMaskField({ network, protocol, showAll, + side, }: FinalMaskFieldProps) { const [form] = Form.useForm(); const [initial] = useState(() => value ?? EMPTY); @@ -55,6 +57,7 @@ export default function FinalMaskField({ protocol={protocol} form={form} showAll={showAll} + side={side} /> ); diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index c9a5137da..7bff0ba7a 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -18,6 +18,7 @@ import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; import { activateOnKey } from '@/utils/a11y'; import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives'; +import { upgradeLegacyUdpHop } from '@/lib/xray/udphop-mask'; import { upgradeLegacyXdnsMasks, XDNS_LEGACY_EDNS0 } from '@/lib/xray/xdns-mask'; const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ @@ -34,6 +35,9 @@ export interface FinalMaskFormProps { // network/protocol. Used by the global sub-JSON finalmask editor where // the masks apply to every stream rather than one specific transport. showAll?: boolean; + // udphop runs only on the dialing side (the core refuses it on a listener), so a server + // keeps the advertised hop range in quicParams.udpHop and a client gets the mask. + side?: 'server' | 'client'; } const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp', 'xdrive']; @@ -194,6 +198,8 @@ function defaultUdpMaskSettings(type: string): Record { return { client: [], server: [] }; case 'noise': return { reset: 0, noise: [] }; + case 'udphop': + return { mode: 'intervalRemote', interval: '5-10', remotePorts: '' }; default: return {}; } @@ -242,6 +248,7 @@ export default function FinalMaskForm({ protocol, form, showAll = false, + side = 'client', }: FinalMaskFormProps) { const base = asPath(name); @@ -273,6 +280,13 @@ export default function FinalMaskForm({ const { next, changed } = upgradeLegacyXdnsMasks(udp); if (changed) form.setFieldValue([...base, 'udp'], next); } + if (side === 'client') { + const finalmask = form.getFieldValue(base) as Record | undefined; + if (finalmask && typeof finalmask === 'object') { + const { next, changed } = upgradeLegacyUdpHop(finalmask); + if (changed) form.setFieldValue(base, next); + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -282,7 +296,13 @@ export default function FinalMaskForm({ // wrap anything even though the leftover network value may be 'tcp'. const isWireguard = protocol === 'wireguard'; const showTcp = showAll || (!isWireguard && TCP_NETWORKS.includes(network)); - const showUdp = showAll || isHysteria || isWireguard || network === 'kcp'; + // xhttp's HTTP/3 dial runs the UDP masks too, which is where a client's udphop lives. + const showUdp = + showAll || + isHysteria || + isWireguard || + network === 'kcp' || + (side === 'client' && network === 'xhttp'); const showQuic = showAll || isHysteria || network === 'xhttp'; const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const hasQuicParams = quicParams != null; @@ -299,6 +319,7 @@ export default function FinalMaskForm({ isHysteria={isHysteria} isWireguard={isWireguard} network={network} + allowUdpHop={side === 'client'} /> )} {showQuic && ( @@ -311,7 +332,13 @@ export default function FinalMaskForm({ }} /> - {hasQuicParams && } + {hasQuicParams && ( + + )} )} @@ -820,12 +847,14 @@ function UdpMasksList({ isHysteria, isWireguard, network, + allowUdpHop, }: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string; + allowUdpHop: boolean; }) { const { t } = useTranslation(); return ( @@ -854,6 +883,7 @@ function UdpMasksList({ isHysteria={isHysteria} isWireguard={isWireguard} network={network} + allowUdpHop={allowUdpHop} onRemove={() => remove(field.name)} /> ))} @@ -871,6 +901,7 @@ function UdpMaskItem({ isHysteria, isWireguard, network, + allowUdpHop, onRemove, }: { fieldName: number; @@ -880,6 +911,7 @@ function UdpMaskItem({ isHysteria: boolean; isWireguard: boolean; network: string; + allowUdpHop: boolean; onRemove: () => void; }) { const absolutePath = [...listPath, fieldName]; @@ -893,8 +925,9 @@ function UdpMaskItem({ } }; + const udpHopOption = allowUdpHop ? [{ value: 'udphop', label: 'UDP Hop' }] : []; const options = isHysteria - ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] + ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption] : [ // Salamander is the mask xray-core's own wireguard finalmask example // uses; it stays hysteria-only elsewhere to keep legacy parity. @@ -905,6 +938,7 @@ function UdpMaskItem({ { value: 'realm', label: 'Realm' }, { value: 'header-custom', label: 'Header Custom' }, { value: 'noise', label: 'Noise' }, + ...udpHopOption, ]; return ( @@ -967,6 +1001,9 @@ function UdpMaskItem({ if (type === 'xdns') { return ; } + if (type === 'udphop') { + return ; + } if (type === 'xicmp') { return ( <> @@ -1366,6 +1403,66 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) { ); } +const UDP_HOP_MODES = [ + { value: 'intervalRemote', label: 'Rotate server port (interval)' }, + { value: 'perConnRemote', label: 'Random server port per connection' }, + { value: 'intervalLocal', label: 'Rotate local port (interval)' }, +]; + +// xray-core lowercases each comma-separated mode, so a lowercase one (from an mport +// import) still maps onto its option here. +function udpHopModesFromWire(value: unknown): string[] { + if (typeof value !== 'string') return []; + return value + .split(',') + .map((mode) => mode.trim()) + .filter(Boolean) + .map( + (mode) => + UDP_HOP_MODES.find((m) => m.value.toLowerCase() === mode.toLowerCase())?.value ?? mode, + ); +} + +function validateUdpHopInterval(_: unknown, value: unknown) { + if (value == null || value === '') return Promise.resolve(); + const bounds = String(value) + .split('-') + .map((v) => Number(v.trim())); + const valid = bounds.length <= 2 && bounds.every((v) => Number.isInteger(v) && v >= 5); + return valid ? Promise.resolve() : Promise.reject(new Error('seconds ≥ 5, e.g. 30 or 5-10')); +} + +// Client-only hopping (xray-core 26.9.9); every key needs a registered field because the +// finalmask watch drops keys without one. +function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) { + return ( + <> + ({ value: udpHopModesFromWire(value) })} + normalize={(value: string[]) => value.join(',')} + rules={[{ required: true, message: 'pick at least one mode' }]} + > + + + + + + + - - - + + { + form.setFieldValue([...base, 'udpHop'], v ? defaultUdpHop() : undefined); + }} + /> + {hasUdpHop && ( + <> + + + + + + + + )} )} diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index b2e085a11..31c571668 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -1,5 +1,6 @@ import { Base64 } from '@/utils'; +import { upgradeLegacyUdpHop } from './udphop-mask'; import { upgradeLegacyXdnsMasks } from './xdns-mask'; // Focused share-link parser for the OutboundFormModal's link-import @@ -272,8 +273,8 @@ const kcpHeaderTypeToMask: Record = { }; // The inbound link emits the entire finalmask object as a JSON-encoded -// `fm` query param. Decode and attach to streamSettings so udpHop / -// quicParams / tcp+udp masks round-trip on outbound import. +// `fm` query param. Decode and attach to streamSettings so quicParams and the +// tcp+udp masks round-trip on outbound import; a legacy udpHop becomes the mask. function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { const fm = params.get('fm'); if (fm) { @@ -282,7 +283,7 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { if (parsed && typeof parsed === 'object') { sanitizeFinalMaskQuicParams(parsed); if (Array.isArray(parsed.udp)) parsed.udp = upgradeLegacyXdnsMasks(parsed.udp).next; - stream.finalmask = parsed; + stream.finalmask = upgradeLegacyUdpHop(parsed).next; } } catch { // malformed fm — leave streamSettings.finalmask absent diff --git a/frontend/src/lib/xray/udphop-mask.ts b/frontend/src/lib/xray/udphop-mask.ts new file mode 100644 index 000000000..fea44f8d2 --- /dev/null +++ b/frontend/src/lib/xray/udphop-mask.ts @@ -0,0 +1,29 @@ +type Raw = Record; + +function isEmptyHopValue(value: unknown): boolean { + return value == null || value === 0 || (typeof value === 'string' && value.trim() === ''); +} + +// xray-core 26.9.9 hops a client only through the "udphop" UDP mask. Mirrors +// internal/util/maskcompat: quicParams.udpHop becomes an intervalRemote mask. +export function upgradeLegacyUdpHop(finalmask: Raw): { next: Raw; changed: boolean } { + const quicParams = finalmask.quicParams; + if (!quicParams || typeof quicParams !== 'object' || !('udpHop' in quicParams)) { + return { next: finalmask, changed: false }; + } + const { udpHop, ...restQuic } = quicParams as Raw; + const next: Raw = { ...finalmask }; + if (Object.keys(restQuic).length > 0) next.quicParams = restQuic; + else delete next.quicParams; + + const hop = (udpHop && typeof udpHop === 'object' ? udpHop : {}) as Raw; + const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : []; + const hasMask = udp.some((m) => String((m as Raw | null)?.type ?? '').toLowerCase() === 'udphop'); + if (!isEmptyHopValue(hop.ports) && !hasMask) { + const settings: Raw = { mode: 'intervalRemote', remotePorts: hop.ports }; + if (!isEmptyHopValue(hop.interval)) settings.interval = hop.interval; + udp.push({ type: 'udphop', settings }); + next.udp = udp; + } + return { next, changed: true }; +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 4563d97d2..8b7bd5dd7 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -998,6 +998,7 @@ export default function InboundFormModal({ onChange={field.onChange} network={network} protocol={protocol} + side="server" /> )} /> diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 0b5f881c2..8aa5bb547 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/frontend/src/test/inbound-form-modal.test.tsx @@ -417,6 +417,56 @@ describe('InboundFormModal', () => { }); }); + // xray-core refuses a udphop mask on a listener ("client only"), so an inbound keeps its + // advertised hop range in quicParams.udpHop: the client-side lift must not run here. + it('keeps an inbound hop range in quicParams.udpHop on save', async () => { + const post = vi.mocked(HttpUtil.post); + post.mockClear(); + const hysteria = new DBInbound({ + id: 43, + port: 443, + listen: '', + protocol: 'hysteria', + remark: 'hy', + enable: true, + settings: { version: 2, clients: [] }, + streamSettings: { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { version: 2, auth: '', udpIdleTimeout: 60 }, + tlsSettings: { + serverName: 'hy.example.com', + alpn: ['h3'], + certificates: [ + { certificateFile: '/etc/ssl/certs/hy.crt', keyFile: '/etc/ssl/private/hy.key' }, + ], + }, + finalmask: { quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } }, + }, + sniffing: { enabled: false }, + nodeId: null, + shareAddrStrategy: 'listen', + shareAddr: '', + }); + renderCloneLikeEdit(hysteria); + // Form watches flush in a macrotask; save only once the hop fields have emitted. + await screen.findByDisplayValue('20000-30000'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + fireEvent.click(primaryButton()); + + const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/43'; + await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true)); + const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string }; + const finalmask = JSON.parse(payload.streamSettings).finalmask as Record; + expect(finalmask.quicParams).toMatchObject({ + udpHop: { ports: '20000-30000', interval: '5-10' }, + }); + expect(JSON.stringify(finalmask.udp ?? [])).not.toContain('udphop'); + }); + // Clients and enable change through their own endpoints; the server keeps the // stored ones, so the edit form must neither send nor validate its stale copy. it('edit save neither sends nor validates the clients it loaded', async () => { diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 60ce26e5d..489cf2b2a 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -452,3 +452,67 @@ describe('OutboundFormModal MASQUE', () => { expect(payload.streamSettings.masqueSettings.pass).toBeUndefined(); }); }); + +describe('OutboundFormModal UDP hop', () => { + // xray-core 26.9.9 ignores quicParams.udpHop; a client hops only through the udphop + // mask, so the editor must lift the dead key and keep every mask field on save. + it('saves a legacy quicParams.udpHop hysteria outbound as a udphop mask', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const outbound = { + protocol: 'hysteria', + tag: 'hy-out', + settings: { address: 'hy.example.com', port: 443, version: 2 }, + streamSettings: { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { version: 2, auth: 'a', udpIdleTimeout: 60 }, + tlsSettings: { serverName: 'hy.example.com', alpn: ['h3'] }, + finalmask: { + udp: [{ type: 'salamander', settings: { password: 'p' } }], + quicParams: { congestion: 'bbr', udpHop: { ports: '20000-30000', interval: '5-10' } }, + }, + }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { + streamSettings: { finalmask: { udp: unknown[]; quicParams?: Record } }; + }; + expect(payload.streamSettings.finalmask.udp).toEqual([ + { type: 'salamander', settings: { password: 'p' } }, + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' }, + }, + ]); + expect(payload.streamSettings.finalmask.quicParams?.udpHop).toBeUndefined(); + expect(payload.streamSettings.finalmask.quicParams?.congestion).toBe('bbr'); + }); +}); diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 5de0b78d1..6b14302c9 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -488,6 +488,25 @@ describe('parseShadowsocksLink', () => { }); describe('parseHysteria2Link', () => { + // A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop in fm=, + // which the core ignores; the import must hand it the udphop mask instead. + it('upgrades a legacy fm= quicParams.udpHop to the udphop mask', () => { + const fm = encodeURIComponent( + JSON.stringify({ quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } }), + ); + const out = parseHysteria2Link( + `hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=${fm}#hy`, + ); + expect((out!.streamSettings as Record).finalmask).toEqual({ + udp: [ + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' }, + }, + ], + }); + }); + it('parses a hysteria2:// link with sni', () => { const link = 'hysteria2://auth-secret@srv.example:443?sni=example.com#imported-hy2'; const out = parseHysteria2Link(link); @@ -822,8 +841,14 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => { const quicParams = finalmask.quicParams as Record; expect(quicParams.congestion).toBe('bbr'); expect(quicParams.maxIdleTimeout).toBe(30); - expect((quicParams.udpHop as Record).interval).toBe('5-10'); - expect((quicParams.udpHop as Record).ports).toBe('20000-50000'); + // xray-core 26.9.9 ignores quicParams.udpHop; the import moves it to the udphop mask. + expect(quicParams.udpHop).toBeUndefined(); + expect(finalmask.udp).toEqual([ + { + type: 'udphop', + settings: { mode: 'intervalRemote', remotePorts: '20000-50000', interval: '5-10' }, + }, + ]); }); it('falls back to x_padding_bytes when extra has no xPaddingBytes', () => { diff --git a/internal/database/db.go b/internal/database/db.go index 25bfa95a7..4e555ce35 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1282,7 +1282,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1429,6 +1429,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "UDPHopClientMaskFix") { + if err := migrateUDPHopClientMasks(); err != nil { + return err + } + } + if !slices.Contains(seedersHistory, "NodeInboundsAdopted") { if err := seedNodeInboundsAdopted(); err != nil { return err @@ -1937,7 +1943,7 @@ func migrateXdnsFinalmaskShape() error { return err } for _, inbound := range inbounds { - if updated, changed := upgradeLegacyXdnsJSON(inbound.StreamSettings, streamFinalmask, false); changed { + if updated, changed := upgradeFinalmasksJSON(inbound.StreamSettings, streamFinalmask, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id). Update("stream_settings", updated).Error; err != nil { return err @@ -1949,7 +1955,7 @@ func migrateXdnsFinalmaskShape() error { return err } for _, host := range hosts { - if updated, changed := upgradeLegacyXdnsJSON(host.FinalMask, wholeFinalmask, false); changed { + if updated, changed := upgradeFinalmasksJSON(host.FinalMask, wholeFinalmask, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Host{}).Where("id = ?", host.Id). Update("final_mask", updated).Error; err != nil { return err @@ -1961,7 +1967,7 @@ func migrateXdnsFinalmaskShape() error { return err } for _, sub := range subs { - if updated, changed := upgradeLegacyXdnsJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false); changed { + if updated, changed := upgradeFinalmasksJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id). Update("last_fetched_outbounds", updated).Error; err != nil { return err @@ -1984,7 +1990,7 @@ func migrateXdnsFinalmaskShape() error { if err != nil { return err } - if updated, changed := upgradeLegacyXdnsJSON(setting.Value, stored.locate, stored.indent); changed { + if updated, changed := upgradeFinalmasksJSON(setting.Value, stored.locate, stored.indent, maskcompat.UpgradeLegacyXdns); changed { if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key). Update("value", updated).Error; err != nil { return err @@ -1995,9 +2001,64 @@ func migrateXdnsFinalmaskShape() error { }) } -// upgradeLegacyXdnsJSON rewrites the finalmasks locate finds in one stored JSON document, -// leaving the document byte-for-byte alone when nothing in it is legacy. -func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (string, bool) { +// migrateUDPHopClientMasks moves the quicParams.udpHop xray-core 26.9.9 ignores into a +// udphop mask in every client-side finalmask; inbounds keep it, udphop refuses to run there. +func migrateUDPHopClientMasks() error { + return db.Transaction(func(tx *gorm.DB) error { + var hosts []model.Host + if err := tx.Select("id", "final_mask").Find(&hosts).Error; err != nil { + return err + } + for _, host := range hosts { + if updated, changed := upgradeFinalmasksJSON(host.FinalMask, wholeFinalmask, false, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.Host{}).Where("id = ?", host.Id). + Update("final_mask", updated).Error; err != nil { + return err + } + } + } + var subs []model.OutboundSubscription + if err := tx.Select("id", "last_fetched_outbounds").Find(&subs).Error; err != nil { + return err + } + for _, sub := range subs { + if updated, changed := upgradeFinalmasksJSON(sub.LastFetchedOutbounds, outboundListFinalmasks, false, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id). + Update("last_fetched_outbounds", updated).Error; err != nil { + return err + } + } + } + for _, stored := range []struct { + key string + locate func(any) []any + indent bool + }{ + {"xrayTemplateConfig", templateOutboundFinalmasks, true}, + {"subJsonFinalMask", wholeFinalmask, false}, + } { + var setting model.Setting + err := tx.Where("key = ?", stored.key).First(&setting).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + if err != nil { + return err + } + if updated, changed := upgradeFinalmasksJSON(setting.Value, stored.locate, stored.indent, maskcompat.UpgradeLegacyUDPHop); changed { + if err := tx.Model(&model.Setting{}).Where("key = ?", stored.key). + Update("value", updated).Error; err != nil { + return err + } + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "UDPHopClientMaskFix"}).Error + }) +} + +// upgradeFinalmasksJSON runs upgrade on the finalmasks locate finds in one stored JSON +// document, leaving the document byte-for-byte alone when nothing in it changed. +func upgradeFinalmasksJSON(raw string, locate func(any) []any, indent bool, upgrade func(any) bool) (string, bool) { if strings.TrimSpace(raw) == "" { return raw, false } @@ -2007,7 +2068,7 @@ func upgradeLegacyXdnsJSON(raw string, locate func(any) []any, indent bool) (str } changed := false for _, mask := range locate(doc) { - if maskcompat.UpgradeLegacyXdns(mask) { + if upgrade(mask) { changed = true } } @@ -2044,6 +2105,11 @@ func outboundListFinalmasks(doc any) []any { return finalmasks } +func templateOutboundFinalmasks(doc any) []any { + cfg, _ := doc.(map[string]any) + return outboundListFinalmasks(cfg["outbounds"]) +} + func templateFinalmasks(doc any) []any { cfg, _ := doc.(map[string]any) return append(outboundListFinalmasks(cfg["inbounds"]), outboundListFinalmasks(cfg["outbounds"])...) diff --git a/internal/database/udphop_client_mask_migration_test.go b/internal/database/udphop_client_mask_migration_test.go new file mode 100644 index 000000000..2e38026ce --- /dev/null +++ b/internal/database/udphop_client_mask_migration_test.go @@ -0,0 +1,117 @@ +package database + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +const legacyHopFinalmask = `{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}` + +const upgradedHopFinalmask = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + +func assertHopUpgraded(t *testing.T, where string, finalmask any) { + t.Helper() + got, err := json.Marshal(finalmask) + if err != nil { + t.Fatalf("%s: marshal finalmask: %v", where, err) + } + if string(got) != upgradedHopFinalmask { + t.Fatalf("%s: finalmask\n got: %s\nwant: %s", where, got, upgradedHopFinalmask) + } +} + +// xray-core 26.9.9 ignores quicParams.udpHop, so every client-side finalmask the panel +// stored before then hops nowhere; inbounds keep it, since udphop refuses to run there. +func TestUDPHopSeederMovesClientHopsToTheMask(t *testing.T) { + initMigrateDB(t) + inboundStream := `{"network":"hysteria","security":"tls","finalmask":` + legacyHopFinalmask + `}` + ib := seedInboundWithStream(t, "hy-in", 8443, inboundStream) + host := &model.Host{InboundId: ib.Id, Remark: "h", Address: "cdn.example.com", Port: 8443, FinalMask: legacyHopFinalmask} + if err := GetDB().Create(host).Error; err != nil { + t.Fatalf("create host: %v", err) + } + seedTemplate(t, `{"inbounds":[{"tag":"tpl-in","streamSettings":{"finalmask":`+legacyHopFinalmask+`}}], + "outbounds":[{"protocol":"hysteria","tag":"hy-out","settings":{},"streamSettings":{"network":"hysteria","finalmask":`+legacyHopFinalmask+`}}]}`) + if err := GetDB().Create(&model.Setting{Key: "subJsonFinalMask", Value: legacyHopFinalmask}).Error; err != nil { + t.Fatalf("seed subJsonFinalMask: %v", err) + } + sub := &model.OutboundSubscription{ + Remark: "donor", Url: "https://donor.example.com/sub", + LastFetchedOutbounds: `[{"protocol":"hysteria","tag":"sub-1","settings":{},"streamSettings":{"network":"hysteria","finalmask":` + legacyHopFinalmask + `}}]`, + } + if err := GetDB().Create(sub).Error; err != nil { + t.Fatalf("create outbound subscription: %v", err) + } + if err := GetDB().Where("seeder_name = ?", "UDPHopClientMaskFix").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + + if err := runSeeders(false); err != nil { + t.Fatalf("runSeeders: %v", err) + } + + var storedHost model.Host + if err := GetDB().First(&storedHost, host.Id).Error; err != nil { + t.Fatalf("reload host: %v", err) + } + var hostMask any + _ = json.Unmarshal([]byte(storedHost.FinalMask), &hostMask) + assertHopUpgraded(t, "host finalMask", hostMask) + + var template struct { + Inbounds []struct { + StreamSettings struct { + Finalmask json.RawMessage `json:"finalmask"` + } `json:"streamSettings"` + } `json:"inbounds"` + Outbounds []struct { + StreamSettings struct { + Finalmask any `json:"finalmask"` + } `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal([]byte(storedTemplate(t)), &template); err != nil || len(template.Outbounds) != 1 || len(template.Inbounds) != 1 { + t.Fatalf("stored template unreadable (%v)", err) + } + assertHopUpgraded(t, "template outbound", template.Outbounds[0].StreamSettings.Finalmask) + var inboundMask any + _ = json.Unmarshal(template.Inbounds[0].StreamSettings.Finalmask, &inboundMask) + var legacy any + _ = json.Unmarshal([]byte(legacyHopFinalmask), &legacy) + want, _ := json.Marshal(legacy) + if got, _ := json.Marshal(inboundMask); string(got) != string(want) { + t.Fatalf("template inbound finalmask = %s, want it untouched", got) + } + + var subMask model.Setting + if err := GetDB().Where("key = ?", "subJsonFinalMask").First(&subMask).Error; err != nil { + t.Fatalf("reload subJsonFinalMask: %v", err) + } + var subFinalmask any + _ = json.Unmarshal([]byte(subMask.Value), &subFinalmask) + assertHopUpgraded(t, "subJsonFinalMask", subFinalmask) + + var storedSub model.OutboundSubscription + if err := GetDB().First(&storedSub, sub.Id).Error; err != nil { + t.Fatalf("reload outbound subscription: %v", err) + } + var cached []struct { + StreamSettings struct { + Finalmask any `json:"finalmask"` + } `json:"streamSettings"` + } + if err := json.Unmarshal([]byte(storedSub.LastFetchedOutbounds), &cached); err != nil || len(cached) != 1 { + t.Fatalf("cached subscription outbounds unreadable (%v)", err) + } + assertHopUpgraded(t, "cached subscription outbound", cached[0].StreamSettings.Finalmask) + + var stored model.Inbound + if err := GetDB().First(&stored, ib.Id).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + if stored.StreamSettings != inboundStream { + t.Fatalf("inbound stream = %s, want it untouched", stored.StreamSettings) + } +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 991f2ea60..edcc85e33 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -16,6 +16,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" + "github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat" "github.com/mhsanaei/3x-ui/v3/internal/util/random" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) @@ -640,6 +641,8 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c if finalmask, ok := newStream["finalmask"].(map[string]any); ok { newStream["finalmask"] = withLegacyFragmentRanges(finalmask) } + // An inbound keeps its hop range as quicParams.udpHop; clients hop only with the mask. + maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"]) streamSettings, _ := json.MarshalIndent(newStream, "", " ") hostMux := hostMuxOverride(extPrxy) @@ -1001,6 +1004,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, if finalmask, ok := hyStream["finalmask"].(map[string]any); ok { newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask) } + maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"]) newStream["network"] = "hysteria" newStream["security"] = "tls" diff --git a/internal/sub/masque_test.go b/internal/sub/masque_test.go index 4486bdf89..58572b629 100644 --- a/internal/sub/masque_test.go +++ b/internal/sub/masque_test.go @@ -81,3 +81,36 @@ func TestGetInboundsBySubIdIncludesMasque(t *testing.T) { t.Fatalf("masque inbound not returned for subId: %+v", inbounds) } } + +// An inbound keeps its hop range in quicParams.udpHop (udphop refuses to run on a server), +// but the client config must carry the udphop mask: the core ignores the old key since 26.9.9. +func TestSubJsonServiceTurnsInboundHopIntoClientUDPHopMask(t *testing.T) { + inbound := &model.Inbound{ + Listen: "203.0.113.10", Port: 443, Protocol: model.Hysteria, Remark: "hy", + Settings: `{"version":2,"clients":[{"email":"ivy@example.test","auth":"a"}]}`, + StreamSettings: `{"network":"hysteria","security":"tls", + "hysteriaSettings":{"version":2}, + "tlsSettings":{"serverName":"hy.example.test","alpn":["h3"]}, + "finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}}`, + } + client := model.Client{Email: "ivy@example.test", Auth: "a"} + + configs := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com") + if len(configs) != 1 { + t.Fatalf("got %d configs, want one", len(configs)) + } + var config struct { + Outbounds []struct { + Protocol string `json:"protocol"` + StreamSettings map[string]any `json:"streamSettings"` + } `json:"outbounds"` + } + if err := json.Unmarshal(configs[0], &config); err != nil { + t.Fatalf("decode config: %v", err) + } + got, _ := json.Marshal(config.Outbounds[0].StreamSettings["finalmask"]) + const want = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("client finalmask\n got: %s\nwant: %s", got, want) + } +} diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 5af07f658..6829e680d 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -769,6 +769,7 @@ func applyFinalMask(stream map[string]any, p url.Values) { if json.Unmarshal([]byte(fm), &parsed) == nil { sanitizeFinalMaskQuicParams(parsed) maskcompat.UpgradeLegacyXdns(parsed) + maskcompat.UpgradeLegacyUDPHop(parsed) stream["finalmask"] = parsed } } diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index 12dc34a46..bee390a7b 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -304,9 +304,9 @@ func TestParseHysteria2_Mport(t *testing.T) { wantHop: true, }, { - name: "legacy fm quicParams.udpHop no longer suppresses mport", + name: "legacy fm quicParams.udpHop is upgraded and wins over mport like the mask", query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`), - wantPorts: "1-2", + wantPorts: "30000-40000", wantHop: true, }, } @@ -577,3 +577,22 @@ func TestParseShadowsocksObfsLocalPlugin(t *testing.T) { }) } } + +// A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop inside fm=, +// which the core ignores; imported verbatim, the outbound would never hop. +func TestParseLink_UpgradesLegacyUDPHopFinalMask(t *testing.T) { + fm := url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}`) + res, err := ParseLink("hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=" + fm + "#hy") + if err != nil { + t.Fatalf("parse hysteria2 with fm: %v", err) + } + stream, _ := res.Outbound["streamSettings"].(map[string]any) + got, err := json.Marshal(stream["finalmask"]) + if err != nil { + t.Fatalf("marshal finalmask: %v", err) + } + want := `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want) + } +} diff --git a/internal/util/maskcompat/udphop.go b/internal/util/maskcompat/udphop.go new file mode 100644 index 000000000..f326f2621 --- /dev/null +++ b/internal/util/maskcompat/udphop.go @@ -0,0 +1,54 @@ +package maskcompat + +import "strings" + +// UpgradeLegacyUDPHop moves the quicParams.udpHop range xray-core 26.9.9 stopped reading +// into the client-only "udphop" UDP mask, whose intervalRemote mode is what the key did. +func UpgradeLegacyUDPHop(finalmask any) bool { + fm, _ := finalmask.(map[string]any) + quicParams, _ := fm["quicParams"].(map[string]any) + legacy, found := quicParams["udpHop"] + if !found { + return false + } + delete(quicParams, "udpHop") + if len(quicParams) == 0 { + delete(fm, "quicParams") + } + + hop, _ := legacy.(map[string]any) + ports := hop["ports"] + if isEmptyHopValue(ports) || hasUDPHopMask(fm) { + return true + } + settings := map[string]any{"mode": "intervalRemote", "remotePorts": ports} + if interval := hop["interval"]; !isEmptyHopValue(interval) { + settings["interval"] = interval + } + masks, _ := fm["udp"].([]any) + fm["udp"] = append(masks, map[string]any{"type": "udphop", "settings": settings}) + return true +} + +func hasUDPHopMask(fm map[string]any) bool { + masks, _ := fm["udp"].([]any) + for _, entry := range masks { + mask, _ := entry.(map[string]any) + if maskType, _ := mask["type"].(string); strings.EqualFold(maskType, "udphop") { + return true + } + } + return false +} + +func isEmptyHopValue(value any) bool { + switch v := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(v) == "" + case float64: + return v == 0 + } + return false +} diff --git a/internal/util/maskcompat/udphop_test.go b/internal/util/maskcompat/udphop_test.go new file mode 100644 index 000000000..e8e7bab1a --- /dev/null +++ b/internal/util/maskcompat/udphop_test.go @@ -0,0 +1,106 @@ +package maskcompat + +import ( + "encoding/json" + "testing" + + "github.com/xtls/xray-core/infra/conf" + "github.com/xtls/xray-core/transport/internet/finalmask/udphop" +) + +// coreUDPHop builds a finalmask's UDP masks through conf.Mask, the loader the core +// calls at startup, and returns the udphop config the client would hop with, if any. +func coreUDPHop(t *testing.T, finalmask map[string]any) *udphop.Config { + t.Helper() + raw, err := json.Marshal(finalmask) + if err != nil { + t.Fatalf("marshal finalmask: %v", err) + } + var fm conf.FinalMask + if err := json.Unmarshal(raw, &fm); err != nil { + t.Fatalf("decode finalmask: %v", err) + } + for _, mask := range fm.Udp { + built, err := mask.Build(false) + if err != nil { + t.Fatalf("the core refuses UDP mask %q: %v", mask.Type, err) + } + if hop, ok := built.(*udphop.Config); ok { + return hop + } + } + return nil +} + +func decodeFinalmask(t *testing.T, s string) map[string]any { + t.Helper() + var fm map[string]any + if err := json.Unmarshal([]byte(s), &fm); err != nil { + t.Fatalf("decode finalmask: %v", err) + } + return fm +} + +func TestUpgradeLegacyUDPHop(t *testing.T) { + fm := decodeFinalmask(t, `{"udp":[{"type":"salamander","settings":{"password":"p"}}], + "quicParams":{"congestion":"bbr","udpHop":{"ports":"20000-20003","interval":"5-10"}}}`) + if hop := coreUDPHop(t, fm); hop != nil { + t.Fatal("the core hops on quicParams.udpHop again; the upgrade is no longer needed") + } + + if !UpgradeLegacyUDPHop(fm) { + t.Fatal("UpgradeLegacyUDPHop reported no change for a legacy hop") + } + got, _ := json.Marshal(fm) + const want = `{"quicParams":{"congestion":"bbr"},"udp":[{"settings":{"password":"p"},"type":"salamander"},{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-20003"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("upgraded finalmask\n got: %s\nwant: %s", got, want) + } + hop := coreUDPHop(t, fm) + if hop == nil || !hop.Remote || hop.Local || hop.IntervalMin != 5 || hop.IntervalMax != 10 || + len(hop.RemotePorts) != 4 || hop.RemotePorts[0] != 20000 || hop.RemotePorts[3] != 20003 { + t.Fatalf("the core hops with %+v, want remote ports 20000-20003 every 5-10s", hop) + } +} + +func TestUpgradeLegacyUDPHopEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "an explicit udphop mask wins over the dead key", + input: `{"udp":[{"type":"udphop","settings":{"mode":"perConnRemote","remotePorts":"443"}}],"quicParams":{"udpHop":{"ports":"20000-30000"}}}`, + want: `{"udp":[{"settings":{"mode":"perConnRemote","remotePorts":"443"},"type":"udphop"}]}`, + }, + { + name: "a hop without ports only loses the dead key", + input: `{"quicParams":{"udpHop":{"interval":"5-10"}}}`, + want: `{}`, + }, + { + name: "no interval keeps the core's 30s default", + input: `{"quicParams":{"udpHop":{"ports":"443,8443"}}}`, + want: `{"udp":[{"settings":{"mode":"intervalRemote","remotePorts":"443,8443"},"type":"udphop"}]}`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fm := decodeFinalmask(t, tc.input) + if !UpgradeLegacyUDPHop(fm) { + t.Fatal("UpgradeLegacyUDPHop reported no change") + } + got, _ := json.Marshal(fm) + if string(got) != tc.want { + t.Fatalf("upgraded finalmask\n got: %s\nwant: %s", got, tc.want) + } + coreUDPHop(t, fm) + }) + } + + current := decodeFinalmask(t, `{"udp":[{"type":"udphop","settings":{"mode":"intervalRemote","remotePorts":"443"}}],"quicParams":{"congestion":"bbr"}}`) + if UpgradeLegacyUDPHop(current) { + t.Fatal("UpgradeLegacyUDPHop rewrote a finalmask without the legacy key") + } +} diff --git a/internal/web/service/inbound_finalmask_xdns_test.go b/internal/web/service/inbound_finalmask_xdns_test.go index fe1d0a049..7519f3017 100644 --- a/internal/web/service/inbound_finalmask_xdns_test.go +++ b/internal/web/service/inbound_finalmask_xdns_test.go @@ -113,3 +113,38 @@ func TestGetXrayConfig_UpgradesTemplateOutboundXdnsMask(t *testing.T) { } t.Fatal("outbound dns-tunnel not found in the generated config") } + +// A hysteria outbound pasted from a pre-26.9.9 template still carries quicParams.udpHop, +// which the core now ignores; the build heal hands it the udphop mask instead. +func TestGetXrayConfig_UpgradesTemplateOutboundUDPHop(t *testing.T) { + setupConflictDB(t) + template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},{"protocol":"hysteria","tag":"hy-out", + "settings":{"version":2,"address":"hy.example.com","port":443}, + "streamSettings":{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2,"auth":"a"}, + "finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}}}]}` + if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil { + t.Fatalf("seed template: %v", err) + } + + cfg, err := (&XrayService{}).GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + var outbounds []map[string]any + if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil { + t.Fatalf("decode emitted outbounds: %v", err) + } + for _, outbound := range outbounds { + if outbound["tag"] != "hy-out" { + continue + } + stream, _ := outbound["streamSettings"].(map[string]any) + got, _ := json.Marshal(stream["finalmask"]) + const want = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}` + if string(got) != want { + t.Fatalf("emitted finalmask\n got: %s\nwant: %s", got, want) + } + return + } + t.Fatal("outbound hy-out not found in the generated config") +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index edf986cf7..654d57c15 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -180,10 +180,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { xrayConfig.API = ensureAPIServices(xrayConfig.API) xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy) xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig) - // A pasted or restored template can still carry pre-#6258 XHTTP session keys or a - // pre-26.10.10 xdns mask the core silently empties; heal them like the inbounds below. + // A pasted or restored template can still carry pre-#6258 XHTTP session keys, a pre-26.10.10 + // xdns mask or a pre-26.9.9 quicParams.udpHop, all of which the core silently ignores. xrayConfig.OutboundConfigs = healOutboundStreams(xrayConfig.OutboundConfigs, - liftXhttpSessionIDKeys, upgradeStreamLegacyXdns) + liftXhttpSessionIDKeys, upgradeStreamLegacyXdns, upgradeStreamLegacyUDPHop) // Bridge amneziawg outbounds before anything else reads OutboundConfigs; // the core has no amneziawg proxy and would reject the raw entry. if err := transformAmneziaWGOutbounds(xrayConfig); err != nil { @@ -1748,3 +1748,7 @@ func healOutboundStreams(raw json_util.RawMessage, heals ...func(stream map[stri func upgradeStreamLegacyXdns(stream map[string]any) bool { return maskcompat.UpgradeLegacyXdns(stream["finalmask"]) } + +func upgradeStreamLegacyUDPHop(stream map[string]any) bool { + return maskcompat.UpgradeLegacyUDPHop(stream["finalmask"]) +} From 1c050f872ae6cc02907d1e688fec7664baf7cec5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 15:27:19 +0200 Subject: [PATCH 10/13] feat(finalmask): header-custom variables, sudoku UDP mask and noise reset ranges xray-core 26.6 grew the finalmask surface the editor could not express, and the finalmask watch drops any key without a registered field, so a config written by hand lost these keys on the next save: - header-custom items can capture their bytes into a variable, reuse one or emit a transform expression; a Kind select keeps each item to exactly one kind, as the core requires, and only parsed JSON reaches transform; - TCP header-custom gains the errors group (the reply sent when a client group does not match), UDP header-custom its prefix/standalone mode; - sudoku is offered as a UDP mask, and share links keep it in fm=; - the noise reset is an Int32Range, so it now takes "60-120" as well. The new golden fixture is saved unchanged by the outbound editor and built by xray-core in TestGoldenStreamFixturesBuildInXray. --- .../xray/forms/transport/FinalMaskForm.tsx | 279 ++++++++++++++---- .../src/schemas/protocols/stream/finalmask.ts | 2 +- .../test/__snapshots__/finalmask.test.ts.snap | 101 +++++++ .../finalmask/header-custom-variables.json | 54 ++++ .../src/test/outbound-form-modal.test.tsx | 132 +++++++++ internal/sub/service.go | 1 + internal/sub/service_test.go | 14 + 7 files changed, 531 insertions(+), 52 deletions(-) create mode 100644 frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 7bff0ba7a..5c45dc81b 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AutoComplete, Button, @@ -9,6 +9,7 @@ import { Select, Space, Switch, + Typography, } from 'antd'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; @@ -98,14 +99,7 @@ function defaultTcpMaskSettings(type: string): Record { // a single length entry reproduces the legacy single-range behavior. return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' }; case 'sudoku': - return { - password: '', - ascii: '', - customTable: '', - customTables: [], - paddingMin: 0, - paddingMax: 0, - }; + return defaultSudokuSettings(); case 'header-custom': return { clients: [], servers: [] }; case 'xmc': @@ -119,6 +113,17 @@ function defaultTcpMaskSettings(type: string): Record { } } +function defaultSudokuSettings(): Record { + return { + password: '', + ascii: '', + customTable: '', + customTables: [], + paddingMin: 0, + paddingMax: 0, + }; +} + function defaultXmcProfile(): Record { return { username: '', uuid: '', texturesValue: '', texturesSignature: '' }; } @@ -198,6 +203,8 @@ function defaultUdpMaskSettings(type: string): Record { return { client: [], server: [] }; case 'noise': return { reset: 0, noise: [] }; + case 'sudoku': + return defaultSudokuSettings(); case 'udphop': return { mode: 'intervalRemote', interval: '5-10', remotePorts: '' }; default: @@ -471,30 +478,7 @@ function TcpMaskItem({ ); } - if (type === 'sudoku') { - return ( - <> - - - - - - - - - - - + + + + + + + + + + {(['client', 'server'] as const).map((groupKey) => ( {(items, { add, remove }) => ( @@ -1283,6 +1319,7 @@ function UdpHeaderCustom({ fieldName={item.name} form={form} absoluteItemPath={[...absoluteSettingsPath, groupKey, item.name]} + allowVariables onRemove={() => remove(item.name)} /> @@ -1463,6 +1500,19 @@ function UdpHopSettings({ udpFieldName }: { udpFieldName: number }) { ); } +// xray-core reads reset as an Int32Range: a plain number or a "min-max" string. +function validateNoiseReset(_: unknown, value: unknown) { + if (value == null || value === '') return Promise.resolve(); + const bounds = String(value) + .split('-') + .map((v) => Number(v.trim())); + const valid = + bounds.length <= 2 && + bounds.every((v) => Number.isInteger(v) && v >= 0) && + bounds[0] <= bounds[bounds.length - 1]; + return valid ? Promise.resolve() : Promise.reject(new Error('seconds, e.g. 0 or 60-120')); +} + function NoiseItems({ udpFieldName, form, @@ -1475,8 +1525,13 @@ function NoiseItems({ const { t } = useTranslation(); return ( <> - - + + {(items, { add, remove }) => ( @@ -1519,20 +1574,98 @@ function NoiseItems({ ); } +// xray-core 26.6 header-custom: an item is exactly one of raw bytes, a reused variable +// or a transform ("exactly one item kind must be set"); capture works with any of them. +type ItemKind = 'bytes' | 'reuse' | 'transform'; + +const ITEM_KIND_OPTIONS: { value: ItemKind; label: string }[] = [ + { value: 'bytes', label: 'Bytes' }, + { value: 'reuse', label: 'Reuse variable' }, + { value: 'transform', label: 'Transform' }, +]; + +function itemKindOf(item: unknown): ItemKind { + const fields = (item ?? {}) as Record; + if (typeof fields.reuse === 'string') return 'reuse'; + if (fields.transform != null) return 'transform'; + return 'bytes'; +} + +function validateCustomVarName(_: unknown, value: unknown) { + if (value == null || value === '') return Promise.resolve(); + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value)) + ? Promise.resolve() + : Promise.reject(new Error('letters, digits and _, not starting with a digit')); +} + +// Hands the form only a parsed JSON object, so half-typed text never reaches the config. +function TransformEditor({ + value, + onChange, +}: { + value?: Record; + onChange?: (next: Record | undefined) => void; +}) { + const [text, setText] = useState(() => (value ? JSON.stringify(value) : '')); + const [invalid, setInvalid] = useState(false); + return ( + <> + { + const next = e.target.value; + setText(next); + if (next.trim() === '') { + setInvalid(false); + onChange?.(undefined); + return; + } + try { + const parsed: unknown = JSON.parse(next); + const isObject = !!parsed && typeof parsed === 'object' && !Array.isArray(parsed); + setInvalid(!isObject); + if (isObject) onChange?.(parsed as Record); + } catch { + setInvalid(true); + } + }} + /> + {invalid && Must be a JSON object} + + ); +} + function ItemEditor({ fieldName, form, absoluteItemPath, delayMode, + allowVariables = false, onRemove: _onRemove, }: { fieldName: number; form: FormInstance; absoluteItemPath: (string | number)[]; delayMode?: 'number' | 'string'; + allowVariables?: boolean; onRemove?: () => void; }) { const { t } = useTranslation(); + const [kind, setKind] = useState(() => + allowVariables ? itemKindOf(form.getFieldValue(absoluteItemPath)) : 'bytes', + ); + const onKindChange = (next: ItemKind) => { + setKind(next); + for (const key of ['type', 'packet', 'rand', 'randRange', 'reuse', 'transform']) { + form.setFieldValue([...absoluteItemPath, key], undefined); + } + if (next === 'bytes') { + form.setFieldValue([...absoluteItemPath, 'type'], 'array'); + form.setFieldValue([...absoluteItemPath, 'rand'], 0); + } + }; /** * Switching to `array` clears the packet instead of emptying it to `[]`: * that branch is rand-driven, and xray-core counts even an empty array as a @@ -1552,19 +1685,26 @@ function ItemEditor({ return ( <> - - + + )} + {kind === 'bytes' && ( + + + + )} + {kind === 'transform' && ( + + + + )} + @@ -1585,6 +1749,7 @@ function ItemEditor({ } > {({ getFieldValue }) => { + if (kind !== 'bytes') return null; const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined; if (type === 'array') { return ( @@ -1635,6 +1800,18 @@ function ItemEditor({ ); }} + + {allowVariables && ( + (v === '' ? undefined : v)} + rules={[{ validator: validateCustomVarName }]} + > + + + )} ); } diff --git a/frontend/src/schemas/protocols/stream/finalmask.ts b/frontend/src/schemas/protocols/stream/finalmask.ts index 874e9be3d..3cc880d3b 100644 --- a/frontend/src/schemas/protocols/stream/finalmask.ts +++ b/frontend/src/schemas/protocols/stream/finalmask.ts @@ -20,7 +20,7 @@ export const TcpMaskSchema = z.object({ export type TcpMask = z.infer; // 'udphop' is client-only in xray-core (it refuses to wrap a server socket), -// so it round-trips here but is deliberately absent from the mask dropdown. +// so only the client-side mask editors offer it. export const UdpMaskTypeSchema = z.enum([ 'salamander', 'mkcp-legacy', diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap index 41dc2f825..7b52b03df 100644 --- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap +++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap @@ -38,6 +38,107 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1` } `; +exports[`FinalMaskStreamSettingsSchema fixtures > parses header-custom-variables byte-stably 1`] = ` +{ + "tcp": [ + { + "settings": { + "clients": [ + [ + { + "capture": "nonce", + "delay": 0, + "rand": 8, + "type": "array", + }, + { + "delay": 0, + "packet": "HELLO", + "type": "str", + }, + ], + ], + "errors": [ + [ + { + "delay": 0, + "packet": "DENIED", + "type": "str", + }, + ], + ], + "servers": [ + [ + { + "delay": 0, + "reuse": "nonce", + }, + { + "delay": 0, + "transform": { + "args": [ + { + "bytes": "OK ", + "type": "str", + }, + { + "reuse": "nonce", + }, + ], + "op": "concat", + }, + }, + ], + ], + }, + "type": "header-custom", + }, + ], + "udp": [ + { + "settings": { + "client": [ + { + "capture": "token", + "rand": 8, + "type": "array", + }, + ], + "mode": "standalone", + "server": [ + { + "reuse": "token", + }, + ], + }, + "type": "header-custom", + }, + { + "settings": { + "ascii": "prefer_entropy", + "paddingMax": 7, + "paddingMin": 2, + "password": "s3cr3t", + }, + "type": "sudoku", + }, + { + "settings": { + "noise": [ + { + "delay": "10-16", + "rand": "10-20", + "type": "array", + }, + ], + "reset": "60-120", + }, + "type": "noise", + }, + ], +} +`; + exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably 1`] = ` { "quicParams": { diff --git a/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json b/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json new file mode 100644 index 000000000..ac17cbb3b --- /dev/null +++ b/frontend/src/test/golden/fixtures/finalmask/header-custom-variables.json @@ -0,0 +1,54 @@ +{ + "tcp": [ + { + "type": "header-custom", + "settings": { + "clients": [ + [ + { "delay": 0, "type": "array", "rand": 8, "capture": "nonce" }, + { "delay": 0, "type": "str", "packet": "HELLO" } + ] + ], + "servers": [ + [ + { "delay": 0, "reuse": "nonce" }, + { + "delay": 0, + "transform": { + "op": "concat", + "args": [{ "type": "str", "bytes": "OK " }, { "reuse": "nonce" }] + } + } + ] + ], + "errors": [[{ "delay": 0, "type": "str", "packet": "DENIED" }]] + } + } + ], + "udp": [ + { + "type": "header-custom", + "settings": { + "mode": "standalone", + "client": [{ "type": "array", "rand": 8, "capture": "token" }], + "server": [{ "reuse": "token" }] + } + }, + { + "type": "sudoku", + "settings": { + "password": "s3cr3t", + "ascii": "prefer_entropy", + "paddingMin": 2, + "paddingMax": 7 + } + }, + { + "type": "noise", + "settings": { + "reset": "60-120", + "noise": [{ "type": "array", "rand": "10-20", "delay": "10-16" }] + } + } + ] +} diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 489cf2b2a..a2c62427b 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -516,3 +516,135 @@ describe('OutboundFormModal UDP hop', () => { expect(payload.streamSettings.finalmask.quicParams?.congestion).toBe('bbr'); }); }); + +describe('OutboundFormModal finalmask variables', () => { + function xhttpOutbound(finalmask: Record) { + return { + protocol: 'vless', + tag: 'masked', + settings: { + vnext: [ + { + address: 'example.com', + port: 443, + users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }], + }, + ], + }, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { path: '/', mode: 'auto' }, + finalmask, + }, + }; + } + + async function saveFinalmask( + finalmask: Record, + edit: () => void = () => {}, + ): Promise> { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + edit(); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(onConfirm).toHaveBeenCalledTimes(1); + return ( + onConfirm.mock.calls[0][0] as { streamSettings: { finalmask: Record } } + ).streamSettings.finalmask; + } + + function typeInto(label: string, value: string) { + const input = formItemByLabel(label)?.querySelector('input, textarea'); + if (!input) throw new Error(`${label} input not found`); + fireEvent.change(input, { target: { value } }); + } + + // xray-core 26.6 header-custom variables, error replies and standalone UDP headers, plus + // the sudoku UDP mask; the same fixture is built by Go's TestGoldenStreamFixturesBuildInXray. + it('keeps every header-custom variable, sudoku UDP mask and noise reset range on save', async () => { + const fixture = (await import('./golden/fixtures/finalmask/header-custom-variables.json')) + .default as Record; + + expect(await saveFinalmask(structuredClone(fixture))).toEqual(fixture); + }); + + // The core rejects an item carrying two kinds ("exactly one item kind must be set"). + it('drops the bytes of an item switched to reuse a variable', async () => { + const finalmask = await saveFinalmask( + { + udp: [ + { + type: 'header-custom', + settings: { client: [{ type: 'array', rand: 4, randRange: '0-255' }], server: [] }, + }, + ], + }, + () => { + chooseOptionByLabel('Kind', 'Reuse variable'); + typeInto('Reuse', 'nonce'); + }, + ); + + expect(finalmask.udp).toEqual([ + { type: 'header-custom', settings: { client: [{ reuse: 'nonce' }], server: [] } }, + ]); + }); + + // A string transform fails the whole core config, so half-typed JSON must never be saved. + it.each([ + ['{"op": "be16", "args": [{"u64": 7}]}', [{ transform: { op: 'be16', args: [{ u64: 7 }] } }]], + ['{"op": "be16"', [{}]], + ])('saves the typed transform %s only once it parses', async (typed, client) => { + const finalmask = await saveFinalmask( + { + udp: [ + { type: 'header-custom', settings: { client: [{ type: 'array', rand: 4 }], server: [] } }, + ], + }, + () => { + chooseOptionByLabel('Kind', 'Transform'); + typeInto('Transform', typed); + }, + ); + + expect(finalmask.udp).toEqual([{ type: 'header-custom', settings: { client, server: [] } }]); + }); + + it('saves a noise reset typed as a range', async () => { + const finalmask = await saveFinalmask( + { udp: [{ type: 'noise', settings: { reset: 0, noise: [] } }] }, + () => typeInto('Reset', '60-120'), + ); + + expect(finalmask.udp).toEqual([{ type: 'noise', settings: { reset: '60-120', noise: [] } }]); + }); +}); diff --git a/internal/sub/service.go b/internal/sub/service.go index feca3d04d..fa1493cdb 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2700,6 +2700,7 @@ var validFinalMaskUDPTypes = map[string]struct{}{ "header-custom": {}, "realm": {}, "udphop": {}, + "sudoku": {}, } var validFinalMaskTCPTypes = map[string]struct{}{ diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 4036e9d91..070a7e9b8 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -1181,6 +1181,20 @@ func TestMarshalFinalMask_KeepsUdpHopMask(t *testing.T) { } } +// xray-core 26.6 accepts sudoku as a UDP mask too; the link must not drop it from fm=. +func TestMarshalFinalMask_KeepsSudokuUdpMask(t *testing.T) { + fm := map[string]any{ + "udp": []any{map[string]any{"type": "sudoku", "settings": map[string]any{"password": "s3cr3t"}}}, + } + out, ok := marshalFinalMask(fm) + if !ok { + t.Fatal("expected ok=true for a sudoku udp mask") + } + if want := `{"udp":[{"settings":{"password":"s3cr3t"},"type":"sudoku"}]}`; out != want { + t.Fatalf("marshalFinalMask = %s, want %s", out, want) + } +} + func TestHasFinalMaskContent(t *testing.T) { if hasFinalMaskContent(nil) { t.Fatal("nil should not count as content") From b6c859f5b9a5a7620e7f16352d7ef2985c672b11 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 15:37:20 +0200 Subject: [PATCH 11/13] feat(xray): edit Lua DNS and routing scripts xray-core 26.10.10 can hand DNS and routing to Lua: dns.script and routing.script name a .lua file (absolute, or found next to the Xray binary) that defines HandleDNSQuery / HandleRoute, and a DNS server's id is how a DNS script picks it. The keys round-tripped through the JSON template but had no field. - DNS: a Lua Script field, and an ID field in the server modal; - Basics: a Routing Script field beside the routing strategy; - Routing: a warning above the rules while a script is set, since the core then routes only through the script and skips every rule, the panel's api rule included. The descriptions say the script must still send the api inbound to the api outbound, and that Xray will not start if the file is missing. An empty field drops the key, because the core rejects an empty path. --- .../docs/en/operations/outbounds-routing.mdx | 14 ++++ .../docs/fa/operations/outbounds-routing.mdx | 14 ++++ .../docs/ru/operations/outbounds-routing.mdx | 14 ++++ .../docs/zh/operations/outbounds-routing.mdx | 12 +++ frontend/src/pages/xray/basics/BasicsTab.tsx | 20 +++++ .../src/pages/xray/dns/DnsServerModal.tsx | 7 ++ frontend/src/pages/xray/dns/DnsTab.tsx | 12 +++ .../src/pages/xray/routing/RoutingTab.tsx | 11 ++- frontend/src/schemas/xray.ts | 1 + frontend/src/test/dns-server-modal.test.tsx | 17 ++++- frontend/src/test/dns-tab.test.tsx | 28 ++++++- frontend/src/test/routing-script.test.tsx | 75 +++++++++++++++++++ internal/web/translation/ar-EG.json | 11 ++- internal/web/translation/en-US.json | 11 ++- internal/web/translation/es-ES.json | 11 ++- internal/web/translation/fa-IR.json | 11 ++- internal/web/translation/id-ID.json | 11 ++- internal/web/translation/ja-JP.json | 11 ++- internal/web/translation/pt-BR.json | 11 ++- internal/web/translation/ru-RU.json | 11 ++- internal/web/translation/tr-TR.json | 11 ++- internal/web/translation/uk-UA.json | 11 ++- internal/web/translation/vi-VN.json | 11 ++- internal/web/translation/zh-CN.json | 11 ++- internal/web/translation/zh-TW.json | 11 ++- 25 files changed, 338 insertions(+), 30 deletions(-) create mode 100644 frontend/src/test/routing-script.test.tsx diff --git a/docs/content/docs/en/operations/outbounds-routing.mdx b/docs/content/docs/en/operations/outbounds-routing.mdx index 41961abae..d25256ef9 100644 --- a/docs/content/docs/en/operations/outbounds-routing.mdx +++ b/docs/content/docs/en/operations/outbounds-routing.mdx @@ -151,6 +151,20 @@ put specific rules above general ones. } ``` +### Lua scripts + +Xray 26.10.10 can hand routing and DNS to Lua. **Basics → Routing Script** takes the +path of a `.lua` file that defines `HandleRoute` (absolute, or relative to the Xray +`bin` folder); **DNS → Lua Script** takes one that defines `HandleDNSQuery`, and a +DNS server's **ID** is the name such a script picks it by. + + + A routing script replaces **every** routing rule, the panel's own `api` rule + included. Send the `api` inbound to the `api` outbound in your script, or traffic + statistics and live client changes stop. Xray does not start if the file is missing + or fails to load. + + ## Balancers A **balancer** groups outbounds by a **selector** (tag prefixes, including the diff --git a/docs/content/docs/fa/operations/outbounds-routing.mdx b/docs/content/docs/fa/operations/outbounds-routing.mdx index fb9d4ad11..2028346c4 100644 --- a/docs/content/docs/fa/operations/outbounds-routing.mdx +++ b/docs/content/docs/fa/operations/outbounds-routing.mdx @@ -152,6 +152,20 @@ Reset یا حذف، peer مربوط به WireGuard را در حساب PIA باط } ``` +### اسکریپت‌های Lua + +Xray 26.10.10 می‌تواند مسیریابی و DNS را به Lua بسپارد. **پایه ← اسکریپت مسیریابی** +مسیر یک فایل `.lua` را می‌گیرد که `HandleRoute` را تعریف می‌کند (مطلق یا نسبت به +پوشه‌ی `bin` در Xray)؛ **DNS ← اسکریپت Lua** فایلی را می‌گیرد که `HandleDNSQuery` را +تعریف می‌کند، و **شناسه**‌ی هر سرور DNS نامی است که اسکریپت با آن سرور را انتخاب می‌کند. + + + اسکریپت مسیریابی جای **همه‌ی** قوانین مسیریابی را می‌گیرد، از جمله قانون `api` خود + پنل. در اسکریپت، اینباند `api` را به اوت‌باند `api` بفرستید، وگرنه آمار ترافیک و + اعمال زنده‌ی تغییرات کلاینت‌ها متوقف می‌شود. اگر فایل وجود نداشته باشد یا بارگذاری + نشود، Xray اجرا نمی‌شود. + + ## متعادل‌کننده‌ها یک **متعادل‌کننده** خروجی‌ها را با یک **انتخاب‌گر** (پیشوندهای برچسب، از جمله مجموعه‌های diff --git a/docs/content/docs/ru/operations/outbounds-routing.mdx b/docs/content/docs/ru/operations/outbounds-routing.mdx index 1a765e514..037318beb 100644 --- a/docs/content/docs/ru/operations/outbounds-routing.mdx +++ b/docs/content/docs/ru/operations/outbounds-routing.mdx @@ -165,6 +165,20 @@ hostname получает уникальный тег `nord-` и не } ``` +### Lua-скрипты + +Xray 26.10.10 умеет передавать маршрутизацию и DNS в Lua. **Основное → Скрипт +маршрутизации** принимает путь к файлу `.lua` с функцией `HandleRoute` (абсолютный +или относительно папки `bin` Xray); **DNS → Lua-скрипт** — файл с функцией +`HandleDNSQuery`, а **ID** DNS-сервера — имя, по которому такой скрипт его выбирает. + + + Скрипт маршрутизации заменяет **все** правила маршрутизации, включая собственное + правило `api` панели. Направляйте в скрипте входящий `api` на исходящий `api`, + иначе остановятся статистика трафика и применение изменений клиентов на лету. Xray не + запустится, если файл отсутствует или не загружается. + + ## Балансировщики **Балансировщик** группирует исходящие соединения по **селектору** (префиксы diff --git a/docs/content/docs/zh/operations/outbounds-routing.mdx b/docs/content/docs/zh/operations/outbounds-routing.mdx index 3d73a5341..8ebe0addd 100644 --- a/docs/content/docs/zh/operations/outbounds-routing.mdx +++ b/docs/content/docs/zh/operations/outbounds-routing.mdx @@ -140,6 +140,18 @@ Xray 配置中——而不会改动你已保存的模板。这是订阅一*池* } ``` +### Lua 脚本 + +Xray 26.10.10 可以把路由和 DNS 交给 Lua 处理。**基础配置 → 路由脚本** 填写定义了 +`HandleRoute` 的 `.lua` 文件路径(绝对路径或相对于 Xray `bin` 目录);**DNS → Lua +脚本** 填写定义了 `HandleDNSQuery` 的文件,DNS 服务器的 **ID** 就是脚本选择它时使用的名称。 + + + 路由脚本会取代**全部**路由规则,包括面板自带的 `api` 规则。请在脚本中把 `api` 入站 + 发往 `api` 出站,否则流量统计和客户端变更的实时生效都会停止。文件不存在或加载失败时 + Xray 无法启动。 + + ## 均衡器 **均衡器**通过一个**选择器**(标签前缀,包括来自出站订阅的通配符池)将出站分组, diff --git a/frontend/src/pages/xray/basics/BasicsTab.tsx b/frontend/src/pages/xray/basics/BasicsTab.tsx index 2ae686512..6eced02ae 100644 --- a/frontend/src/pages/xray/basics/BasicsTab.tsx +++ b/frontend/src/pages/xray/basics/BasicsTab.tsx @@ -160,6 +160,7 @@ export default function BasicsTab({ ); const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs'; + const routingScript = templateSettings?.routing?.script ?? ''; const log = (templateSettings?.log || {}) as Record; const policy = (templateSettings?.policy?.system || {}) as Record; const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record; @@ -259,6 +260,25 @@ export default function BasicsTab({ /> } /> + { + const next = e.target.value; + mutate((tt) => { + if (!tt.routing) return; + if (next.trim() === '') delete tt.routing.script; + else tt.routing.script = next; + }); + }} + /> + } + /> )} + + + diff --git a/frontend/src/pages/xray/dns/DnsTab.tsx b/frontend/src/pages/xray/dns/DnsTab.tsx index 27a011c30..711d3ef94 100644 --- a/frontend/src/pages/xray/dns/DnsTab.tsx +++ b/frontend/src/pages/xray/dns/DnsTab.tsx @@ -297,6 +297,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab /> } /> + setDnsField('script', e.target.value, true)} + /> + } + /> {( [ [ diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index 946b5b2c7..e62f2a33e 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/frontend/src/pages/xray/routing/RoutingTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd'; +import { Alert, Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd'; import { AimOutlined, ControlOutlined, @@ -64,6 +64,7 @@ export default function RoutingTab({ moved: false, }); + const routingScript = templateSettings?.routing?.script ?? ''; const rules = useMemo( () => (templateSettings?.routing?.rules || []) as RoutingRule[], [templateSettings?.routing?.rules], @@ -349,6 +350,14 @@ export default function RoutingTab({ <> {modalContextHolder} {messageContextHolder} + {routingScript.trim() !== '' && ( + + )} { expect(onConfirm).toHaveBeenCalledTimes(1); expect(onConfirm.mock.calls[0][0]).toMatchObject({ address: '1.1.1.1', id: 'cf' }); }); + + it('saves an id typed for a plain server', async () => { + const onConfirm = vi.fn(); + renderWithProviders( + {}} onConfirm={onConfirm} />, + ); + + fireEvent.change(screen.getByLabelText('ID'), { target: { value: 'cf' } }); + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + + expect(onConfirm.mock.calls[0][0]).toMatchObject({ address: '1.1.1.1', id: 'cf' }); + }); }); diff --git a/frontend/src/test/dns-tab.test.tsx b/frontend/src/test/dns-tab.test.tsx index b13af098f..1a9c6648c 100644 --- a/frontend/src/test/dns-tab.test.tsx +++ b/frontend/src/test/dns-tab.test.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { describe, expect, it } from 'vitest'; +import { useEffect, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; import { fireEvent, screen } from '@testing-library/react'; import DnsTab from '@/pages/xray/dns/DnsTab'; @@ -127,4 +127,28 @@ describe('DnsTab', () => { expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull(); }); + + // xray-core 26.10.10 dns.script is a file path; an empty one fails the core build. + it('writes the Lua script path and drops the key once cleared', () => { + const seen = vi.fn(); + const latest = () => seen.mock.lastCall?.[0] as { dns: Record }; + function Harness() { + const [templateSettings, setTemplateSettings] = useState( + withHosts({}), + ); + useEffect(() => seen(templateSettings), [templateSettings]); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + return ; + } + + renderWithProviders(); + const script = screen.getByRole('textbox', { name: 'Lua Script' }); + fireEvent.change(script, { target: { value: '/etc/xray/dns.lua' } }); + expect(latest().dns.script).toBe('/etc/xray/dns.lua'); + + fireEvent.change(script, { target: { value: '' } }); + expect(latest().dns).not.toHaveProperty('script'); + }); }); diff --git a/frontend/src/test/routing-script.test.tsx b/frontend/src/test/routing-script.test.tsx new file mode 100644 index 000000000..a4b98e183 --- /dev/null +++ b/frontend/src/test/routing-script.test.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import BasicsTab from '@/pages/xray/basics/BasicsTab'; +import RoutingTab from '@/pages/xray/routing/RoutingTab'; +import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting'; +import { renderWithProviders } from './test-utils'; + +const SCRIPT_BANNER = 'A routing script is set under Basics, so Xray ignores these rules.'; + +function renderRoutingRules(routing: Record) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderWithProviders( + + + , + ); + fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ })); +} + +// xray-core 26.10.10 routes only through routing.script once it is set, skipping +// every rule, the panel's api rule included. +describe('routing script', () => { + it('writes the script path from Basics and drops the key once cleared', () => { + const seen = vi.fn(); + const latest = () => seen.mock.lastCall?.[0] as { routing: Record }; + function Harness() { + const [templateSettings, setTemplateSettings] = useState({ + routing: { domainStrategy: 'AsIs', rules: [] }, + outbounds: [], + } as unknown as XraySettingsValue); + useEffect(() => seen(templateSettings), [templateSettings]); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + return ( + + ); + } + + renderWithProviders(); + const script = screen.getByRole('textbox', { name: 'Routing Script' }); + fireEvent.change(script, { target: { value: 'route.lua' } }); + expect(latest().routing.script).toBe('route.lua'); + + fireEvent.change(script, { target: { value: ' ' } }); + expect(latest().routing).not.toHaveProperty('script'); + }); + + it('warns above the rules while a script is set', () => { + renderRoutingRules({ rules: [], script: 'route.lua' }); + + expect(screen.getByText(SCRIPT_BANNER)).toBeTruthy(); + }); + + it('shows no warning without a script', () => { + renderRoutingRules({ rules: [] }); + + expect(screen.queryByText(SCRIPT_BANNER)).toBeNull(); + }); +}); diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 47f00497b..e9911cd0f 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2275,7 +2275,11 @@ "clearAll": "حذف الكل", "clearAllTitle": "حذف جميع خوادم DNS؟", "clearAllConfirm": "سيؤدي هذا إلى إزالة جميع خوادم DNS من القائمة. لا يمكن التراجع عن هذا الإجراء.", - "dnsLeakWarning": "قد يتسرب DNS عبر localhost أو UDP/TCP غير المشفر أو وضع DoH/DoQ المحلي أو استعلامات الرجوع أو EDNS client IP. استخدم DoH عبر التوجيه، وثبت أسماء المحللات في hosts، وعطل الرجوع عندما تكون الخصوصية مهمة." + "dnsLeakWarning": "قد يتسرب DNS عبر localhost أو UDP/TCP غير المشفر أو وضع DoH/DoQ المحلي أو استعلامات الرجوع أو EDNS client IP. استخدم DoH عبر التوجيه، وثبت أسماء المحللات في hosts، وعطل الرجوع عندما تكون الخصوصية مهمة.", + "script": "سكريبت Lua", + "scriptDesc": "مسار ملف ‎.lua‎ بيعرّف HandleDNSQuery، مطلق أو نسبةً لمجلد bin بتاع Xray. بعدها السكريبت هو اللي بيرد على كل الاستعلامات وما بيوصلش للسيرفرات غير من خلال الكود بتاعه. Xray مش هيشتغل لو الملف مش موجود أو مش صالح.", + "serverId": "المعرّف", + "serverIdDesc": "الاسم اللي سكريبت DNS بلغة Lua بيستخدمه عشان يختار السيرفر ده" }, "fakedns": { "add": "أضف Fake DNS", @@ -2283,7 +2287,10 @@ "poolSize": "حجم المجموعة" }, "defaultOutbound": "الصادر الافتراضي", - "defaultOutboundDesc": "الحركة التي لا تطابق أي قاعدة توجيه تستخدم هذا الصادر (الأول في القائمة)." + "defaultOutboundDesc": "الحركة التي لا تطابق أي قاعدة توجيه تستخدم هذا الصادر (الأول في القائمة).", + "routingScript": "سكريبت التوجيه", + "routingScriptDesc": "مسار ملف ‎.lua‎ بيعرّف HandleRoute، مطلق أو نسبةً لمجلد bin بتاع Xray. بعدها السكريبت هو اللي بيختار كل مسار وXray بيتجاهل كل قواعد التوجيه، ومنها قاعدة api بتاعة البانل: لازم توجّه الإدخال api للإخراج api وإلا إحصائيات الترافيك وتطبيق تغييرات العملاء مباشرةً هيقفوا. Xray مش هيشتغل لو الملف مش موجود أو مش صالح.", + "routingScriptActive": "فيه سكريبت توجيه متظبط في {section}، عشان كده Xray بيتجاهل القواعد دي." }, "hosts": { "addHost": "إضافة مضيف", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index f1fbf4a17..3cfb13769 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -2393,7 +2393,11 @@ "clearAll": "Delete All", "clearAllTitle": "Delete all DNS servers?", "clearAllConfirm": "This removes every DNS server from the list. This cannot be undone.", - "dnsLeakWarning": "DNS can leak through localhost, plain UDP/TCP, local-mode DoH/DoQ, fallback queries, or EDNS client IP. Use routed DoH, hosts pins, and disable fallback when privacy matters." + "dnsLeakWarning": "DNS can leak through localhost, plain UDP/TCP, local-mode DoH/DoQ, fallback queries, or EDNS client IP. Use routed DoH, hosts pins, and disable fallback when privacy matters.", + "script": "Lua Script", + "scriptDesc": "Path to a .lua file defining HandleDNSQuery, absolute or relative to the Xray bin folder. The script then answers every query and reaches the servers only through its own code. Xray will not start if the file is missing or invalid.", + "serverId": "ID", + "serverIdDesc": "The name a Lua DNS script uses to pick this server" }, "fakedns": { "add": "Add Fake DNS", @@ -2401,7 +2405,10 @@ "poolSize": "Pool Size" }, "defaultOutbound": "Default Outbound", - "defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)." + "defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list).", + "routingScript": "Routing Script", + "routingScriptDesc": "Path to a .lua file defining HandleRoute, absolute or relative to the Xray bin folder. The script then picks every route and Xray ignores all routing rules, the panel's api rule included: send the api inbound to the api outbound or traffic stats and live client changes stop. Xray will not start if the file is missing or invalid.", + "routingScriptActive": "A routing script is set under {section}, so Xray ignores these rules." }, "sponsors": { "title": "Sponsors", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 02f29b1c0..2c1a764ae 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -2275,7 +2275,11 @@ "clearAll": "Eliminar todos", "clearAllTitle": "¿Eliminar todos los servidores DNS?", "clearAllConfirm": "Esto eliminará todos los servidores DNS de la lista. No se puede deshacer.", - "dnsLeakWarning": "El DNS puede filtrarse por localhost, UDP/TCP sin cifrar, DoH/DoQ en modo local, consultas de respaldo o EDNS client IP. Usa DoH enrutado, fija los resolutores en hosts y desactiva el respaldo cuando la privacidad importe." + "dnsLeakWarning": "El DNS puede filtrarse por localhost, UDP/TCP sin cifrar, DoH/DoQ en modo local, consultas de respaldo o EDNS client IP. Usa DoH enrutado, fija los resolutores en hosts y desactiva el respaldo cuando la privacidad importe.", + "script": "Script Lua", + "scriptDesc": "Ruta a un archivo .lua que define HandleDNSQuery, absoluta o relativa a la carpeta bin de Xray. El script responde entonces a cada consulta y solo llega a los servidores a través de su propio código. Xray no arrancará si el archivo falta o no es válido.", + "serverId": "ID", + "serverIdDesc": "Nombre con el que un script DNS en Lua elige este servidor" }, "fakedns": { "add": "Agregar DNS Falso", @@ -2283,7 +2287,10 @@ "poolSize": "Tamaño del grupo" }, "defaultOutbound": "Salida predeterminada", - "defaultOutboundDesc": "El tráfico que no coincide con ninguna regla de enrutamiento usa esta salida (la primera de la lista)." + "defaultOutboundDesc": "El tráfico que no coincide con ninguna regla de enrutamiento usa esta salida (la primera de la lista).", + "routingScript": "Script de enrutamiento", + "routingScriptDesc": "Ruta a un archivo .lua que define HandleRoute, absoluta o relativa a la carpeta bin de Xray. El script elige entonces cada ruta y Xray ignora todas las reglas de enrutamiento, incluida la regla api del panel: envía el inbound api al outbound api o se detendrán las estadísticas de tráfico y los cambios de clientes en vivo. Xray no arrancará si el archivo falta o no es válido.", + "routingScriptActive": "Hay un script de enrutamiento definido en {section}, así que Xray ignora estas reglas." }, "hosts": { "addHost": "Agregar host", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 9e7f2ac04..d20f9741e 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2275,7 +2275,11 @@ "clearAll": "حذف همه", "clearAllTitle": "حذف همه سرورهای DNS؟", "clearAllConfirm": "این کار همه سرورهای DNS را از لیست حذف می‌کند و قابل بازگشت نیست.", - "dnsLeakWarning": "DNS ممکن است از طریق localhost، UDP/TCP ساده، حالت محلی DoH/DoQ، درخواست‌های fallback یا EDNS client IP نشت کند. برای حریم خصوصی از DoH مسیریابی‌شده، مقدارهای hosts ثابت و غیرفعال‌سازی fallback استفاده کنید." + "dnsLeakWarning": "DNS ممکن است از طریق localhost، UDP/TCP ساده، حالت محلی DoH/DoQ، درخواست‌های fallback یا EDNS client IP نشت کند. برای حریم خصوصی از DoH مسیریابی‌شده، مقدارهای hosts ثابت و غیرفعال‌سازی fallback استفاده کنید.", + "script": "اسکریپت Lua", + "scriptDesc": "مسیر یک فایل ‎.lua‎ که HandleDNSQuery را تعریف می‌کند، به‌صورت مطلق یا نسبت به پوشه‌ی bin در Xray. از آن پس اسکریپت به همه‌ی کوئری‌ها پاسخ می‌دهد و سرورها فقط از طریق کد خودش استفاده می‌شوند. اگر فایل وجود نداشته باشد یا نامعتبر باشد، Xray اجرا نمی‌شود.", + "serverId": "شناسه", + "serverIdDesc": "نامی که اسکریپت Lua‌ی DNS با آن این سرور را انتخاب می‌کند" }, "fakedns": { "add": "افزودن دی‌ان‌اس جعلی", @@ -2283,7 +2287,10 @@ "poolSize": "اندازه استخر" }, "defaultOutbound": "خروجی پیش‌فرض", - "defaultOutboundDesc": "ترافیکی که با هیچ قانون مسیریابی جور نشود از این خروجی استفاده می‌کند (اولین خروجی در فهرست)." + "defaultOutboundDesc": "ترافیکی که با هیچ قانون مسیریابی جور نشود از این خروجی استفاده می‌کند (اولین خروجی در فهرست).", + "routingScript": "اسکریپت مسیریابی", + "routingScriptDesc": "مسیر یک فایل ‎.lua‎ که HandleRoute را تعریف می‌کند، به‌صورت مطلق یا نسبت به پوشه‌ی bin در Xray. از آن پس اسکریپت همه‌ی مسیرها را انتخاب می‌کند و Xray همه‌ی قوانین مسیریابی، از جمله قانون api پنل، را نادیده می‌گیرد: اینباند api را به اوت‌باند api بفرستید، وگرنه آمار ترافیک و اعمال زنده‌ی تغییرات کلاینت‌ها متوقف می‌شود. اگر فایل وجود نداشته باشد یا نامعتبر باشد، Xray اجرا نمی‌شود.", + "routingScriptActive": "یک اسکریپت مسیریابی در بخش {section} تنظیم شده است، بنابراین Xray این قوانین را نادیده می‌گیرد." }, "hosts": { "addHost": "افزودن میزبان", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 8ac58554e..0cb4d7e1a 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -2275,7 +2275,11 @@ "clearAll": "Hapus Semua", "clearAllTitle": "Hapus semua server DNS?", "clearAllConfirm": "Ini akan menghapus semua server DNS dari daftar. Tidak dapat dibatalkan.", - "dnsLeakWarning": "DNS dapat bocor lewat localhost, UDP/TCP polos, DoH/DoQ mode lokal, kueri fallback, atau EDNS client IP. Gunakan DoH yang dirutekan, pin resolver di hosts, dan nonaktifkan fallback saat privasi penting." + "dnsLeakWarning": "DNS dapat bocor lewat localhost, UDP/TCP polos, DoH/DoQ mode lokal, kueri fallback, atau EDNS client IP. Gunakan DoH yang dirutekan, pin resolver di hosts, dan nonaktifkan fallback saat privasi penting.", + "script": "Skrip Lua", + "scriptDesc": "Path ke file .lua yang mendefinisikan HandleDNSQuery, absolut atau relatif terhadap folder bin Xray. Skrip lalu menjawab setiap kueri dan hanya menjangkau server melalui kodenya sendiri. Xray tidak akan berjalan jika file tidak ada atau tidak valid.", + "serverId": "ID", + "serverIdDesc": "Nama yang dipakai skrip DNS Lua untuk memilih server ini" }, "fakedns": { "add": "Tambahkan DNS Palsu", @@ -2283,7 +2287,10 @@ "poolSize": "Ukuran Kolam" }, "defaultOutbound": "Outbound default", - "defaultOutboundDesc": "Lalu lintas tanpa aturan routing memakai outbound ini (yang pertama dalam daftar)." + "defaultOutboundDesc": "Lalu lintas tanpa aturan routing memakai outbound ini (yang pertama dalam daftar).", + "routingScript": "Skrip Routing", + "routingScriptDesc": "Path ke file .lua yang mendefinisikan HandleRoute, absolut atau relatif terhadap folder bin Xray. Skrip lalu memilih setiap rute dan Xray mengabaikan semua aturan routing, termasuk aturan api panel: arahkan inbound api ke outbound api atau statistik trafik dan perubahan klien langsung akan berhenti. Xray tidak akan berjalan jika file tidak ada atau tidak valid.", + "routingScriptActive": "Skrip routing diatur di {section}, jadi Xray mengabaikan aturan ini." }, "hosts": { "addHost": "Tambah Host", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 98aee605f..442025a20 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2275,7 +2275,11 @@ "clearAll": "すべて削除", "clearAllTitle": "すべての DNS サーバを削除しますか?", "clearAllConfirm": "リストからすべての DNS サーバが削除されます。この操作は元に戻せません。", - "dnsLeakWarning": "DNS は localhost、暗号化なしの UDP/TCP、ローカルモードの DoH/DoQ、フォールバック問い合わせ、EDNS client IP から漏れる可能性があります。プライバシー重視ではルーティングされた DoH、hosts 固定、フォールバック無効化を使ってください。" + "dnsLeakWarning": "DNS は localhost、暗号化なしの UDP/TCP、ローカルモードの DoH/DoQ、フォールバック問い合わせ、EDNS client IP から漏れる可能性があります。プライバシー重視ではルーティングされた DoH、hosts 固定、フォールバック無効化を使ってください。", + "script": "Lua スクリプト", + "scriptDesc": "HandleDNSQuery を定義した .lua ファイルのパス(絶対パス、または Xray の bin フォルダからの相対パス)。設定するとスクリプトがすべてのクエリに応答し、サーバーへはスクリプト自身のコードからのみ問い合わせます。ファイルが存在しないか不正な場合、Xray は起動しません。", + "serverId": "ID", + "serverIdDesc": "Lua DNS スクリプトがこのサーバーを選ぶときの名前" }, "fakedns": { "add": "フェイクDNS追加", @@ -2283,7 +2287,10 @@ "poolSize": "プールサイズ" }, "defaultOutbound": "デフォルトアウトバウンド", - "defaultOutboundDesc": "ルーティング規則に一致しないトラフィックはこのアウトバウンドを使います(一覧の先頭)。" + "defaultOutboundDesc": "ルーティング規則に一致しないトラフィックはこのアウトバウンドを使います(一覧の先頭)。", + "routingScript": "ルーティングスクリプト", + "routingScriptDesc": "HandleRoute を定義した .lua ファイルのパス(絶対パス、または Xray の bin フォルダからの相対パス)。設定するとスクリプトがすべての経路を選び、Xray はパネルの api ルールを含むすべてのルーティングルールを無視します。api インバウンドを api アウトバウンドへ送らないと、トラフィック統計とクライアント変更の即時反映が止まります。ファイルが存在しないか不正な場合、Xray は起動しません。", + "routingScriptActive": "{section} でルーティングスクリプトが設定されているため、Xray はこれらのルールを無視します。" }, "hosts": { "addHost": "ホストを追加", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 3b8e36dcf..bbba2b6a4 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -2275,7 +2275,11 @@ "clearAll": "Remover Todos", "clearAllTitle": "Remover todos os servidores DNS?", "clearAllConfirm": "Isso remove todos os servidores DNS da lista. Não pode ser desfeito.", - "dnsLeakWarning": "DNS pode vazar por localhost, UDP/TCP sem criptografia, DoH/DoQ em modo local, consultas de fallback ou EDNS client IP. Use DoH roteado, fixe resolvedores em hosts e desative fallback quando privacidade importar." + "dnsLeakWarning": "DNS pode vazar por localhost, UDP/TCP sem criptografia, DoH/DoQ em modo local, consultas de fallback ou EDNS client IP. Use DoH roteado, fixe resolvedores em hosts e desative fallback quando privacidade importar.", + "script": "Script Lua", + "scriptDesc": "Caminho para um arquivo .lua que define HandleDNSQuery, absoluto ou relativo à pasta bin do Xray. O script passa a responder a todas as consultas e só alcança os servidores pelo próprio código. O Xray não inicia se o arquivo estiver ausente ou for inválido.", + "serverId": "ID", + "serverIdDesc": "Nome com que um script DNS em Lua escolhe este servidor" }, "fakedns": { "add": "Adicionar Fake DNS", @@ -2283,7 +2287,10 @@ "poolSize": "Tamanho do Pool" }, "defaultOutbound": "Saída padrão", - "defaultOutboundDesc": "Tráfego sem regra de roteamento usa esta saída (a primeira da lista)." + "defaultOutboundDesc": "Tráfego sem regra de roteamento usa esta saída (a primeira da lista).", + "routingScript": "Script de roteamento", + "routingScriptDesc": "Caminho para um arquivo .lua que define HandleRoute, absoluto ou relativo à pasta bin do Xray. O script passa a escolher cada rota e o Xray ignora todas as regras de roteamento, inclusive a regra api do painel: envie o inbound api para o outbound api ou as estatísticas de tráfego e as alterações de clientes ao vivo param. O Xray não inicia se o arquivo estiver ausente ou for inválido.", + "routingScriptActive": "Há um script de roteamento definido em {section}, então o Xray ignora estas regras." }, "hosts": { "addHost": "Adicionar Host", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index a438036da..188fb3a6c 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2275,7 +2275,11 @@ "clearAll": "Удалить все", "clearAllTitle": "Удалить все DNS-серверы?", "clearAllConfirm": "Все DNS-серверы будут удалены из списка. Это действие нельзя отменить.", - "dnsLeakWarning": "DNS может утекать через localhost, обычный UDP/TCP, локальный режим DoH/DoQ, fallback-запросы или EDNS client IP. Для приватности используйте маршрутизируемый DoH, закрепляйте резолверы в hosts и отключайте fallback." + "dnsLeakWarning": "DNS может утекать через localhost, обычный UDP/TCP, локальный режим DoH/DoQ, fallback-запросы или EDNS client IP. Для приватности используйте маршрутизируемый DoH, закрепляйте резолверы в hosts и отключайте fallback.", + "script": "Lua-скрипт", + "scriptDesc": "Путь к файлу .lua с функцией HandleDNSQuery — абсолютный или относительно папки bin Xray. Скрипт отвечает на все запросы и обращается к серверам только из своего кода. Xray не запустится, если файл отсутствует или содержит ошибки.", + "serverId": "ID", + "serverIdDesc": "Имя, по которому DNS-скрипт на Lua выбирает этот сервер" }, "fakedns": { "add": "Создать Fake DNS", @@ -2283,7 +2287,10 @@ "poolSize": "Размер пула" }, "defaultOutbound": "Исходящий по умолчанию", - "defaultOutboundDesc": "Трафик без совпадения с правилами маршрутизации идёт через этот исходящий (первый в списке)." + "defaultOutboundDesc": "Трафик без совпадения с правилами маршрутизации идёт через этот исходящий (первый в списке).", + "routingScript": "Скрипт маршрутизации", + "routingScriptDesc": "Путь к файлу .lua с функцией HandleRoute — абсолютный или относительно папки bin Xray. Скрипт выбирает каждый маршрут, а Xray игнорирует все правила маршрутизации, включая правило api панели: направляйте входящий api на исходящий api, иначе остановятся статистика трафика и применение изменений клиентов на лету. Xray не запустится, если файл отсутствует или содержит ошибки.", + "routingScriptActive": "В разделе «{section}» задан скрипт маршрутизации, поэтому Xray игнорирует эти правила." }, "hosts": { "addHost": "Добавить хост", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 8ce5bac44..bd26b91c9 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -2275,7 +2275,11 @@ "clearAll": "Tümünü Sil", "clearAllTitle": "Tüm DNS sunucularını sil?", "clearAllConfirm": "Bu işlem tüm DNS sunucularını listeden kaldırır. Geri alınamaz.", - "dnsLeakWarning": "DNS; localhost, düz UDP/TCP, yerel mod DoH/DoQ, fallback sorguları veya EDNS client IP üzerinden sızabilir. Gizlilik önemliyse yönlendirilmiş DoH, hosts sabitlemeleri ve kapalı fallback kullanın." + "dnsLeakWarning": "DNS; localhost, düz UDP/TCP, yerel mod DoH/DoQ, fallback sorguları veya EDNS client IP üzerinden sızabilir. Gizlilik önemliyse yönlendirilmiş DoH, hosts sabitlemeleri ve kapalı fallback kullanın.", + "script": "Lua Betiği", + "scriptDesc": "HandleDNSQuery tanımlayan bir .lua dosyasının yolu; mutlak ya da Xray bin klasörüne göre göreli. Betik bundan sonra her sorguyu yanıtlar ve sunuculara yalnızca kendi koduyla ulaşır. Dosya yoksa veya geçersizse Xray başlamaz.", + "serverId": "Kimlik", + "serverIdDesc": "Bir Lua DNS betiğinin bu sunucuyu seçerken kullandığı ad" }, "fakedns": { "add": "Sahte DNS Ekle", @@ -2283,7 +2287,10 @@ "poolSize": "Havuz Boyutu" }, "defaultOutbound": "Varsayılan giden", - "defaultOutboundDesc": "Yönlendirme kuralıyla eşleşmeyen trafik bu gideni kullanır (listedeki ilk giden)." + "defaultOutboundDesc": "Yönlendirme kuralıyla eşleşmeyen trafik bu gideni kullanır (listedeki ilk giden).", + "routingScript": "Yönlendirme Betiği", + "routingScriptDesc": "HandleRoute tanımlayan bir .lua dosyasının yolu; mutlak ya da Xray bin klasörüne göre göreli. Betik bundan sonra her rotayı seçer ve Xray, panelin api kuralı dahil tüm yönlendirme kurallarını yok sayar: api gelen bağlantısını api giden bağlantısına yönlendirin, yoksa trafik istatistikleri ve canlı istemci değişiklikleri durur. Dosya yoksa veya geçersizse Xray başlamaz.", + "routingScriptActive": "{section} bölümünde bir yönlendirme betiği ayarlı, bu yüzden Xray bu kuralları yok sayar." }, "hosts": { "addHost": "Host Ekle", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 9ac1da9b8..d88d1a8bc 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2275,7 +2275,11 @@ "clearAll": "Видалити всі", "clearAllTitle": "Видалити всі DNS-сервери?", "clearAllConfirm": "Усі DNS-сервери буде видалено зі списку. Дію не можна скасувати.", - "dnsLeakWarning": "DNS може витікати через localhost, звичайний UDP/TCP, локальний режим DoH/DoQ, fallback-запити або EDNS client IP. Для приватності використовуйте маршрутизований DoH, фіксацію hosts і вимикайте fallback." + "dnsLeakWarning": "DNS може витікати через localhost, звичайний UDP/TCP, локальний режим DoH/DoQ, fallback-запити або EDNS client IP. Для приватності використовуйте маршрутизований DoH, фіксацію hosts і вимикайте fallback.", + "script": "Lua-скрипт", + "scriptDesc": "Шлях до файлу .lua з функцією HandleDNSQuery — абсолютний або відносно теки bin Xray. Скрипт відповідає на всі запити й звертається до серверів лише зі свого коду. Xray не запуститься, якщо файл відсутній або містить помилки.", + "serverId": "ID", + "serverIdDesc": "Ім'я, за яким DNS-скрипт на Lua вибирає цей сервер" }, "fakedns": { "add": "Додати підроблений DNS", @@ -2283,7 +2287,10 @@ "poolSize": "Розмір пулу" }, "defaultOutbound": "Вихідний за замовчуванням", - "defaultOutboundDesc": "Трафік без збігу з правилами маршрутизації йде через цей вихідний (перший у списку)." + "defaultOutboundDesc": "Трафік без збігу з правилами маршрутизації йде через цей вихідний (перший у списку).", + "routingScript": "Скрипт маршрутизації", + "routingScriptDesc": "Шлях до файлу .lua з функцією HandleRoute — абсолютний або відносно теки bin Xray. Скрипт обирає кожен маршрут, а Xray ігнорує всі правила маршрутизації, зокрема правило api панелі: спрямовуйте вхідний api на вихідний api, інакше зупиниться статистика трафіку й застосування змін клієнтів на льоту. Xray не запуститься, якщо файл відсутній або містить помилки.", + "routingScriptActive": "У розділі «{section}» задано скрипт маршрутизації, тому Xray ігнорує ці правила." }, "hosts": { "addHost": "Додати хост", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index aba0dd526..8482698b5 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -2275,7 +2275,11 @@ "clearAll": "Xóa tất cả", "clearAllTitle": "Xóa tất cả máy chủ DNS?", "clearAllConfirm": "Thao tác này sẽ xóa toàn bộ máy chủ DNS khỏi danh sách. Không thể hoàn tác.", - "dnsLeakWarning": "DNS có thể rò rỉ qua localhost, UDP/TCP không mã hóa, DoH/DoQ chế độ cục bộ, truy vấn fallback hoặc EDNS client IP. Hãy dùng DoH được định tuyến, ghim resolver trong hosts và tắt fallback khi cần riêng tư." + "dnsLeakWarning": "DNS có thể rò rỉ qua localhost, UDP/TCP không mã hóa, DoH/DoQ chế độ cục bộ, truy vấn fallback hoặc EDNS client IP. Hãy dùng DoH được định tuyến, ghim resolver trong hosts và tắt fallback khi cần riêng tư.", + "script": "Script Lua", + "scriptDesc": "Đường dẫn tới tệp .lua định nghĩa HandleDNSQuery, tuyệt đối hoặc tương đối với thư mục bin của Xray. Khi đó script trả lời mọi truy vấn và chỉ dùng các máy chủ qua mã của chính nó. Xray sẽ không khởi động nếu tệp không tồn tại hoặc không hợp lệ.", + "serverId": "ID", + "serverIdDesc": "Tên mà script DNS Lua dùng để chọn máy chủ này" }, "fakedns": { "add": "Thêm DNS giả", @@ -2283,7 +2287,10 @@ "poolSize": "Kích thước bể bơi" }, "defaultOutbound": "Outbound mặc định", - "defaultOutboundDesc": "Lưu lượng không khớp quy tắc định tuyến dùng outbound này (mục đầu danh sách)." + "defaultOutboundDesc": "Lưu lượng không khớp quy tắc định tuyến dùng outbound này (mục đầu danh sách).", + "routingScript": "Script định tuyến", + "routingScriptDesc": "Đường dẫn tới tệp .lua định nghĩa HandleRoute, tuyệt đối hoặc tương đối với thư mục bin của Xray. Khi đó script chọn mọi tuyến và Xray bỏ qua tất cả quy tắc định tuyến, kể cả quy tắc api của panel: hãy chuyển inbound api tới outbound api, nếu không thống kê lưu lượng và việc áp dụng thay đổi client trực tiếp sẽ dừng. Xray sẽ không khởi động nếu tệp không tồn tại hoặc không hợp lệ.", + "routingScriptActive": "Một script định tuyến đã được đặt trong {section}, nên Xray bỏ qua các quy tắc này." }, "hosts": { "addHost": "Thêm Host", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 2c6e8f1a2..3ed919c00 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2275,7 +2275,11 @@ "clearAll": "删除全部", "clearAllTitle": "删除所有 DNS 服务器?", "clearAllConfirm": "此操作将从列表中删除所有 DNS 服务器,且无法撤销。", - "dnsLeakWarning": "DNS 可能通过 localhost、明文 UDP/TCP、本地模式 DoH/DoQ、回退查询或 EDNS client IP 泄漏。重视隐私时请使用经路由的 DoH、在 hosts 中固定解析器,并禁用回退。" + "dnsLeakWarning": "DNS 可能通过 localhost、明文 UDP/TCP、本地模式 DoH/DoQ、回退查询或 EDNS client IP 泄漏。重视隐私时请使用经路由的 DoH、在 hosts 中固定解析器,并禁用回退。", + "script": "Lua 脚本", + "scriptDesc": "定义了 HandleDNSQuery 的 .lua 文件路径,可为绝对路径或相对于 Xray bin 目录的路径。设置后由脚本应答所有查询,只通过脚本自身的代码访问服务器。文件不存在或无效时 Xray 无法启动。", + "serverId": "ID", + "serverIdDesc": "Lua DNS 脚本用来选择此服务器的名称" }, "fakedns": { "add": "添加假 DNS", @@ -2283,7 +2287,10 @@ "poolSize": "池大小" }, "defaultOutbound": "默认出站", - "defaultOutboundDesc": "未匹配任何路由规则的流量走此出站(列表中的第一个出站)。" + "defaultOutboundDesc": "未匹配任何路由规则的流量走此出站(列表中的第一个出站)。", + "routingScript": "路由脚本", + "routingScriptDesc": "定义了 HandleRoute 的 .lua 文件路径,可为绝对路径或相对于 Xray bin 目录的路径。设置后由脚本选择所有路由,Xray 会忽略全部路由规则,包括面板的 api 规则:请把 api 入站路由到 api 出站,否则流量统计和客户端变更的实时生效都会停止。文件不存在或无效时 Xray 无法启动。", + "routingScriptActive": "已在“{section}”中设置路由脚本,因此 Xray 会忽略这些规则。" }, "hosts": { "addHost": "添加主机", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 859569342..718e052c0 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2275,7 +2275,11 @@ "clearAll": "全部刪除", "clearAllTitle": "刪除所有 DNS 伺服器?", "clearAllConfirm": "此操作將從清單中刪除所有 DNS 伺服器,無法復原。", - "dnsLeakWarning": "DNS 可能透過 localhost、明文 UDP/TCP、本機模式 DoH/DoQ、回退查詢或 EDNS client IP 洩漏。重視隱私時請使用經路由的 DoH、在 hosts 固定解析器,並停用回退。" + "dnsLeakWarning": "DNS 可能透過 localhost、明文 UDP/TCP、本機模式 DoH/DoQ、回退查詢或 EDNS client IP 洩漏。重視隱私時請使用經路由的 DoH、在 hosts 固定解析器,並停用回退。", + "script": "Lua 腳本", + "scriptDesc": "定義了 HandleDNSQuery 的 .lua 檔案路徑,可為絕對路徑或相對於 Xray bin 資料夾的路徑。設定後由腳本回應所有查詢,只透過腳本自身的程式碼存取伺服器。檔案不存在或無效時 Xray 無法啟動。", + "serverId": "ID", + "serverIdDesc": "Lua DNS 腳本用來選擇此伺服器的名稱" }, "fakedns": { "add": "新增假 DNS", @@ -2283,7 +2287,10 @@ "poolSize": "池大小" }, "defaultOutbound": "預設出站", - "defaultOutboundDesc": "未符合任何路由規則的流量走此出站(清單中的第一個出站)。" + "defaultOutboundDesc": "未符合任何路由規則的流量走此出站(清單中的第一個出站)。", + "routingScript": "路由腳本", + "routingScriptDesc": "定義了 HandleRoute 的 .lua 檔案路徑,可為絕對路徑或相對於 Xray bin 資料夾的路徑。設定後由腳本選擇所有路由,Xray 會忽略全部路由規則,包括面板的 api 規則:請將 api 入站路由到 api 出站,否則流量統計與用戶端變更的即時套用都會停止。檔案不存在或無效時 Xray 無法啟動。", + "routingScriptActive": "已在「{section}」中設定路由腳本,因此 Xray 會忽略這些規則。" }, "hosts": { "addHost": "新增 Host", From ef4bda4e061daff2334314d98b62ba015d80016c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 11 Oct 2026 15:40:46 +0200 Subject: [PATCH 12/13] feat(inbounds): TUN adapter type and auto-system options xray-core 26.9 added three TUN settings the inbound form could not set: - desc, the tunnel type a new Wintun adapter is created with on Windows. The settings schema did not list it, so the parse stripped it and a value entered as JSON vanished on the next save; - autoSystemDnsToGateway (Linux), which points systemd-resolved at the TUN gateway; - autoSystemWfpBlockLeak (Windows), whose dns / misconfigtun filters keep traffic from leaking past the TUN. The tooltips name the platform each one acts on and what it needs, since the core refuses to build or start the TUN otherwise. --- .../src/pages/inbounds/form/protocols/tun.tsx | 35 ++++++++++++++- frontend/src/schemas/protocols/inbound/tun.ts | 2 + frontend/src/test/inbound-form-modal.test.tsx | 43 +++++++++++++++++++ frontend/src/test/inbound-from-db.test.ts | 4 +- internal/web/translation/ar-EG.json | 8 +++- internal/web/translation/en-US.json | 8 +++- internal/web/translation/es-ES.json | 8 +++- internal/web/translation/fa-IR.json | 8 +++- internal/web/translation/id-ID.json | 8 +++- internal/web/translation/ja-JP.json | 8 +++- internal/web/translation/pt-BR.json | 8 +++- internal/web/translation/ru-RU.json | 8 +++- internal/web/translation/tr-TR.json | 8 +++- internal/web/translation/uk-UA.json | 8 +++- internal/web/translation/vi-VN.json | 8 +++- internal/web/translation/zh-CN.json | 8 +++- internal/web/translation/zh-TW.json | 8 +++- 17 files changed, 173 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/inbounds/form/protocols/tun.tsx b/frontend/src/pages/inbounds/form/protocols/tun.tsx index 2a92b3971..81b0281de 100644 --- a/frontend/src/pages/inbounds/form/protocols/tun.tsx +++ b/frontend/src/pages/inbounds/form/protocols/tun.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd'; +import { Button, Form, Input, InputNumber, Select, Space, Switch, Tooltip } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { useFieldArray, useFormContext } from 'react-hook-form'; @@ -35,6 +35,12 @@ function StringList({ name, label, placeholder }: StringListProps) { ); } +// xray-core 26.9.30 accepts exactly these autoSystemWfpBlockLeak values. +const WFP_BLOCK_LEAK_OPTIONS = [ + { value: 'dns', label: 'dns' }, + { value: 'misconfigtun', label: 'misconfigtun' }, +]; + export default function TunFields() { const { t } = useTranslation(); return ( @@ -42,6 +48,13 @@ export default function TunFields() { + + + @@ -77,6 +90,26 @@ export default function TunFields() { > + + + + +