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

Bump xtls/xray-core to 37ceb8b4b6 (v26.9.8) and the three binary pins
(DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted
symbols; the impact is entirely on the JSON config surface.

Outbound "proxySettings" is now refused by the config loader (moved to
streamSettings.sockopt.dialerProxy) and a freedom outbound rejects
sockopt.addressPortStrategy. Either key in a stored template would keep
the core from starting after the upgrade, so a new OutboundRemovedKeysFix
seeder rewrites xrayTemplateConfig once: proxySettings.tag becomes
sockopt.dialerProxy (an existing dialerProxy wins) and addressPortStrategy
is dropped from freedom outbounds. Template saves and outbound
subscriptions already run through the vendored loader, so the new
refusals surface there with the core's own message.

REALITY no longer applies a built-in minClientVer (26.3.27) when the
field is empty. The form placeholder and the min/max hints in all 13
locales now say that empty means no minimum.

New upstream keys the Zod schemas would otherwise strip, with form
support where a sibling field already had it:
- blackhole response type "custom" with base64 customResponseData
- realm finalmask ipMode (dual/v4/v6) and portMapping (UPnP / NAT-PMP)
- quicParams brutalDisableLossCompensation, disableChromeParrot,
  disableGSO, disableStatelessReset
- hysteria masquerade proxy xForwarded
- wireguard outbound remoteDNS
- routing rule localOS

freedom.domainStrategy is only deprecated upstream (auto-migrated to
sockopt.domainStrategy with a warning) and is left untouched.
This commit is contained in:
Sanaei
2026-09-08 13:49:32 +02:00
parent a5e68f410f
commit 2ec6c73613
36 changed files with 554 additions and 61 deletions
@@ -994,6 +994,36 @@ function UdpMaskItem({
placeholder="host:port"
/>
</Form.Item>
<Form.Item label="IP Mode" name={[fieldName, 'settings', 'ipMode']}>
<Select
allowClear
placeholder="dual"
options={[
{ value: 'dual', label: 'Dual' },
{ value: 'v4', label: 'IPv4' },
{ value: 'v6', label: 'IPv6' },
]}
/>
</Form.Item>
<Form.Item
label="Port Mapping (UPnP / NAT-PMP)"
name={[fieldName, 'settings', 'portMapping', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="Mapping Timeout (s)"
name={[fieldName, 'settings', 'portMapping', 'timeout']}
>
<InputNumber min={0} placeholder="10 = default" />
</Form.Item>
<Form.Item
label="Mapping Lifetime (s)"
name={[fieldName, 'settings', 'portMapping', 'lifetime']}
>
<InputNumber min={0} placeholder="600 = default" />
</Form.Item>
<Divider plain style={{ margin: '8px 0' }}>
TLS (optional)
</Divider>
@@ -1443,6 +1473,13 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
<Form.Item label="Brutal Down" name={[...base, 'brutalDown']}>
<Input placeholder="e.g. 100 mbps" />
</Form.Item>
<Form.Item
label="Brutal Disable Loss Comp"
name={[...base, 'brutalDisableLossCompensation']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
@@ -1478,6 +1515,23 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
>
<Switch />
</Form.Item>
<Form.Item
label="Disable Chrome Parrot"
name={[...base, 'disableChromeParrot']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="Disable GSO" name={[...base, 'disableGSO']} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label="Disable Stateless Reset"
name={[...base, 'disableStatelessReset']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="Max Incoming Streams" name={[...base, 'maxIncomingStreams']}>
<InputNumber min={8} placeholder="1024 = default" />
+21 -4
View File
@@ -6,6 +6,7 @@ import type { Sniffing, SniffingDest } from '@/schemas/primitives';
import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
import type {
BlackholeOutboundFormSettings,
DnsOutboundFormSettings,
DnsRuleForm,
FreedomFinalRuleForm,
@@ -243,6 +244,9 @@ function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
return (allowed.includes(s) ? s : '') as WireguardOutboundFormSettings['domainStrategy'];
})(),
reserved: reservedArr.join(','),
remoteDNS: asArray(raw.remoteDNS)
.map((x) => asString(x))
.join(','),
peers,
noKernelTun: asBool(raw.noKernelTun),
};
@@ -322,10 +326,13 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
};
}
function blackholeFromWire(raw: Raw) {
function blackholeFromWire(raw: Raw): BlackholeOutboundFormSettings {
const response = asObject(raw.response);
const t = asString(response.type);
return { type: (t === 'none' || t === 'http' ? t : '') as '' | 'none' | 'http' };
return {
type: t === 'none' || t === 'http' || t === 'custom' ? t : '',
customResponseData: asString(response.customResponseData),
};
}
function dnsRuleFromWire(raw: unknown): DnsRuleForm {
@@ -585,6 +592,12 @@ function wireguardToWire(s: WireguardOutboundFormSettings) {
.map((x) => Number(x.trim()))
.filter((n) => Number.isFinite(n))
: undefined,
remoteDNS: s.remoteDNS
? s.remoteDNS
.split(',')
.map((x) => x.trim())
.filter(Boolean)
: undefined,
peers: s.peers.map((p) => ({
publicKey: p.publicKey,
preSharedKey: p.psk.length > 0 ? p.psk : undefined,
@@ -631,8 +644,12 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
};
}
function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
return { response: s.type ? { type: s.type } : undefined };
function blackholeToWire(s: BlackholeOutboundFormSettings) {
if (!s.type) return { response: undefined };
if (s.type === 'custom') {
return { response: { type: s.type, customResponseData: s.customResponseData } };
}
return { response: { type: s.type } };
}
function dnsRuleToWire(r: DnsRuleForm) {
@@ -78,6 +78,13 @@ export default function HysteriaFields() {
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.xForwarded')}
name={[...MASQ_PATH, 'xForwarded']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.skipTlsVerify')}
name={[...MASQ_PATH, 'insecure']}
@@ -189,7 +189,7 @@ export default function RealityForm({
},
}}
>
<Input placeholder="26.3.27" />
<Input placeholder="x.y.z" />
</FormField>
<FormField
name={['streamSettings', 'realitySettings', 'maxClientVer']}
@@ -1,19 +1,33 @@
import { useTranslation } from 'react-i18next';
import { Select } from 'antd';
import { Input, Select } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
export default function BlackholeFields() {
const { t } = useTranslation();
const { control } = useFormContext();
const type = useWatch({ control, name: 'settings.type' }) as string | undefined;
return (
<FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<Select
options={[
{ value: '', label: '(empty)' },
{ value: 'none', label: 'none' },
{ value: 'http', label: 'http' },
]}
/>
</FormField>
<>
<FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<Select
options={[
{ value: '', label: '(empty)' },
{ value: 'none', label: 'none' },
{ value: 'http', label: 'http' },
{ value: 'custom', label: 'custom' },
]}
/>
</FormField>
{type === 'custom' && (
<FormField
label={t('pages.xray.outboundForm.customResponseData')}
name={['settings', 'customResponseData']}
>
<Input.TextArea rows={3} placeholder="SFRUUC8xLjEgNDAzIEZvcmJpZGRlbg0KDQo=" />
</FormField>
)}
</>
);
}
@@ -99,6 +99,9 @@ export default function WireguardFields() {
<FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
<Input placeholder="comma-separated bytes, e.g. 1,2,3" />
</FormField>
<FormField label={t('pages.xray.outboundForm.remoteDNS')} name={['settings', 'remoteDNS']}>
<Input placeholder="comma-separated, e.g. 1.1.1.1,2606:4700:4700::1111" />
</FormField>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
@@ -79,6 +79,13 @@ export default function HysteriaForm() {
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.xForwarded')}
name={[...MASQ, 'xForwarded']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.skipTlsVerify')}
name={[...MASQ, 'insecure']}
@@ -106,6 +106,7 @@ export const WireguardOutboundFormSettingsSchema = z.object({
address: z.string().default(''),
domainStrategy: z.union([WireguardDomainStrategySchema, z.literal('')]).default(''),
reserved: z.string().default(''),
remoteDNS: z.string().default(''),
peers: z.array(WireguardOutboundFormPeerSchema).default([]),
noKernelTun: z.boolean().default(false),
});
@@ -152,6 +153,7 @@ export type FreedomOutboundFormSettings = z.infer<typeof FreedomOutboundFormSett
// adapter wraps as { response: { type } } on the wire and omits when empty.
export const BlackholeOutboundFormSettingsSchema = z.object({
type: z.union([BlackholeResponseTypeSchema, z.literal('')]).default(''),
customResponseData: z.string().default(''),
});
export type BlackholeOutboundFormSettings = z.infer<typeof BlackholeOutboundFormSettingsSchema>;
@@ -1,13 +1,13 @@
import { z } from 'zod';
export const BlackholeResponseTypeSchema = z.enum(['none', 'http']);
export const BlackholeResponseTypeSchema = z.enum(['none', 'http', 'custom']);
export type BlackholeResponseType = z.infer<typeof BlackholeResponseTypeSchema>;
// Blackhole drops traffic. `response.type` is the only knob — when set, Xray
// returns the canned 403 HTTP response before closing; when omitted it
// silently drops. The panel stores it as { response: { type } } or omits the
// whole `response` key when type is empty.
// `response.type` picks Xray's reply before closing: none (silent), http
// (canned 403) or custom (base64 customResponseData). Omitted when empty.
export const BlackholeOutboundSettingsSchema = z.object({
response: z.object({ type: BlackholeResponseTypeSchema }).optional(),
response: z
.object({ type: BlackholeResponseTypeSchema, customResponseData: z.string().optional() })
.optional(),
});
export type BlackholeOutboundSettings = z.infer<typeof BlackholeOutboundSettingsSchema>;
@@ -61,6 +61,7 @@ export const QuicParamsSchema = z.object({
debug: z.boolean().optional(),
brutalUp: z.string().optional(),
brutalDown: z.string().optional(),
brutalDisableLossCompensation: z.boolean().optional(),
udpHop: QuicUdpHopSchema.optional(),
initStreamReceiveWindow: z.number().int().min(0).optional(),
maxStreamReceiveWindow: z.number().int().min(0).optional(),
@@ -69,7 +70,10 @@ export const QuicParamsSchema = z.object({
maxIdleTimeout: z.number().int().min(4).max(120).optional(),
keepAlivePeriod: z.number().int().min(2).max(60).optional(),
disablePathMTUDiscovery: z.boolean().optional(),
disableChromeParrot: z.boolean().optional(),
disableGSO: z.boolean().optional(),
maxIncomingStreams: z.number().int().min(8).optional(),
disableStatelessReset: z.boolean().optional(),
});
export type QuicParams = z.infer<typeof QuicParamsSchema>;
@@ -15,6 +15,7 @@ export const HysteriaMasqueradeSchema = z.object({
dir: z.string().default(''),
url: z.string().default(''),
rewriteHost: z.boolean().default(false),
xForwarded: z.boolean().optional(),
insecure: z.boolean().default(false),
content: z.string().default(''),
headers: z.record(z.string(), z.string()).default({}),
+1
View File
@@ -30,6 +30,7 @@ export const RuleObjectSchema = z.object({
protocol: z.array(z.string()).optional(),
attrs: z.record(z.string(), z.string()).optional(),
process: z.array(z.string()).optional(),
localOS: z.array(z.string()).optional(),
outboundTag: z.string().optional(),
balancerTag: z.string().optional(),
ruleTag: z.string().optional(),
@@ -62,6 +62,23 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params-flags byte-stably 1`] = `
{
"quicParams": {
"brutalDisableLossCompensation": true,
"brutalDown": "100 mbps",
"brutalUp": "60 mbps",
"congestion": "brutal",
"disableChromeParrot": true,
"disableGSO": true,
"disablePathMTUDiscovery": false,
"disableStatelessReset": true,
},
"tcp": [],
"udp": [],
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses realm-tls byte-stably 1`] = `
{
"tcp": [],
@@ -74,6 +74,17 @@ exports[`RuleObjectSchema fixtures > parses full byte-stably 1`] = `
}
`;
exports[`RuleObjectSchema fixtures > parses local-os byte-stably 1`] = `
{
"localOS": [
"linux",
"darwin",
],
"outboundTag": "direct",
"type": "field",
}
`;
exports[`RuleObjectSchema fixtures > parses minimal byte-stably 1`] = `
{
"outboundTag": "direct",
@@ -0,0 +1,12 @@
{
"quicParams": {
"congestion": "brutal",
"brutalUp": "60 mbps",
"brutalDown": "100 mbps",
"brutalDisableLossCompensation": true,
"disablePathMTUDiscovery": false,
"disableChromeParrot": true,
"disableGSO": true,
"disableStatelessReset": true
}
}
@@ -0,0 +1,5 @@
{
"type": "field",
"localOS": ["linux", "darwin"],
"outboundTag": "direct"
}
@@ -264,6 +264,54 @@ describe('outbound-form-adapter: round-trip', () => {
expect(withType.settings).toEqual({ response: { type: 'http' } });
});
it('blackhole carries customResponseData only for the custom response type', () => {
const custom = formValuesToWirePayload(
rawOutboundToFormValues({
protocol: 'blackhole',
settings: { response: { type: 'custom', customResponseData: 'SFRUUC8xLjEgNDAz' } },
}),
);
expect(custom.settings).toEqual({
response: { type: 'custom', customResponseData: 'SFRUUC8xLjEgNDAz' },
});
const http = formValuesToWirePayload(
rawOutboundToFormValues({
protocol: 'blackhole',
settings: { response: { type: 'http', customResponseData: 'ignored' } },
}),
);
expect(http.settings).toEqual({ response: { type: 'http' } });
});
it('wireguard csv-joins remoteDNS on read and splits it on write', () => {
const wire = {
protocol: 'wireguard',
settings: {
secretKey: 'YFVmTVCBsLxXJCe4i+jK8PgD3S6vUqfZ4Zl0JVNDfHA=',
remoteDNS: ['1.1.1.1', '2606:4700:4700::1111'],
peers: [{ publicKey: 'pk', endpoint: 'wg.example.com:51820' }],
},
};
const form = rawOutboundToFormValues(wire);
if (form.protocol === 'wireguard') {
expect(form.settings.remoteDNS).toBe('1.1.1.1,2606:4700:4700::1111');
}
const back = formValuesToWirePayload(form);
expect((back.settings as { remoteDNS?: string[] }).remoteDNS).toEqual([
'1.1.1.1',
'2606:4700:4700::1111',
]);
const unset = formValuesToWirePayload(
rawOutboundToFormValues({
protocol: 'wireguard',
settings: { secretKey: wire.settings.secretKey, peers: wire.settings.peers },
}),
);
expect((unset.settings as { remoteDNS?: string[] }).remoteDNS).toBeUndefined();
});
it('dns rules normalize qType numeric strings, split domains, carry rCode', () => {
const wire = {
protocol: 'dns',