diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index 7c359110a..4808b021d 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -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'], ''); } diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 1ec9bf3bf..2739bc67f 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -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( diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 005edd075..0a078c42c 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -704,11 +704,12 @@ function hysteriaPinHex(pin: string): string { } } -// Hysteria share link: hysteria://@:?#. -// 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://@:?#. +// 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'); diff --git a/frontend/src/pages/inbounds/form/transport/xhttp.tsx b/frontend/src/pages/inbounds/form/transport/xhttp.tsx index 24be0d930..ee5efc27a 100644 --- a/frontend/src/pages/inbounds/form/transport/xhttp.tsx +++ b/frontend/src/pages/inbounds/form/transport/xhttp.tsx @@ -265,11 +265,11 @@ export default function XhttpForm() { > diff --git a/frontend/src/schemas/protocols/inbound/hysteria.ts b/frontend/src/schemas/protocols/inbound/hysteria.ts index a9f14eed7..9d915f289 100644 --- a/frontend/src/schemas/protocols/inbound/hysteria.ts +++ b/frontend/src/schemas/protocols/inbound/hysteria.ts @@ -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; 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; diff --git a/frontend/src/test/__snapshots__/finalmask.test.ts.snap b/frontend/src/test/__snapshots__/finalmask.test.ts.snap index 7e3ac478a..31204b57f 100644 --- a/frontend/src/test/__snapshots__/finalmask.test.ts.snap +++ b/frontend/src/test/__snapshots__/finalmask.test.ts.snap @@ -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", }, ], diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index 68c6dfce4..8f2cd9538 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -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, } diff --git a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap index 6f1f7f41e..e41bf18c4 100644 --- a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap @@ -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"`; diff --git a/frontend/src/test/__snapshots__/protocols.test.ts.snap b/frontend/src/test/__snapshots__/protocols.test.ts.snap index 2da5d9056..c2eddb307 100644 --- a/frontend/src/test/__snapshots__/protocols.test.ts.snap +++ b/frontend/src/test/__snapshots__/protocols.test.ts.snap @@ -37,7 +37,7 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`] "totalGB": 0, }, ], - "version": 1, + "version": 2, }, } `; diff --git a/frontend/src/test/__snapshots__/stream.test.ts.snap b/frontend/src/test/__snapshots__/stream.test.ts.snap index dde53e4ef..6b9adcfa2 100644 --- a/frontend/src/test/__snapshots__/stream.test.ts.snap +++ b/frontend/src/test/__snapshots__/stream.test.ts.snap @@ -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, }, }, } diff --git a/frontend/src/test/golden/fixtures/finalmask/combined.json b/frontend/src/test/golden/fixtures/finalmask/combined.json index 72052ac64..5a428f2ae 100644 --- a/frontend/src/test/golden/fixtures/finalmask/combined.json +++ b/frontend/src/test/golden/fixtures/finalmask/combined.json @@ -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" + } } } diff --git a/frontend/src/test/golden/fixtures/finalmask/tcp-mask.json b/frontend/src/test/golden/fixtures/finalmask/tcp-mask.json index a535d9394..7d6932698 100644 --- a/frontend/src/test/golden/fixtures/finalmask/tcp-mask.json +++ b/frontend/src/test/golden/fixtures/finalmask/tcp-mask.json @@ -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" + } + ] } } ] diff --git a/frontend/src/test/golden/fixtures/finalmask/udp-mask.json b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json index ef0868ef5..d94966230 100644 --- a/frontend/src/test/golden/fixtures/finalmask/udp-mask.json +++ b/frontend/src/test/golden/fixtures/finalmask/udp-mask.json @@ -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" + ] } } ] diff --git a/frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json b/frontend/src/test/golden/fixtures/inbound-full/hysteria-tls.json similarity index 87% rename from frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json rename to frontend/src/test/golden/fixtures/inbound-full/hysteria-tls.json index 3b00b9669..9244a1a0d 100644 --- a/frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json +++ b/frontend/src/test/golden/fixtures/inbound-full/hysteria-tls.json @@ -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", diff --git a/frontend/src/test/golden/fixtures/inbound/hysteria-basic.json b/frontend/src/test/golden/fixtures/inbound/hysteria-basic.json index 77793d885..e5d833c55 100644 --- a/frontend/src/test/golden/fixtures/inbound/hysteria-basic.json +++ b/frontend/src/test/golden/fixtures/inbound/hysteria-basic.json @@ -1,7 +1,7 @@ { "protocol": "hysteria", "settings": { - "version": 1, + "version": 2, "clients": [ { "auth": "hyst3ria-v1-token-XYZ", diff --git a/frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json b/frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json index 29b1de86d..728b2a849 100644 --- a/frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json +++ b/frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json @@ -9,6 +9,6 @@ "xPaddingKey": "secret-key", "xPaddingHeader": "X-Pad", "xPaddingPlacement": "header", - "xPaddingMethod": "random" + "xPaddingMethod": "tokenish" } } diff --git a/frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json b/frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json index 00a6f06ec..97a4e2744 100644 --- a/frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json +++ b/frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json @@ -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" } } diff --git a/frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json b/frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json index cbaf4a7cd..0e118e62a 100644 --- a/frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json +++ b/frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json @@ -19,7 +19,6 @@ }, "xmux": { "maxConcurrency": "16-32", - "maxConnections": 4, "cMaxReuseTimes": 0, "hMaxRequestTimes": "600-900", "hMaxReusableSecs": "1800-3000", diff --git a/internal/database/model/hysteria_version_test.go b/internal/database/model/hysteria_version_test.go new file mode 100644 index 000000000..a7cc75992 --- /dev/null +++ b/internal/database/model/hysteria_version_test.go @@ -0,0 +1,151 @@ +package model + +import ( + "encoding/json" + "strings" + "testing" +) + +func settingsVersion(t *testing.T, settings string) any { + t.Helper() + var parsed map[string]any + if err := json.Unmarshal([]byte(settings), &parsed); err != nil { + t.Fatalf("unmarshal settings: %v", err) + } + return parsed["version"] +} + +func TestHealHysteriaVersion(t *testing.T) { + tests := []struct { + name string + settings string + wantChanged bool + wantVersion any + }{ + { + name: "legacy v1 row", + settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`, + wantChanged: true, + wantVersion: float64(2), + }, + { + name: "no version at all", + settings: `{"clients":[{"auth":"tok","email":"a@x"}]}`, + wantChanged: true, + wantVersion: float64(2), + }, + { + name: "already v2", + settings: `{"version":2,"clients":[]}`, + wantChanged: false, + wantVersion: float64(2), + }, + { + name: "version as a string", + settings: `{"version":"1","clients":[]}`, + wantChanged: true, + wantVersion: float64(2), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + healed, changed := HealHysteriaVersion(tt.settings) + if changed != tt.wantChanged { + t.Fatalf("changed = %v, want %v", changed, tt.wantChanged) + } + if got := settingsVersion(t, healed); got != tt.wantVersion { + t.Fatalf("version = %#v, want %#v", got, tt.wantVersion) + } + }) + } +} + +func TestHealHysteriaVersionKeepsClients(t *testing.T) { + healed, changed := HealHysteriaVersion(`{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`) + if !changed { + t.Fatal("a v1 row must be healed") + } + if !strings.Contains(healed, `"auth": "tok"`) || !strings.Contains(healed, `"email": "a@x"`) { + t.Fatalf("healing dropped client data: %s", healed) + } +} + +func TestHealHysteriaVersionLeavesUnparsableSettings(t *testing.T) { + const broken = `{"version":1,` + healed, changed := HealHysteriaVersion(broken) + if changed || healed != broken { + t.Fatalf("unparsable settings must be left alone, got changed=%v %q", changed, healed) + } + if healed, changed := HealHysteriaVersion(""); changed || healed != "" { + t.Fatalf("empty settings must be left alone, got changed=%v %q", changed, healed) + } +} + +func TestHealHysteriaStreamVersion(t *testing.T) { + healed, changed := HealHysteriaStreamVersion(`{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`) + if !changed { + t.Fatal("a v1 transport must be healed") + } + var parsed map[string]any + if err := json.Unmarshal([]byte(healed), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + hysteria, _ := parsed["hysteriaSettings"].(map[string]any) + if hysteria["version"] != float64(2) { + t.Fatalf("version = %#v, want 2", hysteria["version"]) + } + if hysteria["udpIdleTimeout"] != float64(60) { + t.Fatalf("healing dropped transport settings: %#v", hysteria) + } +} + +func TestHealHysteriaStreamVersionWithoutHysteriaSettings(t *testing.T) { + const stream = `{"network":"tcp","tcpSettings":{}}` + healed, changed := HealHysteriaStreamVersion(stream) + if changed || healed != stream { + t.Fatalf("a stream without hysteriaSettings must be left alone, got changed=%v %q", changed, healed) + } +} + +// TestGenXrayInboundConfigHealsHysteriaVersion is the regression for a stored +// v1 row: xray-core answers "version != 2" and rejects the whole config, so +// every other inbound on the server stays offline until the row is fixed. +func TestGenXrayInboundConfigHealsHysteriaVersion(t *testing.T) { + in := Inbound{ + Protocol: Hysteria, + Port: 36715, + Listen: "127.0.0.1", + Tag: "in-hysteria", + Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`, + StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`, + } + cfg := in.GenXrayInboundConfig() + + if got := settingsVersion(t, string(cfg.Settings)); got != float64(2) { + t.Fatalf("generated settings.version = %#v, want 2", got) + } + var stream map[string]any + if err := json.Unmarshal(cfg.StreamSettings, &stream); err != nil { + t.Fatalf("unmarshal generated streamSettings: %v", err) + } + hysteria, _ := stream["hysteriaSettings"].(map[string]any) + if hysteria["version"] != float64(2) { + t.Fatalf("generated hysteriaSettings.version = %#v, want 2", hysteria["version"]) + } + + if !strings.Contains(in.Settings, `"version":1`) { + t.Fatal("the stored row must keep its own value; only the generated config is healed") + } +} + +func TestGenXrayInboundConfigLeavesOtherProtocolsAlone(t *testing.T) { + in := Inbound{ + Protocol: VLESS, + Port: 443, + Tag: "in-vless", + Settings: `{"clients":[],"decryption":"none"}`, + } + if got := settingsVersion(t, string(in.GenXrayInboundConfig().Settings)); got != nil { + t.Fatalf("a non-hysteria inbound must not gain a version key, got %#v", got) + } +} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 92510e56e..6ae244607 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -229,6 +229,57 @@ func jsonStringFieldFromRaw(r json.RawMessage) string { return string(trimmed) } +// hysteriaConfigVersion is the only hysteria version xray-core builds. Both +// the protocol settings and the transport settings answer anything else with +// "version != 2", and that error rejects the whole config — every other +// inbound on the server goes down with it, not just the hysteria one. +const hysteriaConfigVersion = 2 + +// HealHysteriaVersion pins a hysteria inbound's settings.version to the +// version xray-core accepts. Rows written before the panel settled on v2, or +// through the API and the raw JSON editor, can still carry the legacy 1 or no +// version at all, either of which stops the core from starting. +func HealHysteriaVersion(settings string) (string, bool) { + return healVersionField(settings, nil) +} + +// HealHysteriaStreamVersion does the same for the transport half, +// streamSettings.hysteriaSettings.version, which xray-core validates +// separately. An absent hysteriaSettings object is left alone. +func HealHysteriaStreamVersion(streamSettings string) (string, bool) { + return healVersionField(streamSettings, []string{"hysteriaSettings"}) +} + +// healVersionField rewrites the "version" key of the object reached by path to +// hysteriaConfigVersion, reporting whether anything changed. A path that does +// not resolve to an object leaves the input untouched. +func healVersionField(raw string, path []string) (string, bool) { + if raw == "" { + return raw, false + } + var parsed map[string]any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return raw, false + } + target := parsed + for _, key := range path { + next, ok := target[key].(map[string]any) + if !ok { + return raw, false + } + target = next + } + if version, ok := target["version"].(float64); ok && version == hysteriaConfigVersion { + return raw, false + } + target["version"] = hysteriaConfigVersion + out, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return raw, false + } + return string(out), true +} + // StripInboundXhttpClientFields removes xHTTP knobs that belong on the // client dialer and subscription share-link extras only. xray-core's XHTTP // inbound listener does not consume them; the panel still stores them on @@ -298,11 +349,20 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { if converted, ok := WireguardClientsToPeers(settings); ok { settings = converted } + case Hysteria: + if healed, ok := HealHysteriaVersion(settings); ok { + settings = healed + } } streamSettings := i.StreamSettings if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok { streamSettings = stripped } + if i.Protocol == Hysteria { + if healed, ok := HealHysteriaStreamVersion(streamSettings); ok { + streamSettings = healed + } + } return &xray.InboundConfig{ Listen: json_util.RawMessage(listen), Port: i.Port, diff --git a/internal/web/service/finalmask_rand_packet_test.go b/internal/web/service/finalmask_rand_packet_test.go new file mode 100644 index 000000000..14a318309 --- /dev/null +++ b/internal/web/service/finalmask_rand_packet_test.go @@ -0,0 +1,160 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func streamWithNoiseItem(t *testing.T, item map[string]any) map[string]any { + t.Helper() + return map[string]any{ + "network": "tcp", + "finalmask": map[string]any{ + "udp": []any{map[string]any{ + "type": "noise", + "settings": map[string]any{ + "reset": "60", + "noise": []any{item}, + }, + }}, + }, + } +} + +func noiseItem(t *testing.T, stream map[string]any) map[string]any { + t.Helper() + finalmask, _ := stream["finalmask"].(map[string]any) + udp, _ := finalmask["udp"].([]any) + mask, _ := udp[0].(map[string]any) + settings, _ := mask["settings"].(map[string]any) + noise, _ := settings["noise"].([]any) + item, _ := noise[0].(map[string]any) + return item +} + +// TestDropEmptyRandPacketsClearsEditorResidue is the regression for the mask +// editor writing packet:[] alongside a rand. xray-core counts the empty array +// as a packet and refuses the config, which keeps every inbound offline. +func TestDropEmptyRandPacketsClearsEditorResidue(t *testing.T) { + stream := streamWithNoiseItem(t, map[string]any{ + "type": "array", + "rand": "1-8192", + "packet": []any{}, + "delay": "5", + }) + + if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 { + t.Fatalf("cleared = %d, want 1", cleared) + } + item := noiseItem(t, stream) + if _, present := item["packet"]; present { + t.Fatalf("packet survived: %#v", item) + } + if item["rand"] != "1-8192" || item["delay"] != "5" { + t.Fatalf("healing changed the mask: %#v", item) + } +} + +func TestDropEmptyRandPacketsLeavesRealPacketsAlone(t *testing.T) { + tests := []struct { + name string + item map[string]any + }{ + {"packet without a rand", map[string]any{"type": "array", "packet": []any{1.0, 2.0}, "rand": 0.0}}, + {"empty packet without a rand", map[string]any{"type": "array", "packet": []any{}}}, + {"empty packet with a zero rand", map[string]any{"type": "array", "packet": []any{}, "rand": 0.0}}, + {"empty packet with a zero range", map[string]any{"type": "array", "packet": []any{}, "rand": "0-0"}}, + {"string packet", map[string]any{"type": "str", "packet": "ping", "rand": "1-10"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stream := streamWithNoiseItem(t, tt.item) + if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 { + t.Fatalf("cleared = %d, want the item left alone", cleared) + } + if _, present := noiseItem(t, stream)["packet"]; !present { + t.Fatal("packet was dropped") + } + }) + } +} + +// TestDropEmptyRandPacketsReachesNestedItems covers header-custom, whose items +// sit two arrays deep and are subject to the same exclusive-kind rule. +func TestDropEmptyRandPacketsReachesNestedItems(t *testing.T) { + stream := map[string]any{ + "finalmask": map[string]any{ + "tcp": []any{map[string]any{ + "type": "header-custom", + "settings": map[string]any{ + "clients": []any{[]any{map[string]any{"type": "array", "rand": 64.0, "packet": []any{}}}}, + "servers": []any{[]any{map[string]any{"type": "array", "rand": 32.0, "packet": []any{}}}}, + }, + }}, + }, + } + if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 2 { + t.Fatalf("cleared = %d, want 2", cleared) + } +} + +func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) { + stream := map[string]any{"network": "tcp"} + if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 { + t.Fatalf("cleared = %d, want 0", cleared) + } +} + +// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray +// refuses outright must build once the panel has healed them. +func TestHealedConfigsBuildInXray(t *testing.T) { + t.Run("hysteria v1 row", func(t *testing.T) { + in := model.Inbound{ + Protocol: model.Hysteria, + Port: 36715, + Listen: "127.0.0.1", + Tag: "in-hysteria", + Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`, + StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`, + } + + raw, err := json.Marshal(in.GenXrayInboundConfig()) + if err != nil { + t.Fatalf("marshal generated inbound: %v", err) + } + var healed map[string]any + if err := json.Unmarshal(raw, &healed); err != nil { + t.Fatalf("decode generated inbound: %v", err) + } + assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed)) + + var unhealed map[string]any + if err := json.Unmarshal([]byte(`{ + "tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria", + "settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil { + t.Fatalf("decode raw inbound: %v", err) + } + if err := buildGoldenInbound(t, unhealed); err == nil { + t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable") + } + }) + + t.Run("noise item with an empty packet", func(t *testing.T) { + item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"} + stream := streamWithNoiseItem(t, item) + inbound := map[string]any{ + "tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless", + "settings": map[string]any{"clients": []any{}, "decryption": "none"}, + "streamSettings": stream, + } + if err := buildGoldenInbound(t, inbound); err == nil { + t.Fatal("xray-core is expected to refuse a packet and a rand on one item") + } + + dropEmptyRandPackets(stream["finalmask"]) + inbound["streamSettings"] = stream + assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound)) + }) +} diff --git a/internal/web/service/golden_fixtures_xray_test.go b/internal/web/service/golden_fixtures_xray_test.go new file mode 100644 index 000000000..77ce6c1f9 --- /dev/null +++ b/internal/web/service/golden_fixtures_xray_test.go @@ -0,0 +1,351 @@ +package service + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + + "github.com/xtls/xray-core/infra/conf" +) + +// The frontend's golden fixtures are the panel's model of an xray config: the +// Zod snapshots pin what the forms parse and emit. Parsing proves the panel +// agrees with itself, not that xray-core would accept the result, so every +// fixture is also built here through the very 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. A fixture xray-core refuses is a config the panel would let an +// admin save and then fail to start the core with, taking every inbound down. +// +// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by +// xray, so xray-core has no config id for it. + +func goldenFixtureDir(t *testing.T, category string) string { + t.Helper() + dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category)) + if err != nil { + t.Fatalf("resolve fixture dir: %v", err) + } + return dir +} + +// goldenFixtures returns every fixture in a category as name -> decoded object. +func goldenFixtures(t *testing.T, category string) map[string]map[string]any { + t.Helper() + dir := goldenFixtureDir(t, category) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + out := make(map[string]map[string]any) + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if filepath.Ext(entry.Name()) != ".json" { + continue + } + names = append(names, entry.Name()) + } + sort.Strings(names) + if len(names) == 0 { + t.Fatalf("no fixtures under %s", dir) + } + for _, name := range names { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal %s: %v", name, err) + } + out[strings.TrimSuffix(name, ".json")] = obj + } + return out +} + +// writeTestCertificate writes a self-signed certificate and key, returning both +// paths. The TLS fixtures point certificateFile/keyFile at deployment paths +// that do not exist here, and xray-core reads them while building. +func writeTestCertificate(t *testing.T) (certPath, keyPath string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "golden-fixture.test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + DNSNames: []string{"golden-fixture.test"}, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + + dir := t.TempDir() + certPath = filepath.Join(dir, "fixture.crt") + keyPath = filepath.Join(dir, "fixture.key") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatalf("write certificate: %v", err) + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + return certPath, keyPath +} + +// repointCertificateFiles rewrites every certificateFile/keyFile reference to +// the generated pair, so a fixture is judged on its shape rather than on paths +// that only exist on a deployed server. +func repointCertificateFiles(node any, certPath, keyPath string) { + switch value := node.(type) { + case map[string]any: + for key, child := range value { + switch key { + case "certificateFile": + if _, ok := child.(string); ok { + value[key] = certPath + continue + } + case "keyFile": + if _, ok := child.(string); ok { + value[key] = keyPath + continue + } + } + repointCertificateFiles(child, certPath, keyPath) + } + case []any: + for _, child := range value { + repointCertificateFiles(child, certPath, keyPath) + } + } +} + +// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming +// geoip:/geosite: need the dat files the panel ships next to the xray binary; +// where they are absent the loader fails on the missing file rather than on the +// fixture, so those are skipped instead of reported as broken. +func assertXrayAccepts(t *testing.T, subject string, err error) { + t.Helper() + if err == nil { + return + } + if isMissingGeoAssetErr(err) { + t.Skipf("geo data files not available, cannot judge %s: %v", subject, err) + } + t.Fatalf("xray-core refuses %s: %v", subject, err) +} + +func buildGoldenInbound(t *testing.T, inbound map[string]any) error { + t.Helper() + certPath, keyPath := writeTestCertificate(t) + repointCertificateFiles(inbound, certPath, keyPath) + + raw, err := json.Marshal(inbound) + if err != nil { + t.Fatalf("marshal inbound: %v", err) + } + detour := new(conf.InboundDetourConfig) + if err := json.Unmarshal(raw, detour); err != nil { + return err + } + _, err = detour.Build() + return err +} + +// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal +// inbound and builds it. +func TestGoldenInboundFixturesBuildInXray(t *testing.T) { + for name, fixture := range goldenFixtures(t, "inbound") { + if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) { + continue + } + t.Run(name, func(t *testing.T) { + inbound := map[string]any{ + "tag": "golden-in", + "listen": "127.0.0.1", + "port": 8443, + "protocol": fixture["protocol"], + "settings": fixture["settings"], + } + assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound)) + }) + } +} + +// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound +// model through GenXrayInboundConfig, the conversion both the full-config and +// the live AddInbound paths use, and builds the result. +func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) { + for name, fixture := range goldenFixtures(t, "inbound-full") { + protocol, _ := fixture["protocol"].(string) + if protocol == string(model.MTProto) { + continue + } + t.Run(name, func(t *testing.T) { + section := func(key string) string { + value, ok := fixture[key] + if !ok || value == nil { + return "" + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", key, err) + } + return string(raw) + } + port := 0 + if p, ok := fixture["port"].(float64); ok { + port = int(p) + } + listen, _ := fixture["listen"].(string) + tag, _ := fixture["tag"].(string) + + ib := &model.Inbound{ + Protocol: model.Protocol(protocol), + Port: port, + Listen: listen, + Tag: tag, + Settings: section("settings"), + StreamSettings: section("streamSettings"), + Sniffing: section("sniffing"), + } + raw, err := json.Marshal(ib.GenXrayInboundConfig()) + if err != nil { + t.Fatalf("marshal generated inbound: %v", err) + } + var generated map[string]any + if err := json.Unmarshal(raw, &generated); err != nil { + t.Fatalf("decode generated inbound: %v", err) + } + assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated)) + }) + } +} + +// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole +// stream sections, the security block, sockopt and finalmask — to an inbound. +func TestGoldenStreamFixturesBuildInXray(t *testing.T) { + for _, category := range []string{"stream", "security", "sockopt", "finalmask"} { + for name, fixture := range goldenFixtures(t, category) { + t.Run(category+"/"+name, func(t *testing.T) { + stream := map[string]any{"network": "tcp"} + switch category { + case "stream": + stream = fixture + case "security": + for key, value := range fixture { + stream[key] = value + } + case "sockopt": + stream["sockopt"] = fixture + case "finalmask": + stream["finalmask"] = fixture + } + inbound := map[string]any{ + "tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless", + "settings": map[string]any{ + "clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}}, + "decryption": "none", + }, + "streamSettings": stream, + } + assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound)) + }) + } + } +} + +func buildGoldenRouting(routing map[string]any) error { + raw, err := json.Marshal(routing) + if err != nil { + return err + } + router := new(conf.RouterConfig) + if err := json.Unmarshal(raw, router); err != nil { + return err + } + _, err = router.Build() + return err +} + +// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures +// through the router config ApplyRoutingConfig hands to the running core. +func TestGoldenRoutingFixturesBuildInXray(t *testing.T) { + for name, rule := range goldenFixtures(t, "rule") { + t.Run("rule/"+name, func(t *testing.T) { + assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{ + "domainStrategy": "AsIs", + "rules": []any{rule}, + "balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}}, + })) + }) + } + + for name, balancer := range goldenFixtures(t, "balancer") { + t.Run("balancer/"+name, func(t *testing.T) { + assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{ + "domainStrategy": "AsIs", + "balancers": []any{balancer}, + "rules": []any{map[string]any{ + "type": "field", "port": "443", "balancerTag": balancer["tag"], + }}, + })) + }) + } +} + +// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server +// fixture inside one. +func TestGoldenDNSFixturesBuildInXray(t *testing.T) { + build := func(dns map[string]any) error { + raw, err := json.Marshal(dns) + if err != nil { + return err + } + dnsConf := new(conf.DNSConfig) + if err := json.Unmarshal(raw, dnsConf); err != nil { + return err + } + _, err = dnsConf.Build() + return err + } + + for name, fixture := range goldenFixtures(t, "dns") { + t.Run("dns/"+name, func(t *testing.T) { + assertXrayAccepts(t, "this dns section", build(fixture)) + }) + } + for name, server := range goldenFixtures(t, "dns-server") { + t.Run("dns-server/"+name, func(t *testing.T) { + assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}})) + }) + } +} + +func isMissingGeoAssetErr(err error) bool { + msg := err.Error() + return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat") +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 82b345ce4..fe6edbcc7 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -713,6 +713,52 @@ func stripIncompleteXmcMasks(stream map[string]any) int { return dropped } +// dropEmptyRandPackets removes the leftover empty "packet" from finalmask +// items that also carry a rand, and reports how many it cleared. +// +// xray-core treats even an empty array as a packet, and every item kind is +// exclusive: noise refuses "len(item.Packet) > 0 && item.Rand.To > 0" and +// header-custom refuses "exactly one item kind must be set". Either error +// fails the whole config build, so one such item keeps every inbound offline. +// The panel's mask editor wrote that pair whenever an item was switched to the +// rand-driven array kind, so stored rows carry it; clearing an empty packet +// changes nothing about the mask the admin configured. +func dropEmptyRandPackets(node any) int { + switch value := node.(type) { + case map[string]any: + cleared := 0 + if packet, ok := value["packet"].([]any); ok && len(packet) == 0 && randIsSet(value["rand"]) { + delete(value, "packet") + cleared++ + } + for _, child := range value { + cleared += dropEmptyRandPackets(child) + } + return cleared + case []any: + cleared := 0 + for _, child := range value { + cleared += dropEmptyRandPackets(child) + } + return cleared + default: + return 0 + } +} + +// randIsSet reports whether a finalmask item's rand selects a random packet. +// It is a number on header-custom items and a dash-range string on noise ones. +func randIsSet(value any) bool { + switch rand := value.(type) { + case float64: + return rand > 0 + case string: + return rand != "" && rand != "0" && rand != "0-0" + default: + return false + } +} + // 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 diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 9d8330459..8ec3972f1 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -281,6 +281,8 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { delete(stream, "finalmask") } + dropEmptyRandPackets(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) }