fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target (#4988)

* fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target

Strip mode-specific XHTTP fields for stream-one, reset harmful sockopt defaults
to 0, split server/client Reality fields on save, validate target host:port in
the inbound form, and expose Happy Eyeballs for the direct freedom outbound.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(panel): keep REALITY public key on the wire, guard freedom noises

The REALITY server/client wire split deleted realitySettings.settings on save, but the panel stores the REALITY public key there and every share-link / subscription generator reads it back from that path (frontend inbound-link.ts, Go subService/subJsonService/subClashService). Stripping it produced empty pbk= links, breaking client connectivity after save+reload.

Revert the reality normalization (drop normalizeRealityForWire and the key sets), restore the inbound REALITY form fields (uTLS, spiderX, publicKey, mldsa65Verify) while keeping the new validated target field, and restore the mldsa65Verify clear handler.

Also guard freedomToWire against undefined noises/finalRules (same defensive treatment as the existing fragment guard, issue #4686) which the new freedom-outbound test surfaced as a crash. Tests now assert the public key is preserved.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
nima1024m
2026-06-06 04:10:32 +03:30
committed by GitHub
parent e409bc305d
commit 6ed6f57b5c
11 changed files with 616 additions and 23 deletions
@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
"mark": 0,
"penetrate": false,
"tcpFastOpen": false,
"tcpKeepAliveIdle": 45,
"tcpKeepAliveInterval": 45,
"tcpMaxSeg": 1440,
"tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 0,
"tcpMaxSeg": 0,
"tcpMptcp": false,
"tcpUserTimeout": 10000,
"tcpWindowClamp": 600,
"tcpUserTimeout": 0,
"tcpWindowClamp": 0,
"tcpcongestion": "bbr",
"tproxy": "off",
"trustedXForwardedFor": [],
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
"mark": 255,
"penetrate": true,
"tcpFastOpen": false,
"tcpKeepAliveIdle": 45,
"tcpKeepAliveInterval": 45,
"tcpMaxSeg": 1440,
"tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 0,
"tcpMaxSeg": 0,
"tcpMptcp": false,
"tcpUserTimeout": 10000,
"tcpWindowClamp": 600,
"tcpUserTimeout": 0,
"tcpWindowClamp": 0,
"tcpcongestion": "bbr",
"tproxy": "tproxy",
"trustedXForwardedFor": [],
@@ -0,0 +1,243 @@
/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
import {
normalizeSockoptForWire,
normalizeStreamSettingsForWire,
normalizeXhttpForWire,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
describe('validateRealityTarget', () => {
it('accepts host:port and bare port', () => {
expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
expect(validateRealityTarget('443')).toBeUndefined();
});
it('rejects host without port', () => {
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
});
});
describe('normalizeXhttpForWire stream-one', () => {
it('drops packet-up and stream-up-only fields on inbound', () => {
const out = normalizeXhttpForWire({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
scMaxBufferedPosts: 30,
scStreamUpServerSecs: '20-80',
enableXmux: false,
headers: {},
}, 'inbound');
expect(out).toMatchObject({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
});
expect(out).not.toHaveProperty('scMaxEachPostBytes');
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
expect(out).not.toHaveProperty('scMaxBufferedPosts');
expect(out).not.toHaveProperty('scStreamUpServerSecs');
expect(out).not.toHaveProperty('enableXmux');
expect(out).not.toHaveProperty('headers');
});
it('keeps xmux on outbound stream-one', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xPaddingBytes: '100-1000',
xmux: { maxConcurrency: '16-32' },
scMaxEachPostBytes: '1000000',
}, 'outbound');
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
expect(out).not.toHaveProperty('scMaxEachPostBytes');
});
});
describe('normalizeSockoptForWire', () => {
it('omits doc-example defaults that throttle throughput', () => {
const out = normalizeSockoptForWire({
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
domainStrategy: 'AsIs',
tproxy: 'off',
mark: 0,
});
expect(out).toEqual({
tcpFastOpen: true,
tcpcongestion: 'bbr',
});
});
it('preserves happyEyeballs on freedom-style outbound', () => {
const out = normalizeSockoptForWire({
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
});
expect(out?.happyEyeballs).toMatchObject({
tryDelayMs: 150,
prioritizeIPv6: true,
});
expect(out?.domainStrategy).toBe('UseIP');
});
});
describe('normalizeStreamSettingsForWire reality', () => {
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['abcd'],
settings: {
publicKey: 'pub',
fingerprint: 'chrome',
spiderX: '/',
},
},
}, { side: 'inbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.target).toBe('play.google.com:443');
expect(reality.privateKey).toBe('priv');
const settings = reality.settings as Record<string, unknown>;
expect(settings.publicKey).toBe('pub');
expect(settings.spiderX).toBe('/');
});
it('passes client realitySettings through unchanged on outbound', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
publicKey: 'pub',
fingerprint: 'chrome',
serverName: 'play.google.com',
shortId: 'abcd',
spiderX: '/x',
},
}, { side: 'outbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.publicKey).toBe('pub');
expect(reality.serverName).toBe('play.google.com');
expect(reality.spiderX).toBe('/x');
});
});
describe('inbound formValuesToWirePayload integration', () => {
it('emits lean stream-one xhttp + sockopt on save', () => {
const values = {
remark: 't',
enable: true,
port: 443,
listen: '0.0.0.0',
tag: 'in-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'vless',
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
streamSettings: {
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['44003d86dc1e'],
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
},
xhttpSettings: {
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
enableXmux: false,
},
sockopt: {
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
},
},
} as InboundFormValues;
const payload = formValuesToWirePayload(values);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
const sockopt = stream.sockopt as Record<string, unknown>;
const reality = stream.realitySettings as Record<string, unknown>;
expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
expect(sockopt).not.toHaveProperty('tcpWindowClamp');
expect(sockopt.tcpFastOpen).toBe(true);
const realitySettings = reality.settings as Record<string, unknown>;
expect(realitySettings.publicKey).toBe('pub');
});
});
describe('freedom outbound sockopt wire payload', () => {
it('preserves happyEyeballs on direct freedom outbound', () => {
const wire = outboundToWire({
protocol: 'freedom',
tag: 'direct',
settings: { domainStrategy: 'UseIP' },
streamSettings: {
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
},
},
} as Parameters<typeof outboundToWire>[0]);
expect(wire.streamSettings).toMatchObject({
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
},
},
});
});
});