Files
3x-ui/frontend/src/pages/xray/basics/BasicsTab.tsx
T
MHSanaei b6c859f5b9 feat(xray): edit Lua DNS and routing scripts
xray-core 26.10.10 can hand DNS and routing to Lua: dns.script and
routing.script name a .lua file (absolute, or found next to the Xray
binary) that defines HandleDNSQuery / HandleRoute, and a DNS server's id
is how a DNS script picks it. The keys round-tripped through the JSON
template but had no field.

- DNS: a Lua Script field, and an ID field in the server modal;
- Basics: a Routing Script field beside the routing strategy;
- Routing: a warning above the rules while a script is set, since the
  core then routes only through the script and skips every rule, the
  panel's api rule included.

The descriptions say the script must still send the api inbound to the
api outbound, and that Xray will not start if the file is missing. An
empty field drops the key, because the core rejects an empty path.
2026-10-11 15:37:20 +02:00

519 lines
16 KiB
TypeScript

import { useCallback } from 'react';
import { onNumber } from '@/utils/onNumber';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
BarChartOutlined,
ClockCircleOutlined,
FileTextOutlined,
ReloadOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css';
import {
ACCESS_LOG,
ERROR_LOG,
LOG_LEVELS,
MASK_ADDRESS,
ROUTING_DOMAIN_STRATEGIES,
} from './constants';
import {
directFreedomStrategy,
ensureDirectFreedomOutbound,
isDirectFreedomOutbound,
isDirectTagTaken,
setDirectFreedomStrategy,
} from './helpers';
interface BasicsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
onChangeOutboundTestUrl: (v: string) => void;
onResetDefault: () => void;
}
export default function BasicsTab({
templateSettings,
setTemplateSettings,
outboundTestUrl,
onChangeOutboundTestUrl,
onResetDefault,
}: BasicsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
const setLevel0 = useCallback(
(field: string, value: number | null) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.levels) tt.policy.levels = {};
if (!tt.policy.levels['0']) tt.policy.levels['0'] = {};
if (value === null || value === undefined) {
delete tt.policy.levels['0'][field];
} else {
tt.policy.levels['0'][field] = value;
}
}),
[mutate],
);
const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)
?.metrics;
const setMetrics = useCallback(
(field: 'tag' | 'listen', value: string) =>
mutate((tt) => {
const node = tt as {
metrics?: { tag?: string; listen?: string };
stats?: Record<string, unknown>;
};
const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
if (value.trim() === '') {
delete m[field];
} else {
m[field] = value.trim();
}
if (!m.listen && !m.tag) {
delete node.metrics;
} else {
node.metrics = m;
// xray-core's metrics handler needs a stats object to populate.
if (!node.stats) node.stats = {};
}
}),
[mutate],
);
function confirmResetDefault() {
modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => onResetDefault(),
});
}
const freedomStrategy = directFreedomStrategy(templateSettings);
const directFreedomOutbound = templateSettings?.outbounds?.find((o) =>
isDirectFreedomOutbound(o),
);
const directTagTaken = isDirectTagTaken(templateSettings);
const directHappyEyeballs = (() => {
const sockopt = (
directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
)?.sockopt;
const raw = sockopt?.happyEyeballs;
if (raw == null || typeof raw !== 'object') return null;
const parsed = HappyEyeballsSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
})();
const setDirectHappyEyeballs = useCallback(
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
mutate((tt) => {
const ob = ensureDirectFreedomOutbound(tt);
if (!ob) return;
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
if (next == null) {
delete sockopt.happyEyeballs;
} else {
sockopt.happyEyeballs = next;
}
if (Object.keys(sockopt).length === 0) {
delete stream.sockopt;
} else {
stream.sockopt = sockopt;
}
if (Object.keys(stream).length === 0) {
delete ob.streamSettings;
} else {
ob.streamSettings = stream;
}
});
},
[mutate],
);
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const routingScript = templateSettings?.routing?.script ?? '';
const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
const items = [
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.generalConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.FreedomStrategy')}
description={t('pages.xray.FreedomStrategyDesc')}
paddings="small"
control={
<Select
value={freedomStrategy}
disabled={directTagTaken}
style={{ width: '100%' }}
options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
/>
}
/>
<SettingListItem
title={t('pages.xray.FreedomHappyEyeballs')}
description={t('pages.xray.FreedomHappyEyeballsDesc')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs != null}
disabled={directTagTaken}
onChange={(checked) => {
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
}}
/>
}
/>
{directHappyEyeballs != null && (
<>
<SettingListItem
title={t('pages.inbounds.form.tryDelayMs')}
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
paddings="small"
control={
<InputNumber
min={0}
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={onNumber((v) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: v,
}),
)}
/>
}
/>
<SettingListItem
title={t('pages.inbounds.form.prioritizeIPv6')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs.prioritizeIPv6}
onChange={(checked) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
prioritizeIPv6: checked,
})
}
/>
}
/>
</>
)}
<SettingListItem
title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')}
paddings="small"
control={
<Select
value={routingStrategy}
style={{ width: '100%' }}
options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(next) =>
mutate((tt) => {
if (tt.routing) tt.routing.domainStrategy = next;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.routingScript')}
description={t('pages.xray.routingScriptDesc')}
paddings="small"
control={
<Input
value={routingScript}
placeholder="routing.lua"
onChange={(e) => {
const next = e.target.value;
mutate((tt) => {
if (!tt.routing) return;
if (next.trim() === '') delete tt.routing.script;
else tt.routing.script = next;
});
}}
/>
}
/>
<SettingListItem
title={t('pages.xray.outboundTestUrl')}
description={t('pages.xray.outboundTestUrlDesc')}
paddings="small"
control={
<Input
value={outboundTestUrl}
onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
placeholder="https://www.google.com/generate_204"
/>
}
/>
</>
),
},
{
key: '2',
label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
children: (
<>
{[
['statsInboundUplink', t('pages.xray.statsInboundUplink')],
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
].map(([field, label]) => (
<SettingListItem
key={field}
title={label}
paddings="small"
control={
<Switch
checked={!!policy[field]}
onChange={(checked) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.system) tt.policy.system = {};
tt.policy.system[field] = checked;
})
}
/>
}
/>
))}
<SettingListItem
title={t('pages.xray.metricsListen')}
description={t('pages.xray.metricsListenDesc')}
paddings="small"
control={
<Input
value={metricsCfg?.listen ?? ''}
onChange={(e) => setMetrics('listen', e.target.value)}
placeholder="127.0.0.1:11111"
/>
}
/>
<SettingListItem
title={t('pages.xray.metricsTag')}
paddings="small"
control={
<Input
value={metricsCfg?.tag ?? ''}
onChange={(e) => setMetrics('tag', e.target.value)}
placeholder="metrics_out"
/>
}
/>
</>
),
},
{
key: 'connection',
label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.connectionLimitsDesc')}
/>
<SettingListItem
title={t('pages.xray.connIdle')}
description={t('pages.xray.connIdleDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
min={0}
style={{ width: '100%' }}
placeholder="300"
suffix={t('pages.xray.seconds')}
onChange={(v) => setLevel0('connIdle', v as number | null)}
/>
}
/>
<SettingListItem
title={t('pages.xray.bufferSize')}
description={t('pages.xray.bufferSizeDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
min={0}
style={{ width: '100%' }}
placeholder={t('pages.xray.bufferSizePlaceholder')}
suffix="KB"
onChange={(v) => setLevel0('bufferSize', v as number | null)}
/>
}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.logConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.logLevel')}
description={t('pages.xray.logLevelDesc')}
paddings="small"
control={
<Select
value={(log.loglevel as string) || 'warning'}
style={{ width: '100%' }}
options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.loglevel = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.accessLog')}
description={t('pages.xray.accessLogDesc')}
paddings="small"
control={
<Select
value={(log.access as string) || ''}
style={{ width: '100%' }}
options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.access = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.errorLog')}
description={t('pages.xray.errorLogDesc')}
paddings="small"
control={
<Select
value={(log.error as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...ERROR_LOG.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.error = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.maskAddress')}
description={t('pages.xray.maskAddressDesc')}
paddings="small"
control={
<Select
value={(log.maskAddress as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.maskAddress = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.dnsLog')}
description={t('pages.xray.dnsLogDesc')}
paddings="small"
control={
<Switch
checked={!!log.dnsLog}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.dnsLog = v;
})
}
/>
}
/>
</>
),
},
{
key: 'reset',
label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
children: (
<Space style={{ padding: '0 20px' }}>
<Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
{t('pages.settings.resetDefaultConfig')}
</Button>
</Space>
),
},
];
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
</>
);
}