// ignore_for_file: use_build_context_synchronously import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:adguard_home_manager/screens/settings/dns/rate_limit_allowlist_modal.dart'; import 'package:adguard_home_manager/widgets/custom_list_tile.dart'; import 'package:adguard_home_manager/widgets/custom_radio_list_tile.dart'; import 'package:adguard_home_manager/widgets/section_label.dart'; import 'package:adguard_home_manager/widgets/custom_switch_list_tile.dart'; import 'package:adguard_home_manager/functions/desktop_mode.dart'; import 'package:adguard_home_manager/classes/process_modal.dart'; import 'package:adguard_home_manager/providers/dns_provider.dart'; import 'package:adguard_home_manager/functions/snackbar.dart'; import 'package:adguard_home_manager/providers/app_config_provider.dart'; class DnsServerSettingsScreen extends StatefulWidget { const DnsServerSettingsScreen({super.key}); @override State createState() => _DnsServerSettingsScreenState(); } class _DnsServerSettingsScreenState extends State { final _limitRequestsController = TextEditingController(); final _ipv4PrefixSubnetController = TextEditingController(); String? _ipv4PrefixSubnetError; final _ipv6PrefixSubnetController = TextEditingController(); String? _ipv6PrefixSubnetError; List _rateLimitAllowlist = []; String? _limitRequestsError; final _expandableCustomEdns = ExpandableController(); final _expandableEdnsIp = ExpandableController(); bool _enableEdns = false; bool _useCustomIpEdns = false; final _customIpEdnsController = TextEditingController(); String? _ednsIpError; bool _enableDnssec = false; bool _disableIpv6Resolving = false; String blockingMode = "default"; final _ipv4controller = TextEditingController(); String? _ipv4error; final _ipv6controller = TextEditingController(); String? ipv6error; final _ttlController = TextEditingController(); String? _ttlError; void validateIpv4(String value) { RegExp ipAddress = RegExp(r'^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$'); if (ipAddress.hasMatch(value) == true) { setState(() => _ipv4error = null); } else { setState(() => _ipv4error = AppLocalizations.of(context)!.invalidIp); } } void validateEdns(String value) { RegExp ipAddress = RegExp(r'^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$'); if (ipAddress.hasMatch(value) == true) { setState(() => _ednsIpError = null); } else { setState(() => _ednsIpError = AppLocalizations.of(context)!.ipNotValid); } } void validateIpv6(String value) { RegExp ipAddress = RegExp(r'(?:^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$)|(?:^(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?$)'); if (ipAddress.hasMatch(value) == true) { setState(() => ipv6error = null); } else { setState(() => ipv6error = AppLocalizations.of(context)!.invalidIp); } } bool validateData() { if ( (_limitRequestsController.text == "" || (_limitRequestsController.text != "" && _limitRequestsError == null)) && ( blockingMode != 'custom_ip' || ( blockingMode == 'custom_ip' && _ipv4controller.text != '' && _ipv4error == null && _ipv6controller.text != '' && ipv6error == null ) ) == true && _ednsIpError == null && _ttlController.text != "" && _ttlError == null && (_ipv4PrefixSubnetController.text == "" || (_ipv4PrefixSubnetController.text != "" && _ipv4PrefixSubnetError == null)) && (_ipv6PrefixSubnetController.text == "" || (_ipv6PrefixSubnetController.text != "" && _ipv6PrefixSubnetError == null)) ) { return true; } else { return false; } } String? validateTtl(String value) { if (value == "") return AppLocalizations.of(context)!.valueNotNumber; if (int.tryParse(value) != null) { return null; } else { return AppLocalizations.of(context)!.valueNotNumber; } } String? validateNumber(String value) { if (value == "") return null; if (int.tryParse(value) != null) { return null; } else { return AppLocalizations.of(context)!.valueNotNumber; } } @override void initState() { final dnsProvider = Provider.of(context, listen: false); _limitRequestsController.text = dnsProvider.dnsInfo!.ratelimit.toString(); _enableEdns = dnsProvider.dnsInfo!.ednsCsEnabled; _useCustomIpEdns = dnsProvider.dnsInfo!.ednsCsUseCustom ?? false; _customIpEdnsController.text = dnsProvider.dnsInfo!.ednsCsCustomIp ?? ""; if (dnsProvider.dnsInfo!.ednsCsEnabled == true) _expandableCustomEdns.toggle(); if (dnsProvider.dnsInfo!.ednsCsUseCustom == true) _expandableEdnsIp.toggle(); _enableDnssec = dnsProvider.dnsInfo!.dnssecEnabled; _disableIpv6Resolving = dnsProvider.dnsInfo!.disableIpv6; blockingMode = dnsProvider.dnsInfo!.blockingMode; _ipv4controller.text = dnsProvider.dnsInfo!.blockingIpv4; _ipv6controller.text = dnsProvider.dnsInfo!.blockingIpv6; _ttlController.text = dnsProvider.dnsInfo!.blockedResponseTtl != null ? dnsProvider.dnsInfo!.blockedResponseTtl.toString() : ""; _ipv4PrefixSubnetController.text = dnsProvider.dnsInfo!.ratelimitSubnetLenIpv4 != null ? dnsProvider.dnsInfo!.ratelimitSubnetLenIpv4.toString() : ""; _ipv6PrefixSubnetController.text = dnsProvider.dnsInfo!.ratelimitSubnetLenIpv6 != null ? dnsProvider.dnsInfo!.ratelimitSubnetLenIpv6.toString() : ""; _rateLimitAllowlist = dnsProvider.dnsInfo!.ratelimitWhitelist ?? []; super.initState(); } @override Widget build(BuildContext context) { final dnsProvider = Provider.of(context); final appConfigProvider = Provider.of(context); final width = MediaQuery.of(context).size.width; void saveData() async { ProcessModal processModal = ProcessModal(); processModal.open(AppLocalizations.of(context)!.savingConfig); final result = await dnsProvider.saveDnsServerConfig({ "ratelimit": int.tryParse(_limitRequestsController.text), "edns_cs_enabled": _enableEdns, "edns_cs_use_custom": _useCustomIpEdns, "edns_cs_custom_ip": _customIpEdnsController.text, "dnssec_enabled": _enableDnssec, "disable_ipv6": _disableIpv6Resolving, "blocking_mode": blockingMode, "blocking_ipv4": _ipv4controller.text, "blocking_ipv6": _ipv6controller.text, "blocked_response_ttl": int.tryParse(_ttlController.text), "ratelimit_subnet_len_ipv4": int.tryParse(_ipv4PrefixSubnetController.text), "ratelimit_subnet_len_ipv6": int.tryParse(_ipv6PrefixSubnetController.text), "ratelimit_whitelist": _rateLimitAllowlist }); processModal.close(); if (result.successful == true) { showSnackbar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.dnsConfigSaved, color: Colors.green ); } else if (result.successful == false && result.statusCode == 400) { showSnackbar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.someValueNotValid, color: Colors.red ); } else { showSnackbar( appConfigProvider: appConfigProvider, label: AppLocalizations.of(context)!.dnsConfigNotSaved, color: Colors.red ); } } void updateBlockingMode(String mode) { if (mode != 'custom_ip') { _ipv4controller.text = ''; _ipv4error = null; _ipv6controller.text = ''; ipv6error = null; } setState(() => blockingMode = mode); validateData(); } final dataValid = validateData(); return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.dnsServerSettings), surfaceTintColor: isDesktop(width) ? Colors.transparent : null, actions: [ IconButton( onPressed: dataValid == true ? () => saveData() : null, icon: const Icon(Icons.save_rounded), tooltip: AppLocalizations.of(context)!.save, ), const SizedBox(width: 10) ], ), body: SafeArea( child: ListView( padding: const EdgeInsets.only(top: 0), children: [ SectionLabel( label: AppLocalizations.of(context)!.rateLimit, padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24, top: 8), ), Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24, top: 4), child: TextFormField( controller: _limitRequestsController, onChanged: (v) => setState(() => _limitRequestsError = validateNumber(v)), decoration: InputDecoration( prefixIcon: const Icon(Icons.looks_one_rounded), border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(10) ) ), labelText: AppLocalizations.of(context)!.limitRequestsSecond, errorText: _limitRequestsError ), keyboardType: TextInputType.number, ), ), Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 24), child: TextFormField( controller: _ipv4PrefixSubnetController, onChanged: (v) => setState(() => _ipv4PrefixSubnetError = validateNumber(v)), decoration: InputDecoration( prefixIcon: const Icon(Icons.skip_previous_rounded), border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(10) ) ), labelText: AppLocalizations.of(context)!.subnetPrefixLengthIpv4, errorText: _ipv4PrefixSubnetError ), keyboardType: TextInputType.number, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TextFormField( controller: _ipv6PrefixSubnetController, onChanged: (v) => setState(() => _ipv6PrefixSubnetError = validateNumber(v)), decoration: InputDecoration( prefixIcon: const Icon(Icons.skip_previous_rounded), border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(10) ) ), labelText: AppLocalizations.of(context)!.subnetPrefixLengthIpv6, errorText: _ipv6PrefixSubnetError ), keyboardType: TextInputType.number, ), ), const SizedBox(height: 16), CustomListTile( title: AppLocalizations.of(context)!.rateLimitAllowlist, subtitle: AppLocalizations.of(context)!.rateLimitAllowlistDescription, onTap: () => showDialog( context: context, builder: (context) => RateLimitAllowlistModal( values: _rateLimitAllowlist, onConfirm: (ips) => setState(() => _rateLimitAllowlist = ips) ), ), trailing: _rateLimitAllowlist.isNotEmpty ? Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(40) ), child: Text( _rateLimitAllowlist.length.toString(), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onPrimaryContainer ), ), ) : null, ), SectionLabel(label: AppLocalizations.of(context)!.dnsOptions), CustomSwitchListTile( value: _enableEdns, onChanged: (value) => setState(() { _enableEdns = value; _expandableCustomEdns.toggle(); if (value == false) { _useCustomIpEdns = false; if (_expandableEdnsIp.expanded == true) _expandableEdnsIp.toggle(); _customIpEdnsController.text = ""; _ednsIpError = null; } validateData(); }), title: AppLocalizations.of(context)!.enableEdns, subtitle: AppLocalizations.of(context)!.enableEdnsDescription, ), ExpandableNotifier( controller: _expandableCustomEdns, child: Expandable( collapsed: const SizedBox(), expanded: Column( children: [ CustomSwitchListTile( padding: const EdgeInsets.only( left: 50, top: 12, bottom: 12, right: 16 ), value: _useCustomIpEdns, onChanged: (value) => setState(() { _useCustomIpEdns = value; _expandableEdnsIp.toggle(); if (_useCustomIpEdns == false) { _customIpEdnsController.text = ""; _ednsIpError = null; } validateData(); }), title: AppLocalizations.of(context)!.useCustomIpEdns, subtitle: AppLocalizations.of(context)!.useCustomIpEdnsDescription, ), ExpandableNotifier( controller: _expandableEdnsIp, child: Expandable( collapsed: const SizedBox(), expanded: Padding( padding: const EdgeInsets.only( top: 16, bottom: 16, right: 16, left: 70 ), child: TextFormField( controller: _customIpEdnsController, onChanged: validateEdns, decoration: InputDecoration( prefixIcon: const Icon(Icons.link_rounded), border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(10) ) ), errorText: _ednsIpError, labelText: AppLocalizations.of(context)!.ipAddress, ), ), ), ) ), ], ), ) ), CustomSwitchListTile( value: _enableDnssec, onChanged: (value) => setState(() => _enableDnssec = value), title: AppLocalizations.of(context)!.enableDnssec, subtitle: AppLocalizations.of(context)!.enableDnssecDescription, ), CustomSwitchListTile( value: _disableIpv6Resolving, onChanged: (value) => setState(() => _disableIpv6Resolving = value), title: AppLocalizations.of(context)!.disableResolvingIpv6, subtitle: AppLocalizations.of(context)!.disableResolvingIpv6Description, ), SectionLabel(label: AppLocalizations.of(context)!.blockingMode), CustomRadioListTile( groupValue: blockingMode, value: "default", radioBackgroundColor: Theme.of(context).dialogBackgroundColor, title: AppLocalizations.of(context)!.defaultMode, subtitle: AppLocalizations.of(context)!.defaultDescription, onChanged: updateBlockingMode, ), CustomRadioListTile( groupValue: blockingMode, value: "refused", radioBackgroundColor: Theme.of(context).dialogBackgroundColor, title: "REFUSED", subtitle: AppLocalizations.of(context)!.refusedDescription, onChanged: updateBlockingMode, ), CustomRadioListTile( groupValue: blockingMode, value: "nxdomain", radioBackgroundColor: Theme.of(context).dialogBackgroundColor, title: "NXDOMAIN", subtitle: AppLocalizations.of(context)!.nxdomainDescription, onChanged: updateBlockingMode, ), CustomRadioListTile( groupValue: blockingMode, value: "null_ip", radioBackgroundColor: Theme.of(context).dialogBackgroundColor, title: AppLocalizations.of(context)!.nullIp, subtitle: AppLocalizations.of(context)!.nullIpDescription, onChanged: updateBlockingMode, ), CustomRadioListTile( groupValue: blockingMode, value: "custom_ip", radioBackgroundColor: Theme.of(context).dialogBackgroundColor, title: AppLocalizations.of(context)!.customIp, subtitle: AppLocalizations.of(context)!.customIpDescription, onChanged: updateBlockingMode, ), const SizedBox(height: 10), if (blockingMode == 'custom_ip') ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: TextFormField( controller: _ipv4controller, onChanged: validateIpv4, decoration: InputDecoration( prefixIcon: const Icon(Icons.link_rounded), border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(10) ) ), errorText: _ipv4error, helperText: AppLocalizations.of(context)!.blockingIpv4Description, helperMaxLines: 10, labelText: AppLocalizations.of(context)!.blockingIpv4, ), keyboardType: TextInputType.number, ), ), const SizedBox(height: 30), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: TextFormField( controller: _ipv6controller, onChanged: validateIpv6, decoration: InputDecoration( prefixIcon: const Icon(Icons.link_rounded), border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(10) ) ), errorText: ipv6error, helperText: AppLocalizations.of(context)!.blockingIpv6Description, helperMaxLines: 10, labelText: AppLocalizations.of(context)!.blockingIpv6, ), keyboardType: TextInputType.number, ), ), const SizedBox(height: 30), ], Padding( padding: const EdgeInsets.all(16), child: TextFormField( controller: _ttlController, onChanged: (v) => setState(() => _ttlError = validateTtl(v)), decoration: InputDecoration( prefixIcon: const Icon(Icons.timer_rounded), border: const OutlineInputBorder( borderRadius: BorderRadius.all( Radius.circular(10) ) ), errorText: _ttlError, labelText: AppLocalizations.of(context)!.blockedResponseTtl, helperText: AppLocalizations.of(context)!.blockedResponseTtlDescription, helperMaxLines: 2, ), keyboardType: TextInputType.number, ), ), ], ), ), ); } }