mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 06:31:58 +00:00
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.
519 lines
16 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|