diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index 254880e0..200f6974 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -292,19 +292,20 @@ function blackholeFromWire(raw: Raw) { function dnsRuleFromWire(raw: unknown): DnsRuleForm { const r = asObject(raw); - const qtype = Array.isArray(r.qtype) - ? r.qtype.map((x) => String(x)).join(',') - : typeof r.qtype === 'number' - ? String(r.qtype) - : asString(r.qtype); + const rawQType = r.qType ?? r.qtype; + const qType = Array.isArray(rawQType) + ? rawQType.map((x) => String(x)).join(',') + : typeof rawQType === 'number' + ? String(rawQType) + : asString(rawQType); const domain = Array.isArray(r.domain) ? r.domain.map((x) => asString(x)).join(',') : asString(r.domain); const action = asString(r.action, 'direct'); - const validAction = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(action) + const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action) ? action : 'direct'; - return { action: validAction as DnsRuleForm['action'], qtype, domain }; + return { action: validAction as DnsRuleForm['action'], qType, domain, rCode: asNumber(r.rCode, 0) }; } function dnsFromWire(raw: Raw): DnsOutboundFormSettings { @@ -536,16 +537,17 @@ function blackholeToWire(s: { type: '' | 'none' | 'http' }) { } function dnsRuleToWire(r: DnsRuleForm) { - const action = ['direct', 'reject', 'rejectIPv4', 'rejectIPv6'].includes(r.action) + const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action) ? r.action : 'direct'; const result: Raw = { action }; - const qtype = r.qtype.trim(); - if (qtype) { - result.qtype = /^\d+$/.test(qtype) ? Number(qtype) : qtype; + const qType = r.qType.trim(); + if (qType) { + result.qType = /^\d+$/.test(qType) ? Number(qType) : qType; } const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean); if (domains.length > 0) result.domain = domains; + if (r.rCode > 0) result.rCode = r.rCode; return result; } diff --git a/frontend/src/pages/xray/outbounds/protocols/dns.tsx b/frontend/src/pages/xray/outbounds/protocols/dns.tsx index 85940d15..3d3cc8c7 100644 --- a/frontend/src/pages/xray/outbounds/protocols/dns.tsx +++ b/frontend/src/pages/xray/outbounds/protocols/dns.tsx @@ -35,7 +35,7 @@ export default function DnsFields() { size="small" type="primary" icon={} - onClick={() => add({ action: 'direct', qtype: '', domain: '' })} + onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })} /> {fields.map((field, index) => ( @@ -54,12 +54,15 @@ export default function DnsFields() { options={DNSRuleActions.map((a) => ({ value: a, label: a }))} /> - + + + + ))} diff --git a/frontend/src/schemas/forms/outbound-form.ts b/frontend/src/schemas/forms/outbound-form.ts index 14e94dd9..4bff024d 100644 --- a/frontend/src/schemas/forms/outbound-form.ts +++ b/frontend/src/schemas/forms/outbound-form.ts @@ -29,7 +29,7 @@ import { // the adapter wraps them as { reverse: { tag, sniffing } } on the wire. // - blackhole `type` ('' | 'none' | 'http') is flat; the adapter wraps it // as { response: { type } } on the wire (omitted when empty). -// - DNS rules carry `qtype` and `domain` as comma-joined strings (matches +// - DNS rules carry `qType` and `domain` as comma-joined strings (matches // the legacy DNSRule UI). The adapter normalizes them on submit. // // All flat-form settings types are documented inline so the adapter has a @@ -186,12 +186,13 @@ export const BlackholeOutboundFormSettingsSchema = z.object({ }); export type BlackholeOutboundFormSettings = z.infer; -// DNS rules: form holds qtype + domain as joined strings (the legacy UI +// DNS rules: form holds qType + domain as joined strings (the legacy UI // binds to ). Adapter parses them on submit per the DNSRule class. export const DnsRuleFormSchema = z.object({ action: DNSRuleActionSchema.default('direct'), - qtype: z.string().default(''), + qType: z.string().default(''), domain: z.string().default(''), + rCode: z.number().int().min(0).max(65535).default(0), }); export type DnsRuleForm = z.infer; diff --git a/frontend/src/schemas/primitives/options.ts b/frontend/src/schemas/primitives/options.ts index 79cce3c5..bfd51ecb 100644 --- a/frontend/src/schemas/primitives/options.ts +++ b/frontend/src/schemas/primitives/options.ts @@ -59,7 +59,7 @@ export const Address_Port_Strategy = Object.freeze({ TXT_PORT_AND_ADDRESS: 'TxtPortAndAddress', }); -export const DNSRuleActions = Object.freeze(['direct', 'drop', 'reject', 'hijack'] as const); +export const DNSRuleActions = Object.freeze(['direct', 'drop', 'return', 'hijack'] as const); export const TLS_VERSION_OPTION = Object.freeze({ TLS10: '1.0', diff --git a/frontend/src/schemas/protocols/outbound/dns.ts b/frontend/src/schemas/protocols/outbound/dns.ts index 58dcecb2..c241b788 100644 --- a/frontend/src/schemas/protocols/outbound/dns.ts +++ b/frontend/src/schemas/protocols/outbound/dns.ts @@ -2,15 +2,16 @@ import { z } from 'zod'; import { PortSchema } from '@/schemas/primitives'; -export const DNSRuleActionSchema = z.enum(['direct', 'reject', 'rejectIPv4', 'rejectIPv6']); +export const DNSRuleActionSchema = z.enum(['direct', 'drop', 'return', 'hijack']); -// On the wire `qtype` is either a number (DNS type code) or a string like +// On the wire `qType` is either a number (DNS type code) or a string like // "A"/"AAAA"/"TXT"; the panel normalizes numeric strings to numbers in // toJson. `domain` is a string[] (split from a comma-joined input). export const DNSRuleSchema = z.object({ action: DNSRuleActionSchema.default('direct'), - qtype: z.union([z.string(), z.number().int()]).optional(), + qType: z.union([z.string(), z.number().int()]).optional(), domain: z.array(z.string()).optional(), + rCode: z.number().int().min(0).max(65535).optional(), }); export type DNSRule = z.infer; diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index 738ddfdc..8fe31f00 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/frontend/src/test/outbound-form-adapter.test.ts @@ -197,7 +197,7 @@ describe('outbound-form-adapter: round-trip', () => { expect(withType.settings).toEqual({ response: { type: 'http' } }); }); - it('dns rules normalize qtype numeric strings and split domains', () => { + it('dns rules normalize qType numeric strings, split domains, carry rCode', () => { const wire = { protocol: 'dns', settings: { @@ -205,16 +205,26 @@ describe('outbound-form-adapter: round-trip', () => { rewriteAddress: '1.1.1.1', rewritePort: 53, rules: [ - { action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] }, - { action: 'reject', qtype: 28, domain: 'blocked.com' }, + { action: 'direct', qType: 'A,AAAA', domain: ['example.com', 'ext.org'] }, + { action: 'return', qType: 28, domain: 'blocked.com', rCode: 3 }, ], }, }; const back = formValuesToWirePayload(rawOutboundToFormValues(wire)); const settings = back.settings as Record; const rules = settings.rules as Array>; - expect(rules[0]).toEqual({ action: 'direct', qtype: 'A,AAAA', domain: ['example.com', 'ext.org'] }); - expect(rules[1]).toEqual({ action: 'reject', qtype: 28, domain: ['blocked.com'] }); + expect(rules[0]).toEqual({ action: 'direct', qType: 'A,AAAA', domain: ['example.com', 'ext.org'] }); + expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 }); + }); + + it('dns rules read the legacy qtype wire key for back-compat', () => { + const wire = { + protocol: 'dns', + settings: { rules: [{ action: 'direct', qtype: 'TXT' }] }, + }; + const back = formValuesToWirePayload(rawOutboundToFormValues(wire)); + const rules = (back.settings as Record).rules as Array>; + expect(rules[0]).toEqual({ action: 'direct', qType: 'TXT' }); }); it('freedom emits domainStrategy/redirect/fragment conditionally', () => {