fix(xray): reject configs xray-core refuses, and check the fixtures against it

The frontend's golden fixtures are the panel's model of an xray config, but
nothing ever asked xray-core whether it would accept them: the snapshots only
prove the Zod schemas agree with themselves. Building every fixture through the
same config builders the panel hands its config to — conf.InboundDetourConfig
for the full-config and AddInbound paths, conf.RouterConfig for
ApplyRoutingConfig, conf.DNSConfig for the dns section — found seven the core
refuses, three of them reachable from the panel's own UI. A refusal is not
scoped to one inbound: the config fails to load and every inbound stays down.

Hysteria: xray-core builds version 2 only, in both the protocol settings and
the transport settings, but the inbound settings schema accepted any version
from 1 up and its comment claimed upstream still supported v1. Both fixtures
carried version 1. The schema now pins 2, GenXrayInboundConfig heals stored
rows on the way out the way it already heals shadowsocks ciphers and wireguard
peers, and the share link drops the dead hysteria:// scheme — the subscription
server already emitted hysteria2:// for the same inbound.

XHTTP uplinkDataPlacement: both transport forms offered "query", which the core
has never accepted for that field (auto and body always, cookie and header in
packet-up mode). Replaced with auto, which was missing, and the default label
now names auto rather than body.

FinalMask items: switching an item to the rand-driven array kind wrote
packet:[] next to the rand. xray-core counts an empty array as a packet and
every item kind is exclusive, so noise answers "len(item.Packet) > 0 &&
item.Rand.To > 0" and header-custom "exactly one item kind must be set". The
editor now clears the packet, and GetXrayConfig strips the residue from rows
already saved with it.

The remaining four were stale fixtures: an xmc mask still on the usernames
shape v26.7.28 replaced with profiles, a fragment mask with no length, and
header-custom and noise items passing an array to the string packet kind — all
shapes the panel's own editors cannot produce.

golden_fixtures_xray_test.go keeps this from drifting again: every fixture in
every category is built through xray-core on each run, with a self-signed pair
standing in for the deployment certificate paths, so the next core bump reports
which fixture it broke.
This commit is contained in:
Sanaei
2026-07-28 14:43:55 +02:00
parent fea6a20f7c
commit dc6a16019e
25 changed files with 931 additions and 69 deletions
@@ -1146,12 +1146,18 @@ function ItemEditor({
onRemove?: () => void;
}) {
const { t } = useTranslation();
/**
* 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
* packet, rejecting an item that carries both a packet and a rand. That
* error fails the whole config, so one such item keeps every inbound offline.
*/
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
} else if (v === 'array') {
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
} else {
form.setFieldValue([...absoluteItemPath, 'packet'], '');
}
+1 -1
View File
@@ -174,7 +174,7 @@ export function createDefaultShadowsocksInboundSettings(
// constructor — the field discriminates v1 vs v2 inside the same settings
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
export interface HysteriaInboundSeed {
version?: number;
version?: 2;
}
export function createDefaultHysteriaInboundSettings(
+7 -7
View File
@@ -704,11 +704,12 @@ function hysteriaPinHex(pin: string): string {
}
}
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
// password from finalmask.udp[type=salamander] when present; the broader
// finalmask payload still rides under `fm` like the other links.
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
// settings schema pins it there and the subscription server emits the same
// scheme. Salamander obfuscation pulls its password from
// finalmask.udp[type=salamander] when present; the broader finalmask payload
// still rides under `fm` like the other links.
//
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
// which isn't a field on TlsStreamSettings.Settings — the guard is always
@@ -727,8 +728,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
const stream = inbound.streamSettings;
if (!stream || stream.security !== 'tls') return '';
const settings = inbound.settings;
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
const scheme = 'hysteria2';
const params = new URLSearchParams();
params.set('security', 'tls');
@@ -265,11 +265,11 @@ export default function XhttpForm() {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
@@ -240,11 +240,11 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
@@ -1,8 +1,9 @@
import { z } from 'zod';
// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the
// panel defaults to v2). Each client supplies an `auth` token instead of a
// UUID/password.
// Hysteria inbound. Each client supplies an `auth` token instead of a
// UUID/password. xray-core builds version 2 only — it answers anything else
// with "version != 2" and rejects the entire config, so a legacy row is
// coerced rather than carried through.
export const HysteriaClientSchema = z.object({
auth: z.string().min(1),
email: z.string().min(1),
@@ -20,7 +21,7 @@ export const HysteriaClientSchema = z.object({
export type HysteriaClient = z.infer<typeof HysteriaClientSchema>;
export const HysteriaInboundSettingsSchema = z.object({
version: z.number().int().min(1).default(2),
version: z.preprocess(() => 2, z.literal(2)).default(2),
clients: z.array(HysteriaClientSchema).default([]),
});
export type HysteriaInboundSettings = z.infer<typeof HysteriaInboundSettingsSchema>;
@@ -14,6 +14,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
"tcp": [
{
"settings": {
"length": "10-20",
"packets": "1-3",
},
"type": "fragment",
@@ -145,9 +146,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"GET / HTTP/1.1",
],
"packet": "GET / HTTP/1.1",
"type": "str",
},
],
@@ -157,9 +156,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"HTTP/1.1 200 OK",
],
"packet": "HTTP/1.1 200 OK",
"type": "str",
},
],
@@ -171,8 +168,13 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
"settings": {
"hostname": "mc.example.com",
"password": "s3cr3t",
"usernames": [
"Dream",
"profiles": [
{
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl",
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
"username": "Dream",
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
},
],
},
"type": "xmc",
@@ -219,13 +221,11 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
{
"delay": "10-16",
"rand": "10-20",
"type": "rand",
"type": "array",
},
{
"delay": "5",
"packet": [
"ping",
],
"packet": "ping",
"type": "str",
},
],
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] = `
exports[`InboundSchema (full) fixtures > parses hysteria-tls byte-stably 1`] = `
{
"down": 0,
"enable": true,
@@ -9,7 +9,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
"listen": "",
"port": 36715,
"protocol": "hysteria",
"remark": "gina-hysteria-v1",
"remark": "gina-hysteria",
"settings": {
"clients": [
{
@@ -25,7 +25,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
"totalGB": 0,
},
],
"version": 1,
"version": 2,
},
"shareAddr": "",
"shareAddrStrategy": "node",
@@ -78,7 +78,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
},
},
},
"tag": "inbound-hysteria-v1",
"tag": "inbound-hysteria",
"total": 0,
"up": 0,
}
@@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genHysteriaLink > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@example.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > hysteria-tls: byte-stable 1`] = `"hysteria2://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`;
@@ -37,7 +37,7 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`]
"totalGB": 0,
},
],
"version": 1,
"version": 2,
},
}
`;
@@ -100,7 +100,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
"xPaddingBytes": "500-1500",
"xPaddingHeader": "X-Pad",
"xPaddingKey": "secret-key",
"xPaddingMethod": "random",
"xPaddingMethod": "tokenish",
"xPaddingObfsMode": true,
"xPaddingPlacement": "header",
},
@@ -114,7 +114,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"enableXmux": false,
"headers": {},
"host": "edge.example.test",
"mode": "auto",
"mode": "packet-up",
"noGRPCHeader": false,
"noSSEHeader": false,
"path": "/sp",
@@ -131,7 +131,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"sessionIDTable": "",
"uplinkChunkSize": 0,
"uplinkDataKey": "u",
"uplinkDataPlacement": "query",
"uplinkDataPlacement": "cookie",
"uplinkHTTPMethod": "",
"xPaddingBytes": "100-1000",
"xPaddingHeader": "",
@@ -184,7 +184,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
"maxConcurrency": "16-32",
"maxConnections": 4,
"maxConnections": 0,
},
},
}
@@ -1,15 +1,35 @@
{
"tcp": [
{ "type": "fragment", "settings": { "packets": "1-3" } }
{
"type": "fragment",
"settings": {
"packets": "1-3",
"length": "10-20"
}
}
],
"udp": [
{ "type": "salamander", "settings": { "password": "swordfish" } },
{ "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } }
{
"type": "salamander",
"settings": {
"password": "swordfish"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "wireguard",
"value": ""
}
}
],
"quicParams": {
"congestion": "brutal",
"brutalUp": "100 mbps",
"brutalDown": "200 mbps",
"udpHop": { "ports": "10000-20000", "interval": "5-10" }
"udpHop": {
"ports": "10000-20000",
"interval": "5-10"
}
}
}
@@ -9,18 +9,28 @@
"maxSplit": "0"
}
},
{ "type": "sudoku" },
{
"type": "sudoku"
},
{
"type": "header-custom",
"settings": {
"clients": [
[
{ "type": "str", "packet": ["GET / HTTP/1.1"], "delay": 0 }
{
"type": "str",
"packet": "GET / HTTP/1.1",
"delay": 0
}
]
],
"servers": [
[
{ "type": "str", "packet": ["HTTP/1.1 200 OK"], "delay": 0 }
{
"type": "str",
"packet": "HTTP/1.1 200 OK",
"delay": 0
}
]
],
"errors": []
@@ -30,8 +40,15 @@
"type": "xmc",
"settings": {
"hostname": "mc.example.com",
"usernames": ["Dream"],
"password": "s3cr3t"
"password": "s3cr3t",
"profiles": [
{
"username": "Dream",
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl"
}
]
}
}
]
@@ -1,35 +1,77 @@
{
"udp": [
{ "type": "salamander", "settings": { "password": "swordfish" } },
{ "type": "mkcp-legacy", "settings": { "header": "", "value": "abcdef0123456789" } },
{ "type": "mkcp-legacy", "settings": { "header": "dns", "value": "cloudflare.com" } },
{ "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } },
{
"type": "salamander",
"settings": {
"password": "swordfish"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "",
"value": "abcdef0123456789"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "dns",
"value": "cloudflare.com"
}
},
{
"type": "mkcp-legacy",
"settings": {
"header": "wireguard",
"value": ""
}
},
{
"type": "noise",
"settings": {
"reset": "60",
"noise": [
{ "type": "rand", "rand": "10-20", "delay": "10-16" },
{ "type": "str", "packet": ["ping"], "delay": "5" }
{
"type": "array",
"rand": "10-20",
"delay": "10-16"
},
{
"type": "str",
"packet": "ping",
"delay": "5"
}
]
}
},
{
"type": "xdns",
"settings": {
"domains": ["example.com:txt", "example.org:a"],
"resolvers": ["example.com:txt+udp://1.1.1.1:53"]
"domains": [
"example.com:txt",
"example.org:a"
],
"resolvers": [
"example.com:txt+udp://1.1.1.1:53"
]
}
},
{
"type": "xicmp",
"settings": { "dgram": false, "ips": [] }
"settings": {
"dgram": false,
"ips": []
}
},
{
"type": "realm",
"settings": {
"url": "realm://public@example.com/my-realm",
"stunServers": ["stun.l.google.com:19302", "global.stun.twilio.com:3478"]
"stunServers": [
"stun.l.google.com:19302",
"global.stun.twilio.com:3478"
]
}
}
]
@@ -3,15 +3,20 @@
"up": 0,
"down": 0,
"total": 0,
"remark": "gina-hysteria-v1",
"remark": "gina-hysteria",
"enable": true,
"expiryTime": 0,
"listen": "",
"port": 36715,
"tag": "inbound-hysteria-v1",
"tag": "inbound-hysteria",
"sniffing": {
"enabled": false,
"destOverride": ["http", "tls", "quic", "fakedns"],
"destOverride": [
"http",
"tls",
"quic",
"fakedns"
],
"metadataOnly": false,
"routeOnly": false,
"ipsExcluded": [],
@@ -19,7 +24,7 @@
},
"protocol": "hysteria",
"settings": {
"version": 1,
"version": 2,
"clients": [
{
"auth": "hyst-v1-auth-XYZ",
@@ -56,7 +61,9 @@
"buildChain": false
}
],
"alpn": ["h3"],
"alpn": [
"h3"
],
"echServerKeys": "",
"settings": {
"fingerprint": "chrome",
@@ -1,7 +1,7 @@
{
"protocol": "hysteria",
"settings": {
"version": 1,
"version": 2,
"clients": [
{
"auth": "hyst3ria-v1-token-XYZ",
@@ -9,6 +9,6 @@
"xPaddingKey": "secret-key",
"xPaddingHeader": "X-Pad",
"xPaddingPlacement": "header",
"xPaddingMethod": "random"
"xPaddingMethod": "tokenish"
}
}
@@ -3,12 +3,12 @@
"xhttpSettings": {
"path": "/sp",
"host": "edge.example.test",
"mode": "auto",
"mode": "packet-up",
"sessionIDPlacement": "header",
"sessionIDKey": "X-Session",
"seqPlacement": "cookie",
"seqKey": "X-Seq",
"uplinkDataPlacement": "query",
"uplinkDataPlacement": "cookie",
"uplinkDataKey": "u"
}
}
@@ -19,7 +19,6 @@
},
"xmux": {
"maxConcurrency": "16-32",
"maxConnections": 4,
"cMaxReuseTimes": 0,
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",