Browse Source

feat(outbound): sync DNS outbound config with Xray core changes

Rename the DNS rule wire key qtype to qType (reading the legacy qtype on parse for back-compat), add the new rCode response-code field for the return action (omitted when zero), and rename the reject action to return. Align the DNS rule action set across the form dropdown, schema, and adapter to the core's valid values (direct/drop/return/hijack), dropping the never-valid rejectIPv4/rejectIPv6 entries.
MHSanaei 6 hours ago
parent
commit
2bb9ed1cda

+ 13 - 11
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;
 }
 

+ 5 - 2
frontend/src/pages/xray/outbounds/protocols/dns.tsx

@@ -35,7 +35,7 @@ export default function DnsFields() {
                 size="small"
                 type="primary"
                 icon={<PlusOutlined />}
-                onClick={() => add({ action: 'direct', qtype: '', domain: '' })}
+                onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
               />
             </Form.Item>
             {fields.map((field, index) => (
@@ -54,12 +54,15 @@ export default function DnsFields() {
                     options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
                   />
                 </Form.Item>
-                <Form.Item label="QType" name={[field.name, 'qtype']}>
+                <Form.Item label="QType" name={[field.name, 'qType']}>
                   <Input placeholder="1,3,23-24" />
                 </Form.Item>
                 <Form.Item label={t('domainName')} name={[field.name, 'domain']}>
                   <Input placeholder="domain:example.com" />
                 </Form.Item>
+                <Form.Item label="RCode" name={[field.name, 'rCode']}>
+                  <InputNumber min={0} max={65535} style={{ width: '100%' }} />
+                </Form.Item>
               </div>
             ))}
           </>

+ 4 - 3
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<typeof BlackholeOutboundFormSettingsSchema>;
 
-// 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 <Input>). 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<typeof DnsRuleFormSchema>;
 

+ 1 - 1
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',

+ 4 - 3
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<typeof DNSRuleSchema>;
 

+ 15 - 5
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<string, unknown>;
     const rules = settings.rules as Array<Record<string, unknown>>;
-    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<string, unknown>).rules as Array<Record<string, unknown>>;
+    expect(rules[0]).toEqual({ action: 'direct', qType: 'TXT' });
   });
 
   it('freedom emits domainStrategy/redirect/fragment conditionally', () => {