feat(frontend): migrate DNS + Routing to Zod, align with xray docs

Adds first-class Zod schemas for the xray-core DNS block and routing
sub-objects (Balancer, Rule) matching the documented shape at
https://xtls.github.io/config/dns.html and
https://xtls.github.io/config/routing.html, then wires the
DnsServerModal and BalancerFormModal up to those schemas.

schemas/dns.ts (new):
- DnsQueryStrategySchema enum (UseIP/UseIPv4/UseIPv6/UseSystem)
- DnsHostsSchema record(string -> string | string[])
- DnsServerObjectInnerSchema + DnsServerObjectSchema (with preprocess
  to migrate legacy `expectIPs` -> `expectedIPs` alias)
- DnsServerEntrySchema = string | DnsServerObject (xray accepts both)
- DnsObjectSchema with all documented fields and defaults

schemas/routing.ts (new):
- RuleProtocolSchema enum (http/tls/quic/bittorrent)
- RuleWebhookSchema (url/deduplication/headers)
- RuleObjectSchema covering every documented field (domain/ip/port/
  sourcePort/localPort/network/sourceIP/localIP/user/vlessRoute/
  inboundTag/protocol/attrs/process/outboundTag/balancerTag/ruleTag/
  webhook) with type=literal('field').default('field')
- BalancerStrategyTypeSchema enum (random/roundRobin/leastPing/leastLoad)
- BalancerCostObjectSchema {regexp,match,value}
- BalancerStrategySettingsSchema (expected/maxRTT/tolerance/baselines/costs)
- BalancerStrategySchema + BalancerObjectSchema

schemas/xray.ts:
- routing.rules: was loose 3-field object, now z.array(RuleObjectSchema)
- routing.balancers: was z.array(z.unknown()), now z.array(BalancerObjectSchema)
- dns: was 2-field loose, now full DnsObjectSchema
- BalancerFormSchema: strategy now BalancerStrategyTypeSchema (enum)
  instead of z.string(); fallbackTag defaults to ''; settings? added
  for leastLoad

DnsServerModal (full Pattern A rewrite):
- useState/DnsForm interface -> Form.useForm<DnsServerForm>()
- manual domain/expectedIP/unexpectedIP list -> Form.List
- antdRule on address/port/timeoutMs for inline validation
- preserves legacy collapse-to-bare-string behavior on submit

BalancerFormModal:
- Adds conditional leastLoad sub-form (Expected/MaxRTT/Tolerance/
  Baselines/Costs) wired to BalancerStrategySettingsSchema
- Strategy options derived from schema enum
- Cost rows with regexp/literal switch + match + value
- required prop on Tag and Selector for red asterisk visual

BalancersTab:
- BalancerRecord interface -> type alias to BalancerObject
- onConfirm now propagates strategy.settings to wire when leastLoad
- Removes useMemo wrapping `columns` array. The memo had deps
  [t, isMobile] (with an eslint-disable) so the column render
  functions kept their original closure over `openEdit`. Once a
  balancer was created and the user clicked the edit button, the
  stale openEdit fired with empty `rows`, so rows[idx] was undefined
  and the modal opened blank. Columns are cheap to rebuild each
  render, so dropping the memo is the right fix.

DnsTab + RoutingTab: switch ad-hoc interfaces to schema-derived types.

translations (en-US, fa-IR): add the previously-missing
pages.xray.balancerTagRequired and pages.xray.balancerSelectorRequired
keys so antdRule surfaces a real message instead of the raw i18n key.
This commit is contained in:
MHSanaei
2026-05-26 23:36:01 +02:00
parent 0442be5078
commit 0208396802
10 changed files with 616 additions and 334 deletions

View File

@@ -1,8 +1,18 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, Modal, Select } from 'antd';
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray';
import InputAddon from '@/components/InputAddon';
import {
BalancerFormSchema,
type BalancerFormValues,
} from '@/schemas/xray';
import {
BalancerStrategyTypeSchema,
type BalancerStrategySettings,
type BalancerStrategyType,
} from '@/schemas/routing';
export type BalancerFormValue = BalancerFormValues;
@@ -15,12 +25,38 @@ interface BalancerFormModalProps {
onConfirm: (value: BalancerFormValue) => void;
}
const STRATEGIES = [
{ value: 'random', label: 'Random' },
{ value: 'roundRobin', label: 'Round robin' },
{ value: 'leastLoad', label: 'Least load' },
{ value: 'leastPing', label: 'Least ping' },
];
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
value,
label: STRATEGY_LABELS[value] ?? value,
}));
interface FormState {
tag: string;
strategy: BalancerStrategyType;
selector: string[];
fallbackTag: string;
settings?: BalancerStrategySettings;
}
function initialState(balancer: BalancerFormValue | null): FormState {
if (!balancer) {
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
}
return {
tag: balancer.tag ?? '',
strategy: (balancer.strategy ?? 'random') as BalancerStrategyType,
selector: [...(balancer.selector ?? [])],
fallbackTag: balancer.fallbackTag ?? '',
settings: balancer.settings,
};
}
export default function BalancerFormModal({
open,
@@ -31,110 +67,200 @@ export default function BalancerFormModal({
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
const [tag, setTag] = useState(() => balancer?.tag || '');
const [strategy, setStrategy] = useState(() => balancer?.strategy || 'random');
const [selector, setSelector] = useState<string[]>(() => [...(balancer?.selector || [])]);
const [fallbackTag, setFallbackTag] = useState(() => balancer?.fallbackTag || '');
const [state, setState] = useState<FormState>(() => initialState(balancer));
const isEdit = balancer != null;
useEffect(() => {
if (!open) return;
if (balancer) {
setTag(balancer.tag || '');
setStrategy(balancer.strategy || 'random');
setSelector([...(balancer.selector || [])]);
setFallbackTag(balancer.fallbackTag || '');
} else {
setTag('');
setStrategy('random');
setSelector([]);
setFallbackTag('');
}
}, [open, balancer]);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setState((prev) => ({ ...prev, [key]: value }));
const parsed = useMemo(
() => BalancerFormSchema.safeParse({ tag, strategy, selector, fallbackTag }),
[tag, strategy, selector, fallbackTag],
() => BalancerFormSchema.safeParse(state),
[state],
);
const duplicateTag = !!tag.trim() && otherTags.includes(tag.trim());
const issuesByField = useMemo(() => {
const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim());
const issues = useMemo(() => {
const map: Record<string, string> = {};
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? '');
if (!map[key]) map[key] = issue.message;
if (!map[key]) map[key] = t(issue.message, { defaultValue: issue.message });
}
}
return map;
}, [parsed]);
const isValid = parsed.success && !duplicateTag;
const tagValidateStatus: 'error' | 'warning' | 'success' = issuesByField.tag
? 'error'
: duplicateTag
? 'warning'
: 'success';
const tagHelp = issuesByField.tag
? 'Tag is required'
: duplicateTag
? 'Tag already used by another balancer'
: '';
const selectorValidateStatus: 'error' | 'success' = issuesByField.selector ? 'error' : 'success';
const selectorHelp = issuesByField.selector ? 'Pick at least one outbound' : '';
}, [parsed, t]);
function submit() {
if (!parsed.success || duplicateTag) return;
onConfirm(parsed.data);
const values = { ...parsed.data };
if (values.strategy !== 'leastLoad') delete values.settings;
onConfirm(values);
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
const settings = state.settings;
const updateSetting = <K extends keyof BalancerStrategySettings>(
key: K,
value: BalancerStrategySettings[K],
) => {
setState((prev) => ({
...prev,
settings: { ...(prev.settings ?? {}), [key]: value },
}));
};
const updateBaselines = (next: string[]) => updateSetting('baselines', next);
const updateCosts = (next: NonNullable<BalancerStrategySettings['costs']>) => updateSetting('costs', next);
const baselines = settings?.baselines ?? [];
const costs = settings?.costs ?? [];
const fallbackOptions = useMemo(
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
[outboundTags, t],
);
const title = isEdit
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
okButtonProps={{ disabled: !isValid }}
okButtonProps={{ disabled: !parsed.success || duplicateTag }}
mask={{ closable: false }}
destroyOnHidden
onOk={submit}
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label="Tag" validateStatus={tagValidateStatus} help={tagHelp} hasFeedback>
<Input value={tag} onChange={(e) => setTag(e.target.value)} placeholder="unique balancer tag" />
<Form.Item
label="Tag"
required
validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
help={issues.tag || (duplicateTag ? 'Tag already used by another balancer' : '')}
hasFeedback
>
<Input
value={state.tag}
onChange={(e) => update('tag', e.target.value)}
placeholder="unique balancer tag"
/>
</Form.Item>
<Form.Item label="Strategy">
<Select value={strategy} onChange={setStrategy} options={STRATEGIES} />
<Select
value={state.strategy}
onChange={(v) => update('strategy', v)}
options={STRATEGIES}
/>
</Form.Item>
<Form.Item
label="Selector"
validateStatus={selectorValidateStatus}
help={selectorHelp}
required
validateStatus={issues.selector ? 'error' : ''}
help={issues.selector || ''}
hasFeedback
>
<Select
mode="tags"
value={selector}
onChange={setSelector}
value={state.selector}
onChange={(v) => update('selector', v)}
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</Form.Item>
<Form.Item label="Fallback">
<Select value={fallbackTag} onChange={setFallbackTag} allowClear options={fallbackOptions} />
<Select
value={state.fallbackTag}
onChange={(v) => update('fallbackTag', v ?? '')}
allowClear
options={fallbackOptions}
/>
</Form.Item>
{state.strategy === 'leastLoad' && (
<>
<Form.Item label="Expected">
<InputNumber
value={settings?.expected}
onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)}
min={0}
placeholder="optimal node count"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Max RTT">
<Input
value={settings?.maxRTT ?? ''}
onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)}
placeholder="e.g. 1s"
/>
</Form.Item>
<Form.Item label="Tolerance">
<InputNumber
value={settings?.tolerance}
onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
min={0}
max={1}
step={0.01}
placeholder="0.01 = 1%"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Baselines">
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => updateBaselines([...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
placeholder="e.g. 1s"
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
/>
<InputAddon onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label="Costs">
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
/>
<Input
value={c.match}
placeholder="tag pattern"
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
/>
<InputNumber
value={c.value}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
/>
<InputAddon onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
</>
)}
</Form>
</Modal>
);

View File

@@ -8,6 +8,11 @@ import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal';
import JsonEditor from '@/components/JsonEditor';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type {
BalancerObject,
BalancerStrategySettings,
BalancerStrategyType,
} from '@/schemas/routing';
interface BalancersTabProps {
templateSettings: XraySettingsValue | null;
@@ -16,19 +21,15 @@ interface BalancersTabProps {
isMobile: boolean;
}
interface BalancerRecord {
tag: string;
selector?: string[];
fallbackTag?: string;
strategy?: { type?: string };
}
type BalancerRecord = BalancerObject;
interface BalancerRow {
key: number;
tag: string;
strategy: string;
strategy: BalancerStrategyType;
selector: string[];
fallbackTag: string;
settings?: BalancerStrategySettings;
}
const STRATEGY_LABELS: Record<string, string> = {
@@ -102,9 +103,10 @@ export default function BalancersTab({
return list.map((b, idx) => ({
key: idx,
tag: b.tag || '',
strategy: b.strategy?.type || 'random',
strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
selector: b.selector || [],
fallbackTag: b.fallbackTag || '',
settings: b.strategy?.settings,
}));
}, [templateSettings?.routing?.balancers]);
@@ -159,6 +161,9 @@ export default function BalancersTab({
};
if (form.strategy && form.strategy !== 'random') {
wire.strategy = { type: form.strategy };
if (form.strategy === 'leastLoad' && form.settings) {
wire.strategy.settings = form.settings;
}
}
if (editingIndex == null) {
list.push(wire);
@@ -192,84 +197,80 @@ export default function BalancersTab({
});
}
const columns: ColumnsType<BalancerRow> = useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
]
: []),
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
const columns: ColumnsType<BalancerRow> = [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
]
: []),
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
),
},
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{
title: 'Strategy',
key: 'strategy',
align: 'center',
width: 140,
render: (_v, record) => (
<Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
{STRATEGY_LABELS[record.strategy] || record.strategy}
</div>
),
},
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{
title: 'Strategy',
key: 'strategy',
align: 'center',
width: 140,
render: (_v, record) => (
<Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
{STRATEGY_LABELS[record.strategy] || record.strategy}
</Tag>
),
},
{
title: 'Selector',
key: 'selector',
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag">
{sel}
</Tag>
),
},
{
title: 'Selector',
key: 'selector',
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag">
{sel}
</Tag>
)),
},
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, isMobile],
);
)),
},
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
];
const hasObservatory = !!templateSettings?.observatory;
const hasBurstObservatory = !!templateSettings?.burstObservatory;
@@ -354,6 +355,7 @@ export default function BalancersTab({
</Space>
<BalancerFormModal
key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
open={modalOpen}
balancer={editingBalancer}
outboundTags={outboundTags}

View File

@@ -1,29 +1,23 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
DnsServerObjectSchema,
type DnsServerObject,
} from '@/schemas/dns';
import { antdRule } from '@/utils/zodForm';
export type DnsServerValue =
| string
| {
address: string;
port?: number;
domains?: string[];
expectedIPs?: string[];
| (DnsServerObject & {
expectIPs?: string[];
unexpectedIPs?: string[];
queryStrategy?: string;
skipFallback?: boolean;
disableCache?: boolean;
finalQuery?: boolean;
tag?: string;
clientIP?: string;
serveStale?: boolean;
serveExpiredTTL?: number;
timeoutMs?: number;
[key: string]: unknown;
};
});
interface DnsServerModalProps {
open: boolean;
@@ -33,9 +27,9 @@ interface DnsServerModalProps {
onConfirm: (value: DnsServerValue) => void;
}
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
const STRATEGIES = DnsQueryStrategySchema.options;
interface DnsForm {
type DnsServerForm = {
address: string;
port: number;
domains: string[];
@@ -50,9 +44,9 @@ interface DnsForm {
serveStale: boolean;
serveExpiredTTL: number;
timeoutMs: number;
}
};
function defaultServer(): DnsForm {
function defaultFormValues(): DnsServerForm {
return {
address: 'localhost',
port: 53,
@@ -71,6 +65,68 @@ function defaultServer(): DnsForm {
};
}
function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
if (server == null) return defaultFormValues();
if (typeof server === 'string') return { ...defaultFormValues(), address: server };
const parsed = DnsServerObjectSchema.safeParse(server);
const data = parsed.success ? parsed.data : null;
return {
...defaultFormValues(),
...(data ?? {}),
address: (data?.address ?? server.address) || 'localhost',
domains: data?.domains ?? server.domains ?? [],
expectedIPs: data?.expectedIPs ?? server.expectedIPs ?? server.expectIPs ?? [],
unexpectedIPs: data?.unexpectedIPs ?? server.unexpectedIPs ?? [],
queryStrategy: data?.queryStrategy ?? server.queryStrategy ?? 'UseIP',
skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
disableCache: data?.disableCache ?? server.disableCache ?? false,
finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
tag: data?.tag ?? server.tag ?? '',
clientIP: data?.clientIP ?? server.clientIP ?? '',
serveStale: data?.serveStale ?? server.serveStale ?? false,
serveExpiredTTL: data?.serveExpiredTTL ?? server.serveExpiredTTL ?? 0,
timeoutMs: data?.timeoutMs ?? server.timeoutMs ?? 4000,
};
}
function valuesToWire(values: DnsServerForm): DnsServerValue {
const isPlain
= values.domains.length === 0
&& values.expectedIPs.length === 0
&& values.unexpectedIPs.length === 0
&& values.port === 53
&& values.queryStrategy === 'UseIP'
&& values.skipFallback === false
&& values.disableCache === false
&& values.finalQuery === false
&& !values.tag
&& !values.clientIP
&& values.serveStale === false
&& values.serveExpiredTTL === 0
&& values.timeoutMs === 4000;
if (isPlain) return values.address;
const out: Record<string, unknown> = {
address: values.address,
port: values.port,
domains: values.domains.filter(Boolean),
expectedIPs: values.expectedIPs.filter(Boolean),
unexpectedIPs: values.unexpectedIPs.filter(Boolean),
queryStrategy: values.queryStrategy,
skipFallback: values.skipFallback,
disableCache: values.disableCache,
finalQuery: values.finalQuery,
serveStale: values.serveStale,
serveExpiredTTL: values.serveExpiredTTL,
timeoutMs: values.timeoutMs,
};
if (values.tag) out.tag = values.tag;
if (values.clientIP) out.clientIP = values.clientIP;
return out as DnsServerValue;
}
const shape = DnsServerObjectInnerSchema.shape;
export default function DnsServerModal({
open,
server,
@@ -79,74 +135,16 @@ export default function DnsServerModal({
onConfirm,
}: DnsServerModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<DnsForm>(defaultServer());
const [form] = Form.useForm<DnsServerForm>();
useEffect(() => {
if (!open) return;
if (server == null) {
setForm(defaultServer());
return;
}
if (typeof server === 'string') {
setForm({ ...defaultServer(), address: server });
return;
}
setForm({
...defaultServer(),
...server,
domains: [...(server.domains || [])],
expectedIPs: [...(server.expectedIPs || server.expectIPs || [])],
unexpectedIPs: [...(server.unexpectedIPs || [])],
});
}, [open, server]);
form.setFieldsValue(valuesFromServer(server));
}, [open, server, form]);
const update = <K extends keyof DnsForm>(key: K, value: DnsForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
function updateList(key: 'domains' | 'expectedIPs' | 'unexpectedIPs', mutator: (next: string[]) => void) {
setForm((prev) => {
const next = [...prev[key]];
mutator(next);
return { ...prev, [key]: next };
});
}
function submit() {
const isPlain =
form.domains.length === 0 &&
form.expectedIPs.length === 0 &&
form.unexpectedIPs.length === 0 &&
form.port === 53 &&
form.queryStrategy === 'UseIP' &&
form.skipFallback === false &&
form.disableCache === false &&
form.finalQuery === false &&
!form.tag &&
!form.clientIP &&
form.serveStale === false &&
form.serveExpiredTTL === 0 &&
form.timeoutMs === 4000;
if (isPlain) {
onConfirm(form.address);
return;
}
const out: Record<string, unknown> = {
address: form.address,
port: form.port,
domains: form.domains.filter(Boolean),
expectedIPs: form.expectedIPs.filter(Boolean),
unexpectedIPs: form.unexpectedIPs.filter(Boolean),
queryStrategy: form.queryStrategy,
skipFallback: form.skipFallback,
disableCache: form.disableCache,
finalQuery: form.finalQuery,
serveStale: form.serveStale,
serveExpiredTTL: form.serveExpiredTTL,
timeoutMs: form.timeoutMs,
};
if (form.tag) out.tag = form.tag;
if (form.clientIP) out.clientIP = form.clientIP;
onConfirm(out as DnsServerValue);
async function submit() {
const values = await form.validateFields();
onConfirm(valuesToWire(values));
}
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
@@ -161,99 +159,119 @@ export default function DnsServerModal({
onOk={submit}
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('pages.inbounds.address')}>
<Input value={form.address} onChange={(e) => update('address', e.target.value)} />
<Form
form={form}
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
initialValues={defaultFormValues()}
>
<Form.Item
label={t('pages.inbounds.address')}
name="address"
rules={[antdRule(shape.address, t)]}
>
<Input />
</Form.Item>
<Form.Item label={t('pages.inbounds.port')}>
<InputNumber value={form.port} min={1} max={65535} onChange={(v) => update('port', Number(v) || 53)} />
<Form.Item
label={t('pages.inbounds.port')}
name="port"
rules={[antdRule(shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.tag')}>
<Input value={form.tag} onChange={(e) => update('tag', e.target.value)} />
<Form.Item label={t('pages.xray.dns.tag')} name="tag">
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.dns.clientIp')}>
<Input value={form.clientIP} onChange={(e) => update('clientIP', e.target.value)} />
<Form.Item label={t('pages.xray.dns.clientIp')} name="clientIP">
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.dns.strategy')}>
<Form.Item label={t('pages.xray.dns.strategy')} name="queryStrategy">
<Select
value={form.queryStrategy}
onChange={(v) => update('queryStrategy', v)}
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
/>
</Form.Item>
<Form.Item label={t('pages.xray.dns.timeoutMs')}>
<InputNumber value={form.timeoutMs} min={0} step={500} onChange={(v) => update('timeoutMs', Number(v) || 0)} />
<Form.Item
label={t('pages.xray.dns.timeoutMs')}
name="timeoutMs"
rules={[antdRule(shape.timeoutMs, t)]}
>
<InputNumber min={0} step={500} />
</Form.Item>
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.domains')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} />
{form.domains.map((value, idx) => (
<Space.Compact key={`d${idx}`} block style={{ marginTop: 4 }}>
<Input
value={value}
onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })}
/>
<InputAddon onClick={() => updateList('domains', (d) => d.splice(idx, 1))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.List name="domains">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.domains')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
{form.expectedIPs.map((value, idx) => (
<Space.Compact key={`e${idx}`} block style={{ marginTop: 4 }}>
<Input
value={value}
onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
/>
<InputAddon onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.List name="expectedIPs">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
{form.unexpectedIPs.map((value, idx) => (
<Space.Compact key={`u${idx}`} block style={{ marginTop: 4 }}>
<Input
value={value}
onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
/>
<InputAddon onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.List name="unexpectedIPs">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.skipFallback')}>
<Switch checked={form.skipFallback} onChange={(v) => update('skipFallback', v)} />
<Form.Item label={t('pages.xray.dns.skipFallback')} name="skipFallback" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.finalQuery')}>
<Switch checked={form.finalQuery} onChange={(v) => update('finalQuery', v)} />
<Form.Item label={t('pages.xray.dns.finalQuery')} name="finalQuery" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.disableCache')}>
<Switch checked={form.disableCache} onChange={(v) => update('disableCache', v)} />
<Form.Item label={t('pages.xray.dns.disableCache')} name="disableCache" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.serveStale')}>
<Switch checked={form.serveStale} onChange={(v) => update('serveStale', v)} />
<Form.Item label={t('pages.xray.dns.serveStale')} name="serveStale" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.serveExpiredTTL')}>
<InputNumber
value={form.serveExpiredTTL}
min={0}
step={60}
onChange={(v) => update('serveExpiredTTL', Number(v) || 0)}
/>
<Form.Item label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
<InputNumber min={0} step={60} />
</Form.Item>
</Form>
</Modal>

View File

@@ -9,6 +9,7 @@ import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import { DnsQueryStrategySchema, type DnsObject } from '@/schemas/dns';
import './DnsTab.css';
interface DnsTabProps {
@@ -16,23 +17,10 @@ interface DnsTabProps {
setTemplateSettings: SetTemplate;
}
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
const STRATEGIES = DnsQueryStrategySchema.options;
const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
interface DnsConfig {
tag?: string;
clientIp?: string;
queryStrategy?: string;
disableCache?: boolean;
disableFallback?: boolean;
disableFallbackIfMatch?: boolean;
enableParallelQuery?: boolean;
useSystemHosts?: boolean;
serveStale?: boolean;
serveExpiredTTL?: number;
hosts?: Record<string, string | string[]>;
servers?: DnsServerValue[];
}
type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
interface HostRow {
domain: string;

View File

@@ -17,6 +17,7 @@ import type { ColumnsType } from 'antd/es/table';
import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type { RuleObject } from '@/schemas/routing';
import './RoutingTab.css';
interface RoutingTabProps {
@@ -182,8 +183,9 @@ export default function RoutingTab({
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [] };
if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
if (editingIndex == null) tt.routing.rules.push(rule);
else tt.routing.rules[editingIndex] = rule;
const typed = rule as unknown as RuleObject;
if (editingIndex == null) tt.routing.rules.push(typed);
else tt.routing.rules[editingIndex] = typed;
});
setRuleModalOpen(false);
}

View File

@@ -0,0 +1,64 @@
import { z } from 'zod';
import { PortSchema } from '@/schemas/primitives';
export const DnsQueryStrategySchema = z.enum([
'UseIP',
'UseIPv4',
'UseIPv6',
'UseSystem',
]);
export type DnsQueryStrategy = z.infer<typeof DnsQueryStrategySchema>;
const DnsHostValueSchema = z.union([z.string(), z.array(z.string())]);
export const DnsHostsSchema = z.record(z.string(), DnsHostValueSchema);
export type DnsHosts = z.infer<typeof DnsHostsSchema>;
export const DnsServerObjectInnerSchema = z.object({
address: z.string(),
port: PortSchema.default(53),
domains: z.array(z.string()).optional(),
expectedIPs: z.array(z.string()).optional(),
unexpectedIPs: z.array(z.string()).optional(),
skipFallback: z.boolean().optional(),
finalQuery: z.boolean().optional(),
tag: z.string().optional(),
clientIP: z.string().optional(),
queryStrategy: DnsQueryStrategySchema.optional(),
disableCache: z.boolean().optional(),
timeoutMs: z.number().int().min(0).default(4000),
serveStale: z.boolean().optional(),
serveExpiredTTL: z.number().int().min(0).optional(),
});
export const DnsServerObjectSchema = z.preprocess(
(val) => {
if (typeof val !== 'object' || val === null || Array.isArray(val)) return val;
const v = val as Record<string, unknown>;
if (v.expectIPs && !v.expectedIPs) {
return { ...v, expectedIPs: v.expectIPs };
}
return val;
},
DnsServerObjectInnerSchema,
);
export type DnsServerObject = z.infer<typeof DnsServerObjectSchema>;
export const DnsServerEntrySchema = z.union([z.string(), DnsServerObjectSchema]);
export type DnsServerEntry = z.infer<typeof DnsServerEntrySchema>;
export const DnsObjectSchema = z.object({
tag: z.string().optional(),
hosts: DnsHostsSchema.optional(),
servers: z.array(DnsServerEntrySchema).optional(),
clientIp: z.string().optional(),
queryStrategy: DnsQueryStrategySchema.default('UseIP'),
disableCache: z.boolean().default(false),
disableFallback: z.boolean().default(false),
disableFallbackIfMatch: z.boolean().default(false),
enableParallelQuery: z.boolean().default(false),
useSystemHosts: z.boolean().default(false),
serveStale: z.boolean().default(false),
serveExpiredTTL: z.number().int().min(0).default(0),
});
export type DnsObject = z.infer<typeof DnsObjectSchema>;

View File

@@ -0,0 +1,77 @@
import { z } from 'zod';
export const RuleProtocolSchema = z.enum(['http', 'tls', 'quic', 'bittorrent']);
export type RuleProtocol = z.infer<typeof RuleProtocolSchema>;
const PortValueSchema = z.union([
z.number().int().min(0).max(65535),
z.string(),
]);
export const RuleWebhookSchema = z.object({
url: z.string(),
deduplication: z.number().int().min(0).optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
export const RuleObjectSchema = z.object({
type: z.literal('field').default('field'),
domain: z.array(z.string()).optional(),
ip: z.array(z.string()).optional(),
port: PortValueSchema.optional(),
sourcePort: PortValueSchema.optional(),
localPort: PortValueSchema.optional(),
network: z.string().optional(),
sourceIP: z.array(z.string()).optional(),
localIP: z.array(z.string()).optional(),
user: z.array(z.string()).optional(),
vlessRoute: PortValueSchema.optional(),
inboundTag: z.array(z.string()).optional(),
protocol: z.array(z.string()).optional(),
attrs: z.record(z.string(), z.string()).optional(),
process: z.array(z.string()).optional(),
outboundTag: z.string().optional(),
balancerTag: z.string().optional(),
ruleTag: z.string().optional(),
webhook: RuleWebhookSchema.optional(),
});
export type RuleObject = z.infer<typeof RuleObjectSchema>;
export const BalancerStrategyTypeSchema = z.enum([
'random',
'roundRobin',
'leastPing',
'leastLoad',
]);
export type BalancerStrategyType = z.infer<typeof BalancerStrategyTypeSchema>;
export const BalancerCostObjectSchema = z.object({
regexp: z.boolean().default(false),
match: z.string(),
value: z.number(),
});
export type BalancerCostObject = z.infer<typeof BalancerCostObjectSchema>;
export const BalancerStrategySettingsSchema = z.object({
expected: z.number().int().min(0).optional(),
maxRTT: z.string().optional(),
tolerance: z.number().min(0).max(1).optional(),
baselines: z.array(z.string()).optional(),
costs: z.array(BalancerCostObjectSchema).optional(),
});
export type BalancerStrategySettings = z.infer<typeof BalancerStrategySettingsSchema>;
export const BalancerStrategySchema = z.object({
type: BalancerStrategyTypeSchema.default('random'),
settings: BalancerStrategySettingsSchema.optional(),
});
export type BalancerStrategy = z.infer<typeof BalancerStrategySchema>;
export const BalancerObjectSchema = z.object({
tag: z.string().trim().min(1),
selector: z.array(z.string()).min(1),
fallbackTag: z.string().optional(),
strategy: BalancerStrategySchema.optional(),
});
export type BalancerObject = z.infer<typeof BalancerObjectSchema>;

View File

@@ -1,4 +1,11 @@
import { z } from 'zod';
import { DnsObjectSchema } from './dns';
import {
BalancerObjectSchema,
BalancerStrategySettingsSchema,
BalancerStrategyTypeSchema,
RuleObjectSchema,
} from './routing';
export const XraySettingsValueSchema = z.object({
inbounds: z.array(z.unknown()).optional(),
@@ -13,18 +20,11 @@ export const XraySettingsValueSchema = z.object({
)
.optional(),
routing: z.object({
rules: z.array(z.object({
type: z.string().optional(),
outboundTag: z.string().optional(),
balancerTag: z.string().optional(),
}).loose()).optional(),
balancers: z.array(z.unknown()).optional(),
rules: z.array(RuleObjectSchema).optional(),
balancers: z.array(BalancerObjectSchema).optional(),
domainStrategy: z.string().optional(),
}).loose().optional(),
dns: z.object({
tag: z.string().optional(),
servers: z.array(z.unknown()).optional(),
}).loose().optional(),
dns: DnsObjectSchema.optional(),
log: z.record(z.string(), z.unknown()).optional(),
policy: z.object({
system: z.record(z.string(), z.boolean()).optional(),
@@ -109,9 +109,10 @@ export const RuleFormSchema = z.object({
export const BalancerFormSchema = z.object({
tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'),
strategy: z.string(),
strategy: BalancerStrategyTypeSchema.default('random'),
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
fallbackTag: z.string(),
fallbackTag: z.string().default(''),
settings: BalancerStrategySettingsSchema.optional(),
});
export const OutboundTagSchema = z

View File

@@ -791,6 +791,8 @@
"InboundsDesc": "Accepting the specific clients.",
"Outbounds": "Outbounds",
"Balancers": "Balancers",
"balancerTagRequired": "Tag is required",
"balancerSelectorRequired": "Pick at least one outbound",
"OutboundsDesc": "Set the outgoing traffic pathway.",
"Routings": "Routing Rules",
"RoutingsDesc": "The priority of each rule is important!",

View File

@@ -791,6 +791,8 @@
"InboundsDesc": "پذیرش کلاینت خاص",
"Outbounds": "خروجی‌ها",
"Balancers": "بالانسرها",
"balancerTagRequired": "تگ الزامی است",
"balancerSelectorRequired": "حداقل یک outbound انتخاب کنید",
"OutboundsDesc": "مسیر ترافیک خروجی را تنظیم کنید",
"Routings": "قوانین مسیریابی",
"RoutingsDesc": "اولویت هر قانون مهم است",