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

Bump xtls/xray-core to 1b1647cd9ddf (v26.10.21) and move the three
binary pins (DockerInit.sh, both release.yml fetches) in lockstep. The
v26.10.10 release was pulled upstream, so the old pins already 404.

XDRIVE (#7182): the core now runs XDRIVE only for VLESS inside VLESS
encryption, with no flow, and an outbound only over Mux; it refuses the
whole config otherwise. The panel offers XDRIVE only for VLESS, refuses
to save an inbound without decryption, never emits a client flow on an
XDRIVE stream (config, JSON subscription, forms), and turns Mux on when
an outbound picks XDRIVE. An inbound or template outbound stored before
the upgrade that the core would refuse is left out of the generated
config with a warning, so it no longer stops every other inbound. The
service "Google Drive" is now "google-drive" (old name read as a
deprecated alias); saves, the schema and both form adapters store the
new name. XDRIVE and MASQUE gain a client xmux; both schemas keep it
instead of stripping it on an inbound save.

Lua scripts (#7124, #7136): the core refuses a routing script beside
rules or domainStrategy, and a dns script beside the fallback/parallel
switches or a domain-scoped server. The panel always keeps its api
routing rule, so a template save now refuses any routing script and a
dns script in a refused combination, and a stored one is dropped from
the generated config so the core still starts. Noise masks gain a Lua
script that the loader compiles inside Build: the in-process build
(template save check, hot add) now resolves a relative script the way
the xray child does, beside the xray binary, instead of failing it. The
mask editor gets a Script field that replaces the noise list.

XDNS (#7140): a resolver takes https:// DoH addresses with worker,
tlsConfig and quicConfig. The mask editor registered only addrs, so a
save dropped the rest; it now edits all four.

Finalmask filter (#7206): a new TCP/UDP mask screens an inbound's source
IPs. The inbound mask editor offers it with its allow and block lists;
it is a no-op on a dialer and an older client core refuses the type, so
the share link and JSON subscription drop it like the Go link already
does.
This commit is contained in:
MHSanaei
2026-10-22 17:10:11 +02:00
parent a8d0700bf2
commit 61b129d5b3
43 changed files with 1055 additions and 95 deletions
@@ -36,8 +36,8 @@ export interface FinalMaskFormProps {
// network/protocol. Used by the global sub-JSON finalmask editor where
// the masks apply to every stream rather than one specific transport.
showAll?: boolean;
// udphop runs only on the dialing side (the core refuses it on a listener), so a server
// keeps the advertised hop range in quicParams.udpHop and a client gets the mask.
// udphop runs only on the dialing side (the core refuses it on a listener), so a server keeps
// the advertised hop range in quicParams.udpHop; filter is the listener-only counterpart.
side?: 'server' | 'client';
}
@@ -332,7 +332,7 @@ export default function FinalMaskForm({
return (
<>
{showTcp && <TcpMasksList base={base} form={form} />}
{showTcp && <TcpMasksList base={base} form={form} allowFilter={side === 'server'} />}
{showUdp && (
<UdpMasksList
base={base}
@@ -341,6 +341,7 @@ export default function FinalMaskForm({
isWireguard={isWireguard}
network={network}
allowUdpHop={side === 'client'}
allowFilter={side === 'server'}
/>
)}
{showQuic && (
@@ -366,7 +367,15 @@ export default function FinalMaskForm({
);
}
function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
function TcpMasksList({
base,
form,
allowFilter,
}: {
base: (string | number)[];
form: FormInstance;
allowFilter: boolean;
}) {
const { t } = useTranslation();
return (
<Form.List name={[...base, 'tcp']}>
@@ -390,6 +399,7 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
displayIndex={mIdx + 1}
form={form}
listPath={[...base, 'tcp']}
allowFilter={allowFilter}
onRemove={() => remove(field.name)}
/>
))}
@@ -404,12 +414,14 @@ function TcpMaskItem({
displayIndex,
form,
listPath,
allowFilter,
onRemove,
}: {
fieldName: number;
displayIndex: number;
form: FormInstance;
listPath: (string | number)[];
allowFilter: boolean;
onRemove: () => void;
}) {
// Absolute path for setFieldValue side effects (resetting settings on
@@ -442,6 +454,7 @@ function TcpMaskItem({
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'sudoku', label: 'Sudoku' },
{ value: 'xmc', label: 'XMC (Minecraft)' },
...(allowFilter ? [FILTER_MASK_OPTION] : []),
]}
/>
</Form.Item>
@@ -493,6 +506,7 @@ function TcpMaskItem({
);
}
if (type === 'sudoku') return <SudokuSettings fieldName={fieldName} />;
if (type === 'filter') return <FilterMaskSettings fieldName={fieldName} />;
if (type === 'header-custom') {
return (
<HeaderCustomGroups
@@ -543,6 +557,41 @@ function TcpMaskItem({
);
}
const FILTER_MASK_OPTION = { value: 'filter', label: 'Filter (source IPs)' };
// xray-core 26.10.21 (#7206) screens a listener's source IPs; a blocked match wins over an allowed
// one, and an empty list leaves that side open. Links and JSON subscriptions drop it.
function FilterMaskSettings({ fieldName }: { fieldName: number }) {
return (
<>
<Form.Item
label="Allowed Source IPs"
name={[fieldName, 'settings', 'allowedSourceIPs']}
tooltip="Only these sources may connect; empty allows every source"
>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="10.0.0.0/8, geoip:ir"
/>
</Form.Item>
<Form.Item
label="Blocked Source IPs"
name={[fieldName, 'settings', 'blockedSourceIPs']}
tooltip="These sources are dropped before any other mask runs"
>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="203.0.113.7, geoip:cn"
/>
</Form.Item>
</>
);
}
function SudokuSettings({ fieldName }: { fieldName: number }) {
return (
<>
@@ -882,6 +931,7 @@ function UdpMasksList({
isWireguard,
network,
allowUdpHop,
allowFilter,
}: {
base: (string | number)[];
form: FormInstance;
@@ -889,6 +939,7 @@ function UdpMasksList({
isWireguard: boolean;
network: string;
allowUdpHop: boolean;
allowFilter: boolean;
}) {
const { t } = useTranslation();
return (
@@ -918,6 +969,7 @@ function UdpMasksList({
isWireguard={isWireguard}
network={network}
allowUdpHop={allowUdpHop}
allowFilter={allowFilter}
onRemove={() => remove(field.name)}
/>
))}
@@ -936,6 +988,7 @@ function UdpMaskItem({
isWireguard,
network,
allowUdpHop,
allowFilter,
onRemove,
}: {
fieldName: number;
@@ -946,6 +999,7 @@ function UdpMaskItem({
isWireguard: boolean;
network: string;
allowUdpHop: boolean;
allowFilter: boolean;
onRemove: () => void;
}) {
const absolutePath = [...listPath, fieldName];
@@ -960,8 +1014,9 @@ function UdpMaskItem({
};
const udpHopOption = allowUdpHop ? [{ value: 'udphop', label: 'UDP Hop' }] : [];
const filterOption = allowFilter ? [FILTER_MASK_OPTION] : [];
const options = isHysteria
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption]
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }, ...udpHopOption, ...filterOption]
: [
// Salamander is the mask xray-core's own wireguard finalmask example
// uses; it stays hysteria-only elsewhere to keep legacy parity.
@@ -974,6 +1029,7 @@ function UdpMaskItem({
{ value: 'noise', label: 'Noise' },
{ value: 'sudoku', label: 'Sudoku' },
...udpHopOption,
...filterOption,
];
return (
@@ -1039,6 +1095,7 @@ function UdpMaskItem({
if (type === 'udphop') {
return <UdpHopSettings udpFieldName={fieldName} />;
}
if (type === 'filter') return <FilterMaskSettings fieldName={fieldName} />;
if (type === 'xicmp') {
return (
<>
@@ -1430,23 +1487,44 @@ function XdnsSettings({ udpFieldName }: { udpFieldName: number }) {
/>
</Form.Item>
{resolvers.map((resolver, ri) => (
<Form.Item key={resolver.key} label={`Resolver ${ri + 1}`}>
<Space.Compact block>
<Form.Item name={[resolver.name, 'addrs']} noStyle>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="udp://8.8.8.8:53, tcp://1.1.1.1"
/>
</Form.Item>
<Button
icon={<DeleteOutlined />}
<div key={resolver.key}>
<Divider style={{ margin: 0 }}>
Resolver {ri + 1}
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(resolver.name)}
onKeyDown={activateOnKey(() => remove(resolver.name))}
/>
</Space.Compact>
</Form.Item>
</Divider>
<Form.Item label="Addresses" name={[resolver.name, 'addrs']}>
<Select
mode="tags"
style={{ width: '100%' }}
tokenSeparators={[',', ' ']}
placeholder="udp://8.8.8.8:53, https://dns.google/dns-query"
/>
</Form.Item>
<Form.Item label="Workers" name={[resolver.name, 'worker']}>
<InputNumber min={1} placeholder="32" />
</Form.Item>
<Form.Item
label="DoH TLS"
name={[resolver.name, 'tlsConfig']}
tooltip="tlsSettings for the https:// addresses, e.g. a serverName to front the domain"
>
<JsonObjectInput placeholder='{"serverName": "dns.google", "fingerprint": "chrome"}' />
</Form.Item>
<Form.Item
label="DoH QUIC"
name={[resolver.name, 'quicConfig']}
tooltip="QUIC parameters for an https:// address reached over h3"
>
<JsonObjectInput placeholder='{"congestion": "bbr"}' />
</Form.Item>
</div>
))}
</>
)}
@@ -1531,6 +1609,7 @@ function validateNoiseReset(_: unknown, value: unknown) {
return valid ? Promise.resolve() : Promise.reject(new Error('seconds, e.g. 0 or 60-120'));
}
// xray-core 26.10.17 (#7124) refuses a noise script beside noise items, so a script replaces the list.
function NoiseItems({
udpFieldName,
form,
@@ -1539,18 +1618,49 @@ function NoiseItems({
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const script = Form.useWatch([...absoluteSettingsPath, 'script'], form) as string | undefined;
return (
<>
<Form.Item
label="Reset"
name={[udpFieldName, 'settings', 'reset']}
tooltip="Seconds before the noise is sent to a destination again; 0 sends it once"
rules={[{ validator: validateNoiseReset }]}
>
<Input placeholder="0 or 60-120" />
</Form.Item>
<Form.Item
label="Script"
name={[udpFieldName, 'settings', 'script']}
tooltip="A Lua file xray-core runs to build the noise, found beside the xray binary; replaces the noise list"
normalize={(value: string) => value || undefined}
>
<Input allowClear placeholder="noise.lua" />
</Form.Item>
{!script && (
<NoiseItemList
udpFieldName={udpFieldName}
form={form}
absoluteSettingsPath={absoluteSettingsPath}
/>
)}
</>
);
}
function NoiseItemList({
udpFieldName,
form,
absoluteSettingsPath,
}: {
udpFieldName: number;
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
<Form.Item
label="Reset"
name={[udpFieldName, 'settings', 'reset']}
tooltip="Seconds before the noise is sent to a destination again; 0 sends it once"
rules={[{ validator: validateNoiseReset }]}
>
<Input placeholder="0 or 60-120" />
</Form.Item>
<Form.List name={[udpFieldName, 'settings', 'noise']}>
{(items, { add, remove }) => (
<>
@@ -1617,12 +1727,14 @@ function validateCustomVarName(_: unknown, value: unknown) {
}
// Hands the form only a parsed JSON object, so half-typed text never reaches the config.
function TransformEditor({
function JsonObjectInput({
value,
onChange,
placeholder,
}: {
value?: Record<string, unknown>;
onChange?: (next: Record<string, unknown> | undefined) => void;
placeholder?: string;
}) {
const [text, setText] = useState(() => (value ? JSON.stringify(value) : ''));
const [invalid, setInvalid] = useState(false);
@@ -1631,7 +1743,7 @@ function TransformEditor({
<Input.TextArea
value={text}
autoSize={{ minRows: 2, maxRows: 8 }}
placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}'
placeholder={placeholder}
onChange={(e) => {
const next = e.target.value;
setText(next);
@@ -1755,7 +1867,7 @@ function ItemEditor({
tooltip='An expression tree, e.g. {"op": "concat", "args": [{"type": "str", "bytes": "OK "}, {"reuse": "nonce"}]}'
rules={[{ required: true, message: 'enter the transform expression' }]}
>
<TransformEditor />
<JsonObjectInput placeholder='{"op": "concat", "args": [{"reuse": "nonce"}]}' />
</Form.Item>
)}
@@ -13,6 +13,7 @@ const SERVICE_LABEL_KEYS: Partial<Record<string, string>> = {
local: 'pages.inbounds.form.xdrive.serviceLocal',
template: 'pages.inbounds.form.xdrive.serviceTemplate',
};
const SERVICE_NAMES: Partial<Record<string, string>> = { 'google-drive': 'Google Drive' };
// Core defaults (transport/internet/xdrive/params.go), shown as placeholders so an
// empty field keeps meaning "use the core's value".
@@ -76,7 +77,7 @@ export default function XDriveForm() {
const service = useWatch({ control, name: `${BASE.join('.')}.service` }) as string | undefined;
const serviceOptions = XDriveServiceSchema.options.map((value) => {
const labelKey = SERVICE_LABEL_KEYS[value];
return { value, label: labelKey ? t(labelKey) : value };
return { value, label: labelKey ? t(labelKey) : (SERVICE_NAMES[value] ?? value) };
});
return (
@@ -91,7 +92,7 @@ export default function XDriveForm() {
>
<Input placeholder={service === 'local' ? '/var/lib/xdrive' : 'xray-tunnel'} />
</FormField>
{service === 'Google Drive' &&
{service === 'google-drive' &&
GOOGLE_DRIVE_SECRETS.map((key, index) => (
<FormField
key={key}
@@ -28,6 +28,7 @@ import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { liftLegacyXDriveService } from '@/schemas/protocols/stream/xdrive';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
@@ -183,6 +184,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;
if (streamSettings) {
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
liftLegacyXDriveService(streamSettings as unknown as Record<string, unknown>);
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
const streamRecord = streamSettings as unknown as Record<string, unknown>;
const xh = streamRecord.xhttpSettings;
+18 -1
View File
@@ -172,9 +172,26 @@ function withLegacyFragmentRanges(finalmask: FinalMaskStreamSettings): FinalMask
return changed ? { ...finalmask, tcp } : finalmask;
}
// The "filter" mask (xray-core 26.10.21) screens the listener's source IPs, and a client core older
// than it refuses the unknown type. Mirrors dropServerOnlyFinalMasks in internal/sub.
function withoutServerOnlyMasks(finalmask: FinalMaskStreamSettings): FinalMaskStreamSettings {
const next: Record<string, unknown> = { ...finalmask };
let changed = false;
for (const layer of ['tcp', 'udp'] as const) {
const masks = finalmask[layer];
if (!Array.isArray(masks)) continue;
const kept = masks.filter((mask) => mask?.type !== 'filter');
if (kept.length === masks.length) continue;
changed = true;
if (kept.length > 0) next[layer] = kept;
else delete next[layer];
}
return changed ? (next as FinalMaskStreamSettings) : finalmask;
}
function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string {
if (!finalmask) return '';
const shareable = withLegacyFragmentRanges(finalmask);
const shareable = withLegacyFragmentRanges(withoutServerOnlyMasks(finalmask));
return hasShareableFinalMaskValue(shareable) ? JSON.stringify(shareable) : '';
}
@@ -1,4 +1,5 @@
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { liftLegacyXDriveService } from '@/schemas/protocols/stream/xdrive';
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
import { canEnableTlsFlow } from '@/lib/xray/protocol-capabilities';
@@ -570,6 +571,7 @@ function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
next.network = next.method;
}
delete next.method;
liftLegacyXDriveService(next);
const xh = next.xhttpSettings;
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
const xhttp = { ...(xh as Raw) };
@@ -66,11 +66,14 @@ function hasVlessEncryption(settings: CapabilityProtocolSlice['settings']): bool
return isSet(settings?.encryption) || isSet(settings?.decryption);
}
// xray-core 26.10.17 (#7182) refuses a VLESS flow on XDRIVE, encrypted or not.
export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
if (values.protocol !== 'vless') return false;
const network = values.streamSettings?.network;
const security = values.streamSettings?.security;
if (network === 'xdrive') return false;
// Classic XTLS Vision: raw TCP carried over TLS or REALITY.
if (network === 'tcp' && (security === 'tls' || security === 'reality')) return true;
@@ -948,7 +948,7 @@ export default function InboundFormModal({
{ value: 'grpc', label: 'gRPC' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
{ value: 'xdrive', label: 'XDRIVE' },
...(protocol === Protocols.VLESS ? [{ value: 'xdrive', label: 'XDRIVE' }] : []),
]}
/>
</Form.Item>
@@ -22,7 +22,7 @@ import {
FLOW_OPTIONS,
HYSTERIA_NETWORK_OPTION,
MASQUE_NETWORK_OPTION,
NETWORK_OPTIONS,
networkOptionsFor,
PROTOCOL_OPTIONS,
SERVER_PROTOCOLS,
TARGET_STRATEGY_OPTIONS,
@@ -265,9 +265,11 @@ export default function OutboundFormModal({
* wsSettings, etc.) so the DU branch matches. Preserve security if
* the new network supports it, otherwise force back to 'none'.
*/
// xray-core 26.10.17 (#7182) dials XDRIVE only through Mux, so picking XDRIVE turns Mux on.
function onNetworkChange(next: string) {
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
if (next === 'xdrive') methods.setValue('mux.enabled', true);
}
function onXmuxToggle(checked: boolean) {
@@ -499,7 +501,7 @@ export default function OutboundFormModal({
? [HYSTERIA_NETWORK_OPTION]
: protocol === 'masque'
? [MASQUE_NETWORK_OPTION]
: NETWORK_OPTIONS
: networkOptionsFor(protocol)
}
/>
</Form.Item>
@@ -47,6 +47,13 @@ export const NETWORK_OPTIONS: { value: string; label: string }[] = [
{ value: 'xdrive', label: 'XDRIVE' },
];
// xray-core 26.10.17 (#7182) carries only VLESS over XDRIVE.
export function networkOptionsFor(protocol: string): { value: string; label: string }[] {
return protocol === 'vless'
? NETWORK_OPTIONS
: NETWORK_OPTIONS.filter((option) => option.value !== 'xdrive');
}
// The hysteria protocol is locked to its own QUIC transport: the selector
// shows only this option when the parent protocol is hysteria.
export const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' };
@@ -10,7 +10,9 @@ import { z } from 'zod';
// here would dwarf the rest of the stream module without buying anything
// the safety net doesn't already cover.
export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom', 'xmc']);
// 'filter' (xray-core 26.10.21) screens a listener's source IPs, so only the server-side
// editors offer it and the share-link generators drop it.
export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom', 'xmc', 'filter']);
export type TcpMaskType = z.infer<typeof TcpMaskTypeSchema>;
export const TcpMaskSchema = z.object({
@@ -31,6 +33,7 @@ export const UdpMaskTypeSchema = z.enum([
'sudoku',
'realm',
'udphop',
'filter',
]);
export type UdpMaskType = z.infer<typeof UdpMaskTypeSchema>;
@@ -22,5 +22,8 @@ export const MasqueStreamSettingsSchema = z.object({
pass: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
warp: MasqueWarpSchema.optional(),
// Client XMUX (xray-core 26.10.17); left unset, the core keeps one connection, so no
// defaults are filled in here. A JSON subscription hands an inbound's value to clients.
xmux: z.record(z.string(), z.unknown()).optional(),
});
export type MasqueStreamSettings = z.infer<typeof MasqueStreamSettingsSchema>;
@@ -2,13 +2,28 @@ import { z } from 'zod';
// Both XDRIVE ends carry the same service, folder and secrets; an absent tuning knob takes
// the core's default (segmentBytes and concurrency are capped there at 16 MiB / 64).
export const XDriveServiceSchema = z.enum(['Google Drive', 'local', 'template']);
export const XDriveServiceSchema = z.enum(['google-drive', 'local', 'template']);
export type XDriveService = z.infer<typeof XDriveServiceSchema>;
// xray-core 26.10.17 (#7182) renamed "Google Drive" and reads the old name only as a deprecated alias.
const canonicalXDriveService = (value: unknown) =>
value === 'Google Drive' ? 'google-drive' : value;
const XDriveServiceInputSchema = z.preprocess(canonicalXDriveService, XDriveServiceSchema);
/** Lifts a stored stream's legacy service name before an editor reads it unparsed. */
export function liftLegacyXDriveService(stream: Record<string, unknown>): void {
const xdrive = stream.xdriveSettings;
if (!xdrive || typeof xdrive !== 'object' || Array.isArray(xdrive)) return;
const settings = xdrive as Record<string, unknown>;
const service = canonicalXDriveService(settings.service);
if (service !== settings.service) stream.xdriveSettings = { ...settings, service };
}
const MillisSchema = z.number().int().min(0);
export const XDriveStreamSettingsSchema = z.object({
service: XDriveServiceSchema.default('Google Drive'),
service: XDriveServiceInputSchema.default('google-drive'),
remoteFolder: z.string().default(''),
// Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken.
secrets: z.array(z.string()).default([]),
@@ -21,5 +36,8 @@ export const XDriveStreamSettingsSchema = z.object({
eagerWindowMs: MillisSchema.optional(),
holeTimeoutMs: MillisSchema.optional(),
template: z.record(z.string(), z.unknown()).optional(),
// Client XMUX (xray-core 26.10.21); left unset, the core sizes it per service, so no
// defaults are filled in here. A JSON subscription hands an inbound's value to clients.
xmux: z.record(z.string(), z.unknown()).optional(),
});
export type XDriveStreamSettings = z.infer<typeof XDriveStreamSettingsSchema>;
@@ -54,7 +54,7 @@ exports[`NetworkSettingsSchema fixtures > parses xdrive-google-drive byte-stably
"1//example-refresh-token",
],
"segmentBytes": 524288,
"service": "Google Drive",
"service": "google-drive",
"sessionTtlSeconds": 300,
},
}
+18
View File
@@ -29,6 +29,24 @@ describe('FinalMaskForm item types', () => {
});
});
describe('FinalMaskForm filter mask', () => {
// xray-core 26.10.21's filter screens a listener's source IPs and is a no-op on a dialer, so
// only the inbound editor offers it; an older client core refuses the unknown type.
it('offers filter on the server side only', () => {
const value = { tcp: [{ type: 'fragment' as const, settings: {} }], udp: [] };
const client = renderWithProviders(
<FinalMaskField network="tcp" protocol="vless" side="client" value={value} />,
);
expect(listSelectOptions('finalmask_tcp_0_type')).not.toContain('Filter (source IPs)');
client.unmount();
renderWithProviders(
<FinalMaskField network="tcp" protocol="vless" side="server" value={value} />,
);
expect(listSelectOptions('finalmask_tcp_0_type')).toContain('Filter (source IPs)');
});
});
describe('FinalMaskForm salamander password', () => {
// xray-core loads a salamander password under 4 bytes and refuses it only when the socket
// opens: on an inbound that stops Xray. It counts bytes, so "a€" (4 bytes) is long enough.
@@ -3,7 +3,7 @@
"address": "142.250.0.1",
"port": 443,
"xdriveSettings": {
"service": "Google Drive",
"service": "google-drive",
"remoteFolder": "xray-tunnel",
"secrets": [
"000000000000-example.apps.googleusercontent.com",
+49 -1
View File
@@ -371,7 +371,7 @@ describe('InboundFormModal', () => {
network: 'xdrive',
security: 'none',
xdriveSettings: {
service: 'Google Drive',
service: 'google-drive',
remoteFolder: 'xray-tunnel',
secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'],
},
@@ -467,6 +467,54 @@ describe('InboundFormModal', () => {
expect(JSON.stringify(finalmask.udp ?? [])).not.toContain('udphop');
});
// xray-core 26.10.21 (#7206) screens a listener's source IPs with the "filter" mask.
it('saves an inbound filter mask with both source lists', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
const filter = {
type: 'filter',
settings: { allowedSourceIPs: ['10.0.0.0/8'], blockedSourceIPs: ['geoip:cn'] },
};
const hysteria = new DBInbound({
id: 45,
port: 443,
listen: '',
protocol: 'hysteria',
remark: 'hy',
enable: true,
settings: { version: 2, clients: [] },
streamSettings: {
network: 'hysteria',
security: 'tls',
hysteriaSettings: { version: 2, auth: '', udpIdleTimeout: 60 },
tlsSettings: {
serverName: 'hy.example.com',
alpn: ['h3'],
certificates: [
{ certificateFile: '/etc/ssl/certs/hy.crt', keyFile: '/etc/ssl/private/hy.key' },
],
},
finalmask: { udp: [filter] },
},
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'listen',
shareAddr: '',
});
renderCloneLikeEdit(hysteria);
await screen.findByText('geoip:cn');
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
fireEvent.click(primaryButton());
const isUpdate = ([url]: unknown[]) => url === '/panel/api/inbounds/update/45';
await waitFor(() => expect(post.mock.calls.some(isUpdate)).toBe(true));
const payload = post.mock.calls.find(isUpdate)![1] as { streamSettings: string };
expect(JSON.parse(payload.streamSettings).finalmask.udp).toEqual([filter]);
});
// xray-core 26.9: the Wintun adapter type (desc) and the TUN auto-system leak guards.
it('saves the TUN adapter type and auto-system options set in the form', async () => {
const post = vi.mocked(HttpUtil.post);
+10 -2
View File
@@ -226,8 +226,9 @@ describe('protocol-capability helpers with raw coerced shapes', () => {
streamSettings: { network: 'xhttp', security: 'none' },
}),
).toBe(false);
// xray-core runs Vision inside VLESS encryption on any transport, RAW and WS included.
for (const network of ['tcp', 'ws', 'grpc', 'xdrive']) {
// xray-core runs Vision inside VLESS encryption on any transport, RAW and WS included,
// but XDRIVE, which refuses a flow since 26.10.17 (#7182).
for (const network of ['tcp', 'ws', 'grpc']) {
expect(
canEnableTlsFlow({
protocol: 'vless',
@@ -236,6 +237,13 @@ describe('protocol-capability helpers with raw coerced shapes', () => {
}),
).toBe(true);
}
expect(
canEnableTlsFlow({
protocol: 'vless',
settings: { decryption: dec, encryption: enc },
streamSettings: { network: 'xdrive', security: 'none' },
}),
).toBe(false);
expect(
canEnableTlsFlow({
protocol: 'vless',
+16
View File
@@ -288,6 +288,22 @@ describe('genVlessLink TCP fragment finalmask compatibility', () => {
expect(exported).toEqual(finalmask);
});
// xray-core 26.10.21's filter screens the listener's own source IPs; a client core older than
// it refuses the unknown type, and the lists would hand clients the server's IP policy.
it('leaves the inbound-only filter mask out of the link', () => {
const exported = finalmaskFrom(
linkFor({
tcp: [{ type: 'filter', settings: { allowedSourceIPs: ['10.0.0.0/8'] } }],
udp: [
{ type: 'filter', settings: { blockedSourceIPs: ['geoip:cn'] } },
{ type: 'salamander', settings: { password: 'p' } },
],
}),
);
expect(exported).toEqual({ udp: [{ type: 'salamander', settings: { password: 'p' } }] });
});
it('exports a stored UDP-only finalmask whose empty tcp list was dropped on save', () => {
const udpOnly = { udp: [{ type: 'salamander', settings: { password: 'p' } }] };
const inbound = inboundFromDb({
+160 -3
View File
@@ -354,7 +354,7 @@ describe('OutboundFormModal', () => {
expect(formItemByLabel('Mux')).toBeDefined();
ws.unmount();
await openModal(
const xhttp = await openModal(
vless('mlkem768x25519plus.native.0rtt.key', {
network: 'xhttp',
security: 'none',
@@ -362,6 +362,68 @@ describe('OutboundFormModal', () => {
}),
);
expect(formItemByLabel('Flow')).toBeDefined();
xhttp.unmount();
// xray-core 26.10.17 (#7182) refuses a flow on XDRIVE even inside VLESS encryption.
await openModal(
vless('mlkem768x25519plus.native.0rtt.key', {
network: 'xdrive',
security: 'none',
xdriveSettings: { service: 'local', remoteFolder: '/srv/drive' },
}),
);
expect(formItemByLabel('Transmission')?.textContent).toContain('XDRIVE');
expect(formItemByLabel('Flow')).toBeUndefined();
});
// xray-core 26.10.17 (#7182) carries only VLESS over XDRIVE.
it('offers XDRIVE only to a VLESS outbound', async () => {
const transmissionOptions = async (protocol: string) => {
const queryClient = makeTestQueryClient();
const outbound = {
protocol,
tag: 'out',
settings: {
vnext: [
{
address: 'example.com',
port: 443,
users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
},
],
},
streamSettings: { network: 'tcp', security: 'none' },
};
const tree = (open: boolean) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<OutboundFormModal
open={open}
outbound={outbound}
existingTags={[]}
onClose={() => {}}
onConfirm={() => {}}
/>
</ThemeProvider>
</QueryClientProvider>
);
const view = render(tree(false));
view.rerender(tree(true));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
const select = formItemByLabel('Transmission')?.querySelector('.ant-select');
if (!select) throw new Error('Transmission select not found');
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
const options = Array.from(document.querySelectorAll('.ant-select-item-option')).map((o) =>
(o.getAttribute('title') ?? o.textContent ?? '').trim(),
);
view.unmount();
return options;
};
expect(await transmissionOptions('vmess')).not.toContain('XDRIVE');
expect(await transmissionOptions('vless')).toContain('XDRIVE');
});
it('saves an XDRIVE stream picked in the transmission selector', async () => {
@@ -421,15 +483,83 @@ describe('OutboundFormModal', () => {
});
expect(onConfirm).toHaveBeenCalledTimes(1);
const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record<string, unknown> };
const payload = onConfirm.mock.calls[0][0] as {
streamSettings: Record<string, unknown>;
mux?: Record<string, unknown>;
};
expect(payload.streamSettings).toMatchObject({
network: 'xdrive',
xdriveSettings: {
service: 'Google Drive',
service: 'google-drive',
remoteFolder: 'xray-tunnel',
secrets: ['id.apps.googleusercontent.com', 'client-secret', 'refresh-token'],
},
});
// xray-core 26.10.17 (#7182) refuses an XDRIVE client without Mux.
expect(payload.mux).toMatchObject({ enabled: true });
});
});
// xray-core 26.10.17 (#7182) renamed the service "google-drive"; an outbound saved before still
// says "Google Drive", which the editor lifts so its secrets stay editable.
describe('OutboundFormModal XDRIVE service name', () => {
it('opens a stored "Google Drive" outbound under the new service name', async () => {
const onConfirm = vi.fn();
const queryClient = makeTestQueryClient();
const outbound = {
protocol: 'vless',
tag: 'drive-out',
settings: {
address: 'example.com',
port: 443,
id: 'c9f0c2d0-0000-4000-8000-000000000000',
encryption: 'mlkem768x25519plus.native.0rtt.key',
},
mux: { enabled: true, concurrency: 8 },
streamSettings: {
network: 'xdrive',
security: 'none',
xdriveSettings: {
service: 'Google Drive',
remoteFolder: 'xray-tunnel',
secrets: ['id', 'secret', 'token'],
},
},
};
const tree = (open: boolean) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<OutboundFormModal
open={open}
outbound={outbound}
existingTags={[]}
onClose={() => {}}
onConfirm={onConfirm}
/>
</ThemeProvider>
</QueryClientProvider>
);
const { rerender } = render(tree(false));
rerender(tree(true));
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect((screen.getByLabelText('Client ID') as HTMLInputElement).value).toBe('id');
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
await act(async () => {
fireEvent.click(ok);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(onConfirm).toHaveBeenCalledTimes(1);
const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record<string, unknown> };
expect(payload.streamSettings.xdriveSettings).toMatchObject({
service: 'google-drive',
secrets: ['id', 'secret', 'token'],
});
});
});
@@ -767,6 +897,33 @@ describe('OutboundFormModal finalmask variables', () => {
expect(finalmask.udp).toEqual([{ type: 'header-custom', settings: { client, server: [] } }]);
});
// xray-core 26.10.17 gives noise a Lua `script` and xdns a DoH resolver with its own
// worker count, TLS and QUIC settings; the mask editor only keeps keys it registers.
it('keeps a noise script and the DoH settings of an xdns resolver on save', async () => {
const fixture = {
tcp: [],
udp: [
{ type: 'noise', settings: { reset: 0, script: 'noise.lua' } },
{
type: 'xdns',
settings: {
domains: [{ names: ['t.example.com'] }],
resolvers: [
{
addrs: ['https://dns.example.com/dns-query'],
worker: 8,
tlsConfig: { serverName: 'dns.example.com', fingerprint: 'chrome' },
quicConfig: { congestion: 'bbr', maxIdleTimeout: 30 },
},
],
},
},
],
};
expect(await saveFinalmask(structuredClone(fixture))).toEqual(fixture);
});
it('saves a noise reset typed as a range', async () => {
const finalmask = await saveFinalmask(
{ udp: [{ type: 'noise', settings: { reset: 0, noise: [] } }] },
+26
View File
@@ -44,3 +44,29 @@ describe('NetworkSettingsSchema method alias', () => {
expect((parsed as { network?: string }).network).toBe('grpc');
});
});
// xray-core 26.10.17 (#7182) renamed the XDRIVE service, reading "Google Drive" only as an alias.
// XDRIVE's and MASQUE's xmux are client keys a JSON subscription hands on from the inbound.
describe('NetworkSettingsSchema xray-core 26.10.21 keys', () => {
it('folds the "Google Drive" service into google-drive', () => {
const parsed = NetworkSettingsSchema.parse({
network: 'xdrive',
xdriveSettings: { service: 'Google Drive', remoteFolder: 'f', secrets: ['a', 'b', 'c'] },
}) as { xdriveSettings: { service: string } };
expect(parsed.xdriveSettings.service).toBe('google-drive');
});
it('keeps the XDRIVE and MASQUE xmux', () => {
const xmux = { maxConnections: 2, hMaxReusableSecs: '1800-3000' };
const xdrive = NetworkSettingsSchema.parse({
network: 'xdrive',
xdriveSettings: { service: 'local', remoteFolder: '/srv/drive', xmux },
}) as { xdriveSettings: { xmux?: unknown } };
const masque = NetworkSettingsSchema.parse({
network: 'masque',
masqueSettings: { host: 'masque.example.com', xmux },
}) as { masqueSettings: { xmux?: unknown } };
expect(xdrive.xdriveSettings.xmux).toEqual(xmux);
expect(masque.masqueSettings.xmux).toEqual(xmux);
});
});