import { RestartAltRounded } from '@mui/icons-material' import { Alert, Box, Button, FormControl, List, ListItem, ListItemText, MenuItem, Select, styled, TextField, Typography, } from '@mui/material' import { useLockFn } from 'ahooks' import * as yaml from 'js-yaml' import type { Ref } from 'react' import { useCallback, useEffect, useReducer, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { BaseDialog, type DialogRef, MonacoEditor, Switch, } from '@/components/base' import { useClash } from '@/hooks/use-clash' import { useProfiles } from '@/hooks/use-profiles' import { useVerge } from '@/hooks/use-verge' import { applyDnsConfig, getDnsConfigContent, saveDnsConfig, validateDnsConfig, } from '@/services/cmds' import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { MonacoEditorInstance } from '@/types/monaco' import { MONACO_FONT_FAMILY } from '@/utils/font-family' import { useSettingDialog } from '../settings-search-context' const Item = styled(ListItem)(() => ({ padding: '5px 2px', '& textarea': { lineHeight: 1.5, fontSize: 14, resize: 'vertical', }, })) type NameserverPolicy = Record function parseNameserverPolicy(str: string): NameserverPolicy { const result: NameserverPolicy = {} if (!str) return result const ruleRegex = /\s*([^=]+?)\s*=\s*([^,]+)(?:,|$)/g const matchs = [...str.matchAll(ruleRegex)] matchs.forEach((match) => { const [, domainsPart, serversPart] = match const domains = [domainsPart.trim()] const servers = serversPart.split(';').map((s) => s.trim()) domains.forEach((domain) => { result[domain] = servers }) }) return result } function formatNameserverPolicy(policy: unknown): string { if (!policy || typeof policy !== 'object') return '' return Object.entries(policy as Record) .map(([domain, servers]) => { const serversStr = Array.isArray(servers) ? servers.join(';') : servers return `${domain}=${serversStr}` }) .join(', ') } function formatHosts(hosts: unknown): string { if (!hosts || typeof hosts !== 'object') return '' const result: string[] = [] Object.entries(hosts as Record).forEach( ([domain, value]) => { if (Array.isArray(value)) { const ipsStr = value.join(';') result.push(`${domain}=${ipsStr}`) } else { result.push(`${domain}=${value}`) } }, ) return result.join(', ') } function parseHosts(str: string): NameserverPolicy { const result: NameserverPolicy = {} if (!str) return result str.split(',').forEach((item) => { const parts = item.trim().split('=') if (parts.length < 2) return const domain = parts[0].trim() const valueStr = parts.slice(1).join('=').trim() if (valueStr.includes(';')) { result[domain] = valueStr .split(';') .map((s) => s.trim()) .filter(Boolean) } else { result[domain] = valueStr } }) return result } function parseList(str: string): string[] { if (!str?.trim()) return [] return str .split(',') .map((item) => item.trim()) .filter(Boolean) } const DEFAULT_DNS_CONFIG = { enable: true, listen: ':53', 'enhanced-mode': 'fake-ip' as 'fake-ip' | 'redir-host', 'fake-ip-range': '198.18.0.1/16', 'fake-ip-range6': 'fdfe:dcba:9876::1/64', 'fake-ip-filter-mode': 'blacklist' as 'blacklist' | 'whitelist', 'prefer-h3': false, 'respect-rules': false, 'use-hosts': false, 'use-system-hosts': false, ipv6: true, 'fake-ip-filter': [ '*.lan', '*.local', '*.arpa', 'time.*.com', 'ntp.*.com', 'time.*.com', '+.market.xiaomi.com', 'localhost.ptlogin2.qq.com', '*.msftncsi.com', 'www.msftconnecttest.com', ], 'default-nameserver': [ 'system', '223.6.6.6', '8.8.8.8', '2400:3200::1', '2001:4860:4860::8888', ], nameserver: [ '8.8.8.8', 'https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query', ], fallback: [], 'nameserver-policy': {}, 'proxy-server-nameserver': [], 'direct-nameserver': [], 'direct-nameserver-follow-policy': false, 'fallback-filter': { geoip: true, 'geoip-code': 'CN', ipcidr: ['240.0.0.0/4', '0.0.0.0/32'], domain: ['+.google.com', '+.facebook.com', '+.youtube.com'], }, } type DnsFormValues = { enable: boolean listen: string enhancedMode: 'fake-ip' | 'redir-host' fakeIpRange: string fakeIpRange6: string fakeIpFilterMode: 'blacklist' | 'whitelist' preferH3: boolean respectRules: boolean useHosts: boolean useSystemHosts: boolean ipv6: boolean fakeIpFilter: string nameserver: string fallback: string defaultNameserver: string proxyServerNameserver: string directNameserver: string directNameserverFollowPolicy: boolean fallbackGeoip: boolean fallbackGeoipCode: string fallbackIpcidr: string fallbackDomain: string nameserverPolicy: string hosts: string // hosts设置,独立于dns } const DEFAULT_FORM_VALUES: DnsFormValues = { enable: DEFAULT_DNS_CONFIG.enable, listen: DEFAULT_DNS_CONFIG.listen, enhancedMode: DEFAULT_DNS_CONFIG['enhanced-mode'], fakeIpRange: DEFAULT_DNS_CONFIG['fake-ip-range'], fakeIpRange6: DEFAULT_DNS_CONFIG['fake-ip-range6'], fakeIpFilterMode: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'], preferH3: DEFAULT_DNS_CONFIG['prefer-h3'], respectRules: DEFAULT_DNS_CONFIG['respect-rules'], useHosts: DEFAULT_DNS_CONFIG['use-hosts'], useSystemHosts: DEFAULT_DNS_CONFIG['use-system-hosts'], ipv6: DEFAULT_DNS_CONFIG.ipv6, fakeIpFilter: DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '), defaultNameserver: DEFAULT_DNS_CONFIG['default-nameserver'].join(', '), nameserver: DEFAULT_DNS_CONFIG.nameserver.join(', '), fallback: DEFAULT_DNS_CONFIG.fallback.join(', '), proxyServerNameserver: DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || '', directNameserver: DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || '', directNameserverFollowPolicy: DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'] || false, fallbackGeoip: DEFAULT_DNS_CONFIG['fallback-filter'].geoip, fallbackGeoipCode: DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'], fallbackIpcidr: DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr?.join(', ') || '', fallbackDomain: DEFAULT_DNS_CONFIG['fallback-filter'].domain?.join(', ') || '', nameserverPolicy: '', hosts: '', } function buildDnsConfig(values: DnsFormValues) { const dnsConfig: any = { enable: values.enable, listen: values.listen, 'enhanced-mode': values.enhancedMode, 'fake-ip-range': values.fakeIpRange, 'fake-ip-range6': values.fakeIpRange6, 'fake-ip-filter-mode': values.fakeIpFilterMode, 'prefer-h3': values.preferH3, 'respect-rules': values.respectRules, 'use-hosts': values.useHosts, 'use-system-hosts': values.useSystemHosts, ipv6: values.ipv6, 'fake-ip-filter': parseList(values.fakeIpFilter), 'default-nameserver': parseList(values.defaultNameserver), nameserver: parseList(values.nameserver), 'nameserver-policy': parseNameserverPolicy(values.nameserverPolicy), 'direct-nameserver-follow-policy': values.directNameserverFollowPolicy, 'fallback-filter': { geoip: values.fallbackGeoip, 'geoip-code': values.fallbackGeoipCode, ipcidr: parseList(values.fallbackIpcidr), domain: parseList(values.fallbackDomain), }, fallback: parseList(values.fallback), 'proxy-server-nameserver': parseList(values.proxyServerNameserver), 'direct-nameserver': parseList(values.directNameserver), } return dnsConfig } function buildDnsYaml(values: DnsFormValues) { const config: Record = {} const dnsConfig = buildDnsConfig(values) if (Object.keys(dnsConfig).length > 0) { config.dns = dnsConfig } config.hosts = parseHosts(values.hosts) return yaml.dump(config, { forceQuotes: true }) } export function DnsViewer({ ref }: { ref?: Ref }) { const { t } = useTranslation() const { mutateClash } = useClash() const { verge } = useVerge() const { current: currentProfile } = useProfiles() const dnsEnabled = currentProfile ? (verge?.profile_dns_settings?.[currentProfile.uid!]?.enabled ?? verge?.enable_dns_settings ?? false) : false const themeMode = useThemeMode() const [open, setOpen] = useState(false) const [initializing, setInitializing] = useState(false) const [visualization, setVisualization] = useState(true) const editorRef = useRef(null) const [values, setValues] = useState(DEFAULT_FORM_VALUES) // Prevent YAML-to-form sync before configuration loads. const [yamlContent, setYamlContent] = useReducer( (_: string, next: string) => next, '{ isInitValue: true }', ) const updateValuesFromConfig = useCallback((config: any) => { if (!config) return undefined const dnsConfig = config.dns || {} const hostsConfig = config.hosts || {} const enhancedMode = dnsConfig['enhanced-mode'] || DEFAULT_DNS_CONFIG['enhanced-mode'] const validEnhancedMode = enhancedMode === 'fake-ip' || enhancedMode === 'redir-host' ? enhancedMode : DEFAULT_DNS_CONFIG['enhanced-mode'] const fakeIpFilterMode = dnsConfig['fake-ip-filter-mode'] || DEFAULT_DNS_CONFIG['fake-ip-filter-mode'] const validFakeIpFilterMode = fakeIpFilterMode === 'blacklist' || fakeIpFilterMode === 'whitelist' ? fakeIpFilterMode : DEFAULT_DNS_CONFIG['fake-ip-filter-mode'] const next: DnsFormValues = { enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable, listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen, enhancedMode: validEnhancedMode, fakeIpRange: dnsConfig['fake-ip-range'] ?? DEFAULT_DNS_CONFIG['fake-ip-range'], fakeIpRange6: dnsConfig['fake-ip-range6'] ?? DEFAULT_DNS_CONFIG['fake-ip-range6'], fakeIpFilterMode: validFakeIpFilterMode, preferH3: dnsConfig['prefer-h3'] ?? DEFAULT_DNS_CONFIG['prefer-h3'], respectRules: dnsConfig['respect-rules'] ?? DEFAULT_DNS_CONFIG['respect-rules'], useHosts: dnsConfig['use-hosts'] ?? DEFAULT_DNS_CONFIG['use-hosts'], useSystemHosts: dnsConfig['use-system-hosts'] ?? DEFAULT_DNS_CONFIG['use-system-hosts'], ipv6: dnsConfig.ipv6 ?? DEFAULT_DNS_CONFIG.ipv6, fakeIpFilter: dnsConfig['fake-ip-filter']?.join(', ') ?? DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '), nameserver: dnsConfig.nameserver?.join(', ') ?? DEFAULT_DNS_CONFIG.nameserver.join(', '), fallback: dnsConfig.fallback?.join(', ') ?? DEFAULT_DNS_CONFIG.fallback.join(', '), defaultNameserver: dnsConfig['default-nameserver']?.join(', ') ?? DEFAULT_DNS_CONFIG['default-nameserver'].join(', '), proxyServerNameserver: dnsConfig['proxy-server-nameserver']?.join(', ') ?? (DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || ''), directNameserver: dnsConfig['direct-nameserver']?.join(', ') ?? (DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || ''), directNameserverFollowPolicy: dnsConfig['direct-nameserver-follow-policy'] ?? DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'], fallbackGeoip: dnsConfig['fallback-filter']?.geoip ?? DEFAULT_DNS_CONFIG['fallback-filter'].geoip, fallbackGeoipCode: dnsConfig['fallback-filter']?.['geoip-code'] ?? DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'], fallbackIpcidr: dnsConfig['fallback-filter']?.ipcidr?.join(', ') ?? DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr.join(', '), fallbackDomain: dnsConfig['fallback-filter']?.domain?.join(', ') ?? DEFAULT_DNS_CONFIG['fallback-filter'].domain.join(', '), nameserverPolicy: formatNameserverPolicy(dnsConfig['nameserver-policy']) || '', hosts: formatHosts(hostsConfig) || '', } setValues(next) return next }, []) const resetToDefaults = useCallback(() => { setValues(DEFAULT_FORM_VALUES) setYamlContent(buildDnsYaml(DEFAULT_FORM_VALUES)) }, []) const updateValuesFromYaml = useCallback(() => { try { const parsedYaml = yaml.load(yamlContent) as any if (!parsedYaml || parsedYaml.isInitValue) return updateValuesFromConfig(parsedYaml) } catch { showNotice.error('settings.modals.dns.errors.invalidYaml') } }, [yamlContent, updateValuesFromConfig]) useEffect(() => { return () => { editorRef.current?.dispose() editorRef.current = null } }, []) const initDnsConfig = useCallback(async () => { setInitializing(true) try { const dnsConfig = await getDnsConfigContent() if (dnsConfig !== null) { const config = yaml.load(dnsConfig) as any const loaded = updateValuesFromConfig(config) setYamlContent(loaded ? buildDnsYaml(loaded) : dnsConfig) } else { resetToDefaults() } } catch (err) { console.error('Failed to initialize DNS config', err) resetToDefaults() } finally { setInitializing(false) } }, [resetToDefaults, updateValuesFromConfig]) useSettingDialog(ref, 'dns-viewer', open, () => ({ open: () => { setOpen(true) void initDnsConfig() }, close: () => setOpen(false), })) const onSave = useLockFn(async () => { try { let config: Record if (visualization) { config = {} const dnsConfig = buildDnsConfig(values) if (Object.keys(dnsConfig).length > 0) { config.dns = dnsConfig } config.hosts = parseHosts(values.hosts) } else { const parsedConfig = yaml.load(yamlContent) if (typeof parsedConfig !== 'object' || parsedConfig === null) { throw new Error(t('settings.modals.dns.errors.invalid')) } config = parsedConfig as Record } await mutate(() => saveDnsConfig(config), { id: 'save-dns-config', errorNotice: false, }) const validation = await validateDnsConfig() if (validation.status !== 'valid') { const errorMsg = validation.status === 'invalid' ? validation.message : 'Configuration validation skipped' let cleanErrorMsg = errorMsg if (errorMsg.includes('level=error')) { const errorLines = errorMsg .split('\n') .filter( (line) => line.includes('level=error') || line.includes('level=fatal') || line.includes('failed'), ) if (errorLines.length > 0) { cleanErrorMsg = errorLines .map((line) => { const msgMatch = line.match(/msg="([^"]+)"/) return msgMatch ? msgMatch[1] : line }) .join(', ') } } showNotice.error( 'settings.modals.dns.messages.configError', cleanErrorMsg, ) return } if (dnsEnabled) { await mutate(() => applyDnsConfig(true), { id: 'apply-dns-config', errorNotice: false, }) mutateClash() } setOpen(false) showNotice.success('settings.modals.dns.messages.saved') } catch (err) { showNotice.error('settings.modals.dns.messages.configError', err) } }) const handleYamlChange = (value?: string) => { setYamlContent(value || '') } const handleChange = (field: string) => (event: any) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value setValues((prev) => ({ ...prev, [field]: value, })) } return ( {t('settings.modals.dns.dialog.title')} } contentSx={{ width: 550, overflow: 'auto', ...(visualization ? {} : { padding: '0 24px', display: 'flex', flexDirection: 'column' }), }} okBtn={t('shared.actions.save')} cancelBtn={t('shared.actions.cancel')} onClose={() => setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > {initializing && ( {t('shared.statuses.loading')} )} {t('settings.modals.dns.dialog.profileScope')} {t('settings.modals.dns.dialog.warning')} {visualization ? ( {t('settings.modals.dns.sections.general')} {t('settings.modals.dns.sections.fallbackFilter')} {t('settings.modals.dns.sections.hosts')} ) : ( { editorRef.current = editorInstance }} options={{ tabSize: 2, minimap: { enabled: document.documentElement.clientWidth >= 1500, }, mouseWheelZoom: true, quickSuggestions: { strings: true, comments: true, other: true, }, padding: { top: 33, }, fontFamily: MONACO_FONT_FAMILY, fontLigatures: false, smoothScrolling: true, }} onChange={handleYamlChange} /> )} ) }