Explorar o código

feat(xray): update xray-core to v26.7.28 and adapt panel

Bump xtls/xray-core to 5ca6f4b7d4dc (v26.7.28) and move the three binary
pins (DockerInit.sh, the Linux and Windows URLs in release.yml) in lockstep
so the in-process conf.Build() validation and the child binary agree.

XMC finalmask (#6487) is the breaking change. The mask's `usernames` string
list is gone, replaced by a required `profiles` array whose entries each need
a 3-16 character [A-Za-z0-9_] username, a parseable UUID and both Mojang
texture fields; the "default to Dream when empty" fallback was removed, so an
xmc mask saved by an older panel now fails to build and takes the whole
config down with it rather than degrading one inbound.

The textures are a signed blob only Mojang's session server can issue, so a
legacy username cannot be upgraded automatically. The panel now:

- rejects an incomplete xmc mask at save time (AddInbound/UpdateInbound),
  pointing at the specific field that is missing;
- drops only the offending mask when generating the core config, for rows
  that never went through the form (upgrade, node sync, restored backup,
  direct DB edit), warning which inbound lost its obfuscation instead of
  leaving every inbound offline;
- carries legacy usernames into profile stubs in the finalmask form so the
  operator keeps their player names and sees exactly what still needs
  filling in, and edits profiles through a list editor.

No destructive DB migration: unlike the removed shadowsocks ciphers there is
no valid replacement to rewrite to, and dropping the mask from stored rows
would discard the operator's hostname and password for config they can still
repair. The generation-time strip already prevents the startup failure.

Also track the core's xmux maxConnections fallback, lowered from 6 to 3 for
anti-TSPU, in the fresh-XMUX seed so a new panel config matches what the core
would pick on its own.

TUN gained a `desc` key and random utunN naming, but the Go validator no
longer accepts TUN inbounds and the panel only renders legacy saved rows, so
nothing there needs adapting. The remaining commits are REALITY log-warning
wording, gRPC/XHTTP localAddr accuracy and a routing tweak, none of which
change the JSON config surface.

Tests cross-check the panel's profile predicate against conf.XMCProfile.Build()
so a future core release that tightens or relaxes the rules fails loudly
rather than silently emitting configs the core refuses to start on.
Sanaei hai 10 horas
pai
achega
7f7b7e16a4

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

@@ -124,7 +124,7 @@ jobs:
           cd x-ui/bin
 
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             fetch ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -282,7 +282,7 @@ jobs:
           cd x-ui\bin
 
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
           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"

+ 2 - 1
.gitignore

@@ -2,7 +2,8 @@
 .idea/
 .vscode/
 .cursor/
-.claude/*
+.specify/
+.claude/
 .cache/
 .sync*
 

+ 1 - 1
DockerInit.sh

@@ -32,7 +32,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.7.11/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 127 - 12
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
     case 'header-custom':
       return { clients: [], servers: [] };
     case 'xmc':
-      return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
+      return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
     default:
       return {};
   }
 }
 
+function defaultXmcProfile(): Record<string, unknown> {
+  return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
+}
+
+// xray-core #6487 replaced the xmc mask's `usernames` string list with
+// `profiles` objects carrying a Mojang-signed session profile, and dropped the
+// "default to Dream" fallback so at least one complete profile is now
+// mandatory. The signature can only come from Mojang's session server, so a
+// legacy username cannot be upgraded automatically — carry it into a profile
+// stub instead, which keeps the operator's player names visible and leaves the
+// per-field validators pointing at exactly what still has to be filled in.
+export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
+  const out: Record<string, unknown> = { ...settings };
+  let changed = false;
+  if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
+    out.profiles = out.usernames
+      .filter((name): name is string => typeof name === 'string' && name.trim() !== '')
+      .map((name) => ({ ...defaultXmcProfile(), username: name }));
+    changed = true;
+  }
+  if ('usernames' in out) {
+    delete out.usernames;
+    changed = true;
+  }
+  if (!Array.isArray(out.profiles)) {
+    out.profiles = [];
+    changed = true;
+  }
+  return { next: out, changed };
+}
+
 // xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
 // with `lengths`/`delays` arrays (the singular keys remain in core only as a
 // fallback). Lift any legacy singular value into a one-element array so the
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
 export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
   const base = asPath(name);
 
-  // Migrate legacy single-range fragment masks to the per-segment arrays once
-  // on mount so configs saved before #6334 render in the list UI.
+  // Migrate legacy TCP mask shapes once on mount so configs saved before
+  // #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
   const migratedRef = useRef(false);
   useEffect(() => {
     if (migratedRef.current) return;
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
     const next = tcp.map((mask) => {
       if (!mask || typeof mask !== 'object') return mask;
       const m = mask as Record<string, unknown>;
-      if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
-      const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
+      if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
+      if (!m.settings || typeof m.settings !== 'object') return mask;
+      const settings = m.settings as Record<string, unknown>;
+      const { next: migrated, changed } = m.type === 'fragment'
+        ? migrateFragmentSettings(settings)
+        : migrateXmcSettings(settings);
       if (!changed) return mask;
       anyChanged = true;
       return { ...m, settings: migrated };
@@ -380,13 +415,7 @@ function TcpMaskItem({
                 <Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
                   <Input placeholder="Server address mimicked in the handshake" />
                 </Form.Item>
-                <Form.Item
-                  label="Usernames"
-                  name={[fieldName, 'settings', 'usernames']}
-                  extra="Player names offered to probes; core defaults to Dream when empty."
-                >
-                  <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
-                </Form.Item>
+                <XmcProfilesList tcpFieldName={fieldName} />
                 <Form.Item label="Password" required>
                   <Space.Compact block>
                     <Form.Item
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
   return cur;
 }
 
+// Mojang hands the profile UUID back undashed from the session server and
+// dashed from most other endpoints; xray-core parses either, so accept both
+// rather than forcing the operator to reformat what they pasted.
+const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
+const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
+
+function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
+  if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
+  return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
+}
+
+function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
+  if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
+  return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
+}
+
+// Each mask needs at least one fully signed profile since xray-core #6487 —
+// an empty or partial list makes the core reject the whole config, so the
+// panel blocks the save here rather than letting the backend drop the mask.
+function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
+  const { t } = useTranslation();
+  return (
+    <Form.List name={[tcpFieldName, 'settings', 'profiles']}>
+      {(profiles, { add, remove }) => (
+        <>
+          <Form.Item
+            label="Profiles"
+            extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
+          >
+            <Button
+              type="primary"
+              size="small"
+              icon={<PlusOutlined />}
+              aria-label={t('add')}
+              onClick={() => add(defaultXmcProfile())}
+            />
+          </Form.Item>
+          {profiles.map((profile, idx) => (
+            <div key={profile.key}>
+              <Divider style={{ margin: 0 }}>
+                Profile {idx + 1}
+                <DeleteOutlined
+                  className="danger-icon"
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('remove')}
+                  onClick={() => remove(profile.name)}
+                  onKeyDown={activateOnKey(() => remove(profile.name))}
+                />
+              </Divider>
+              <Form.Item
+                label="Username"
+                name={[profile.name, 'username']}
+                rules={[{ validator: validateXmcUsername }]}
+              >
+                <Input placeholder="Notch" />
+              </Form.Item>
+              <Form.Item
+                label="UUID"
+                name={[profile.name, 'uuid']}
+                rules={[{ validator: validateXmcUuid }]}
+              >
+                <Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
+              </Form.Item>
+              <Form.Item
+                label="Textures Value"
+                name={[profile.name, 'texturesValue']}
+                rules={[{ required: true, message: 'Textures value is required' }]}
+              >
+                <Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
+              </Form.Item>
+              <Form.Item
+                label="Textures Signature"
+                name={[profile.name, 'texturesSignature']}
+                rules={[{ required: true, message: 'Textures signature is required' }]}
+              >
+                <Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
+              </Form.Item>
+            </div>
+          ))}
+        </>
+      )}
+    </Form.List>
+  );
+}
+
 function HeaderCustomGroups({
   tcpFieldName, form, absoluteSettingsPath,
 }: {

+ 5 - 3
frontend/src/schemas/protocols/stream/xhttp.ts

@@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({
 export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
 
 // Seed for freshly enabling XMUX on a config that had no xmux block:
-// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
-// rather than the concurrency strategy.
+// mirrors xray-core's own maxConnections fallback rather than the
+// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for
+// anti-TSPU, so track it here to keep a fresh panel config matching what
+// the core would have picked on its own.
 export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
   ...XHttpXmuxSchema.parse({}),
   maxConcurrency: '',
-  maxConnections: 6,
+  maxConnections: 3,
 };
 
 // Predefined sessionIDTable names xray-core accepts as a shorthand for a

+ 45 - 1
frontend/src/test/finalmask.test.ts

@@ -1,7 +1,7 @@
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 
-import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
+import { migrateXmcSettings, parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
 
 const fixtures = import.meta.glob<unknown>(
@@ -26,6 +26,50 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => {
   }
 });
 
+describe('migrateXmcSettings', () => {
+  it('carries legacy usernames into profile stubs and drops the dead key', () => {
+    const { next, changed } = migrateXmcSettings({
+      hostname: 'mc.example.com',
+      usernames: ['Dream', 'Notch'],
+      password: 'pw',
+    });
+
+    expect(changed).toBe(true);
+    expect(next.usernames).toBeUndefined();
+    expect(next.hostname).toBe('mc.example.com');
+    expect(next.password).toBe('pw');
+    expect(next.profiles).toEqual([
+      { username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
+      { username: 'Notch', uuid: '', texturesValue: '', texturesSignature: '' },
+    ]);
+  });
+
+  it('gives a mask with neither key an empty profiles list', () => {
+    const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw' });
+
+    expect(changed).toBe(true);
+    expect(next.profiles).toEqual([]);
+  });
+
+  it('leaves an already migrated mask untouched', () => {
+    const profiles = [
+      { username: 'Notch', uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5', texturesValue: 'dmFsdWU=', texturesSignature: 'c2ln' },
+    ];
+    const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw', profiles });
+
+    expect(changed).toBe(false);
+    expect(next.profiles).toEqual(profiles);
+  });
+
+  it('discards blank legacy usernames rather than seeding unfixable stubs', () => {
+    const { next } = migrateXmcSettings({ usernames: ['Dream', '', '   '], password: 'pw' });
+
+    expect(next.profiles).toEqual([
+      { username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
+    ]);
+  });
+});
+
 describe('parseGeckoPacketSize', () => {
   it('accepts positive ordered packet size ranges', () => {
     expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 });

+ 3 - 3
frontend/src/test/stream-wire-normalize.test.ts

@@ -158,8 +158,8 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
   });
 
-  it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => {
-    expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
+  it('XMUX_FRESH_DEFAULTS seeds the core maxConnections fallback without a competing maxConcurrency', () => {
+    expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3);
     expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
 
     const out = normalizeXhttpForWire({
@@ -170,7 +170,7 @@ describe('normalizeXhttpForWire stream-one', () => {
     }, 'outbound');
 
     const xmux = out.xmux as Record<string, unknown>;
-    expect(xmux.maxConnections).toBe(6);
+    expect(xmux.maxConnections).toBe(3);
     expect(xmux.maxConcurrency).toBe('');
   });
 });

+ 1 - 1
go.mod

@@ -21,7 +21,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.72.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c
+	github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.54.0
 	golang.org/x/sys v0.47.0

+ 2 - 2
go.sum

@@ -218,8 +218,8 @@ 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-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM=
-github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw=
+github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0=
+github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

+ 145 - 0
internal/web/service/inbound.go

@@ -8,10 +8,13 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"regexp"
 	"sort"
 	"strings"
 	"time"
 
+	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -592,6 +595,142 @@ func validateFinalMaskRealityCombo(streamSettings string) error {
 	return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.")
 }
 
+var xmcProfileUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
+
+// xmcMaskProfilesComplete reports whether an xmc finalmask carries the signed
+// Minecraft session profiles xray-core has required since v26.7.28 (#6487).
+// The core replaced the old `usernames` string list with `profiles` objects
+// and removed the "default to Dream when empty" fallback, so a mask still on
+// the legacy shape — or one whose profiles are incomplete — now fails
+// conf.XMC.Build() and takes the entire config down with it rather than
+// degrading that one inbound.
+//
+// The texture fields are a signed blob only Mojang's session server can issue
+// (resolve the UUID by username, then fetch the profile with unsigned=false),
+// so the panel cannot synthesize a valid profile from a legacy username; an
+// incomplete mask can only be reported or dropped.
+func xmcMaskProfilesComplete(mask map[string]any) bool {
+	settings, ok := mask["settings"].(map[string]any)
+	if !ok {
+		return false
+	}
+	profiles, _ := settings["profiles"].([]any)
+	if len(profiles) == 0 {
+		return false
+	}
+	for _, entry := range profiles {
+		profile, ok := entry.(map[string]any)
+		if !ok {
+			return false
+		}
+		username, _ := profile["username"].(string)
+		if !xmcProfileUsernamePattern.MatchString(username) {
+			return false
+		}
+		id, _ := profile["uuid"].(string)
+		if _, err := uuid.Parse(id); err != nil {
+			return false
+		}
+		if value, _ := profile["texturesValue"].(string); value == "" {
+			return false
+		}
+		if signature, _ := profile["texturesSignature"].(string); signature == "" {
+			return false
+		}
+	}
+	return true
+}
+
+// isIncompleteXmcMask reports whether a finalmask.tcp entry is an xmc mask
+// xray-core would refuse to build.
+func isIncompleteXmcMask(entry any) bool {
+	mask, ok := entry.(map[string]any)
+	if !ok {
+		return false
+	}
+	if maskType, _ := mask["type"].(string); maskType != "xmc" {
+		return false
+	}
+	return !xmcMaskProfilesComplete(mask)
+}
+
+// incompleteXmcMaskCount counts the stream's xmc finalmask entries that
+// xray-core would refuse to build.
+func incompleteXmcMaskCount(stream map[string]any) int {
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		return 0
+	}
+	tcp, _ := finalmask["tcp"].([]any)
+	count := 0
+	for _, entry := range tcp {
+		if isIncompleteXmcMask(entry) {
+			count++
+		}
+	}
+	return count
+}
+
+// stripIncompleteXmcMasks removes every xmc finalmask entry xray-core would
+// refuse to build, returning how many were dropped, and clears the finalmask
+// object once nothing is left in it.
+//
+// AddInbound and UpdateInbound reject an incomplete mask at save time, but a
+// row that never went through those paths — an upgrade from a panel predating
+// v26.7.28, node sync, a restored backup, a direct DB edit — would otherwise
+// fail the whole config build and keep every other inbound offline too.
+// Dropping only the offending mask degrades that one inbound instead, which
+// the accompanying warning tells the admin to reconfigure.
+func stripIncompleteXmcMasks(stream map[string]any) int {
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		return 0
+	}
+	tcp, _ := finalmask["tcp"].([]any)
+	if len(tcp) == 0 {
+		return 0
+	}
+	kept := make([]any, 0, len(tcp))
+	dropped := 0
+	for _, entry := range tcp {
+		if isIncompleteXmcMask(entry) {
+			dropped++
+			continue
+		}
+		kept = append(kept, entry)
+	}
+	if dropped == 0 {
+		return 0
+	}
+	if len(kept) == 0 {
+		delete(finalmask, "tcp")
+	} else {
+		finalmask["tcp"] = kept
+	}
+	if len(finalmask) == 0 {
+		delete(stream, "finalmask")
+	}
+	return dropped
+}
+
+// validateFinalMaskXmcProfiles rejects an xmc finalmask without complete
+// profiles at save time, so the admin gets a targeted error instead of a core
+// that refuses to start (or, after GetXrayConfig heals it, an inbound quietly
+// serving without the obfuscation they configured).
+func validateFinalMaskXmcProfiles(streamSettings string) error {
+	if streamSettings == "" {
+		return nil
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return nil
+	}
+	if incompleteXmcMaskCount(stream) == 0 {
+		return nil
+	}
+	return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
+}
+
 // normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
 // always valid before the row is persisted, and drops the vestigial inbound-level
 // secret and adTag: MTProto is multi-client, so mtg and every share link read
@@ -725,6 +864,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 	}
+	if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
+		return inbound, false, err
+	}
 	s.normalizeMtprotoSecret(inbound)
 	if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
 		return inbound, false, err
@@ -1148,6 +1290,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 	}
+	if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
+		return inbound, false, err
+	}
 	s.normalizeMtprotoSecret(inbound)
 	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 

+ 204 - 0
internal/web/service/inbound_finalmask_xmc_test.go

@@ -0,0 +1,204 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/xtls/xray-core/infra/conf"
+)
+
+const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
+
+func TestValidateFinalMaskXmcProfiles(t *testing.T) {
+	tests := []struct {
+		name           string
+		streamSettings string
+		wantErr        bool
+	}{
+		{
+			name:           "empty streamSettings",
+			streamSettings: "",
+			wantErr:        false,
+		},
+		{
+			name:           "no finalmask",
+			streamSettings: `{"network":"tcp","security":"none"}`,
+			wantErr:        false,
+		},
+		{
+			name:           "non-xmc mask is untouched",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
+			wantErr:        false,
+		},
+		{
+			name:           "xmc with a complete profile",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
+			wantErr:        false,
+		},
+		{
+			name:           "legacy usernames shape without profiles",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","usernames":["Dream"]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "xmc with an empty profiles array",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "profile missing the textures signature",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":""}]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "profile with an unparseable uuid",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"not-a-uuid","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "profile with an out-of-range username",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"ab","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "one complete and one incomplete profile",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `,{"username":"Herobrine","uuid":"","texturesValue":"","texturesSignature":""}]}}]}}`,
+			wantErr:        true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateFinalMaskXmcProfiles(tt.streamSettings)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("validateFinalMaskXmcProfiles(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr)
+			}
+		})
+	}
+}
+
+// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
+// to xray-core's own loader instead of a restatement of it: every profile the
+// panel accepts must build, and every one it rejects must fail to build. A
+// future core release that tightens or relaxes the rules fails here rather
+// than silently producing configs the core refuses to start on.
+func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
+	profiles := []struct {
+		name string
+		raw  string
+	}{
+		{name: "complete", raw: completeXmcProfile},
+		{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
+	}
+
+	for _, tt := range profiles {
+		t.Run(tt.name, func(t *testing.T) {
+			var coreProfile conf.XMCProfile
+			if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
+				t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
+			}
+			_, coreErr := coreProfile.Build()
+
+			mask := map[string]any{"type": "xmc"}
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
+				t.Fatalf("unmarshal settings: %v", err)
+			}
+			mask["settings"] = settings
+
+			panelAccepts := xmcMaskProfilesComplete(mask)
+			coreAccepts := coreErr == nil
+			if panelAccepts != coreAccepts {
+				t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
+			}
+		})
+	}
+}
+
+// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
+// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
+// with no profiles at all is now a build failure.
+func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
+	var core conf.XMC
+	if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
+		t.Fatalf("unmarshal into conf.XMC: %v", err)
+	}
+	if _, err := core.Build(); err == nil {
+		t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
+	}
+}
+
+func TestStripIncompleteXmcMasks(t *testing.T) {
+	tests := []struct {
+		name        string
+		stream      string
+		wantDropped int
+		wantStream  string
+	}{
+		{
+			name:        "legacy usernames mask is dropped and finalmask removed",
+			stream:      `{"network":"tcp","finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"],"password":"pw"}}]}}`,
+			wantDropped: 1,
+			wantStream:  `{"network":"tcp"}`,
+		},
+		{
+			name:        "complete mask is kept",
+			stream:      `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
+			wantDropped: 0,
+			wantStream:  `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
+		},
+		{
+			name:        "sibling masks survive the drop",
+			stream:      `{"finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"]}},{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
+			wantDropped: 1,
+			wantStream:  `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
+		},
+		{
+			name:        "udp masks are preserved when tcp empties out",
+			stream:      `{"finalmask":{"tcp":[{"type":"xmc","settings":{}}],"udp":[{"type":"salamander"}]}}`,
+			wantDropped: 1,
+			wantStream:  `{"finalmask":{"udp":[{"type":"salamander"}]}}`,
+		},
+		{
+			name:        "stream without finalmask is untouched",
+			stream:      `{"network":"tcp","security":"tls"}`,
+			wantDropped: 0,
+			wantStream:  `{"network":"tcp","security":"tls"}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var stream map[string]any
+			if err := json.Unmarshal([]byte(tt.stream), &stream); err != nil {
+				t.Fatalf("unmarshal stream: %v", err)
+			}
+
+			dropped := stripIncompleteXmcMasks(stream)
+			if dropped != tt.wantDropped {
+				t.Errorf("stripIncompleteXmcMasks dropped = %d, want %d", dropped, tt.wantDropped)
+			}
+
+			var want map[string]any
+			if err := json.Unmarshal([]byte(tt.wantStream), &want); err != nil {
+				t.Fatalf("unmarshal wantStream: %v", err)
+			}
+			got, err := json.Marshal(stream)
+			if err != nil {
+				t.Fatalf("marshal stream: %v", err)
+			}
+			wantJSON, err := json.Marshal(want)
+			if err != nil {
+				t.Fatalf("marshal want: %v", err)
+			}
+			if string(got) != string(wantJSON) {
+				t.Errorf("stream after strip = %s, want %s", got, wantJSON)
+			}
+		})
+	}
+}

+ 4 - 0
internal/web/service/xray.go

@@ -281,6 +281,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 				delete(stream, "finalmask")
 			}
 
+			if dropped := stripIncompleteXmcMasks(stream); dropped > 0 {
+				logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
+			}
+
 			// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
 			// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
 			// new names here so inbounds stored before the rename keep working