From b29916a6bba04a8538d44d43232b8391ded75145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E5=AE=87=E5=AE=81?= Date: Thu, 17 Sep 2026 19:59:46 +0700 Subject: [PATCH] =?UTF-8?q?dns=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/config/config.dart | 3 + lib/hj_utils/detect_line_manager.dart | 44 +++++- lib/hj_utils/dns_solve/_answer.dart | 176 ++++++++++++++++++++++++ lib/hj_utils/dns_solve/_dnsolve.dart | 188 ++++++++++++++++++++++++++ lib/hj_utils/dns_solve/_question.dart | 22 +++ lib/hj_utils/dns_solve/_response.dart | 90 ++++++++++++ lib/hj_utils/dns_solve/dnsolve.dart | 5 + lib/hj_utils/dns_solve/exception.dart | 42 ++++++ lib/hj_utils/store_keys.dart | 3 + 9 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 lib/hj_utils/dns_solve/_answer.dart create mode 100644 lib/hj_utils/dns_solve/_dnsolve.dart create mode 100644 lib/hj_utils/dns_solve/_question.dart create mode 100644 lib/hj_utils/dns_solve/_response.dart create mode 100644 lib/hj_utils/dns_solve/dnsolve.dart create mode 100644 lib/hj_utils/dns_solve/exception.dart diff --git a/lib/config/config.dart b/lib/config/config.dart index b05b640..b3961a1 100644 --- a/lib/config/config.dart +++ b/lib/config/config.dart @@ -21,6 +21,9 @@ class Config { // ===== 域名 / 线路 ===== + /// DNS TXT 兜底:本地线路全挂时解析这个域名拿新线路(运维提供) + static String dns = 'hgdj.mfcg.it.com'; + /// 本地兜底线路(无本地缓存线路时用) static const lineList = [ 'https://d1s84171319hi1.cloudfront.net', diff --git a/lib/hj_utils/detect_line_manager.dart b/lib/hj_utils/detect_line_manager.dart index f111a12..05d8d2b 100644 --- a/lib/hj_utils/detect_line_manager.dart +++ b/lib/hj_utils/detect_line_manager.dart @@ -4,6 +4,9 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/dns_solve/dnsolve.dart'; +import 'package:hgdj/hj_utils/light_model.dart'; +import 'package:hgdj/hj_utils/store_keys.dart'; import 'package:hgdj/hj_utils/text_util.dart'; import 'package:hgdj/tools_base/debug_log.dart'; import 'package:hgdj/tools_base/net/http_manager.dart'; @@ -36,8 +39,14 @@ class DetectLineManager { /// 一次完整的批量选线过程 Future _detectLineOnce() async { - //线路只来自 Config.lineList:DNS 解析兜底和本地线路缓存都已去掉 - return await _pingCheckBatch(Config.lineList); + final saved = await lightKV.getStringList(StoreKeys.DETECT_LINE); + final lines = saved?.isNotEmpty == true ? saved! : Config.lineList; + var successLine = await _pingCheckBatch(lines); + // 本地线路全部失败,回退 DNS 解析 + if (TextUtil.isEmpty(successLine)) { + successLine = await _dnsSolve(); + } + return successLine; } ///检查一批线路:并发 ping,谁先成功用谁;全失败返回 "" @@ -94,4 +103,35 @@ class DetectLineManager { } return resp.isSuccess; } + + //本地域名不通的情况下,使用 dns 解析 + Future _dnsSolve() async { + final response = await DNSolve().lookup( + Config.dns, + dnsSec: true, + type: RecordType.txt, + provider: DNSProvider.aliyun, + ); + + //必须先取出来判空,不能写成 `?? []`:那个空字面量会被推成 List, + //整行 LUB 退化后 record 也成了 dynamic,record.data.split().map().toList() + //一路 dynamic 下去,运行期是 List,传给 setStringList(List) 直接抛。 + //全程 dynamic,analyze 一个警告都不会给 + final records = response.answer?.records; + if (records == null) return ''; + for (final record in records) { + if (record.data.isEmpty) continue; + debugLog("selectLine", "dnsSolve ==== ${record.data}"); + //DNS TXT 值带引号,去掉后按 _ 拆成多条线路 + final lines = record.data + .split('_') + .map((e) => e.replaceAll("\"", "")) + .where(TextUtil.isNotEmpty) + .toList(); + if (lines.isEmpty) continue; // 这条记录没解析出线路,接着看下一条,别直接放弃 + lightKV.setStringList(StoreKeys.DETECT_LINE, lines); + return await _pingCheckBatch(lines); + } + return ''; + } } diff --git a/lib/hj_utils/dns_solve/_answer.dart b/lib/hj_utils/dns_solve/_answer.dart new file mode 100644 index 0000000..ef1cab3 --- /dev/null +++ b/lib/hj_utils/dns_solve/_answer.dart @@ -0,0 +1,176 @@ +part of '_dnsolve.dart'; + +/// Represents an answer containing a list of generic records and a list of +/// Service (SRV) records parsed from JSON data. +class _Answer { + const _Answer(this.records, [this.srvs]); + + /// List of generic records. + final List<_Record>? records; + + /// List of Service (SRV) records. + final List? srvs; + + /// Constructs an [_Answer] instance from JSON data. + /// + /// The [json] parameter should be a list of dynamic objects representing + /// DNS records. Returns an [_Answer] instance containing parsed records + /// and Service (SRV) records. + factory _Answer.fromJson(List? json) { + if (json == null) { + return const _Answer(null); + } + + final records = json + .map((answer) => _Record.fromJson(answer as Map)) + .toList(); + final srvs = []; + + { + final RegExp regExp = RegExp(r'(\d+)\s+(\d+)\s+(\d+)\s+([\w\.\-]+)'); + for (final record in records) { + if (record.rType == RecordType.srv) { + final match = regExp.firstMatch(record.data); + + if (match != null) { + final priority = int.parse(match.group(1)!); + final weight = int.parse(match.group(2)!); + final port = int.parse(match.group(3)!); + final target = match.group(4)!; + + srvs.add( + SRVRecord( + priority: priority, + weight: weight, + port: port, + target: target, + fqdn: record.name, + ), + ); + } else { + throw const SRVRecordFormatException( + 'Failed to parse or process the Service (SRV) record', + ); + } + } + } + } + + return _Answer(records, srvs); + } + + @override + String toString() => '''$records'''; +} + +class _Record { + const _Record({ + required this.name, + required this.rType, + required this.ttl, + required this.data, + }); + + final String name; + final RecordType rType; + final int ttl; + final String data; + + factory _Record.fromJson(Map json) => _Record( + name: json['name'] as String, + rType: DNSolve.intToRecord(json['type'] as int), + ttl: json['TTL'] as int, + data: json['data'] as String, + ); + + @override + String toString() => + '''(name: $name, type: $rType, ttl: $ttl, data: $data)'''; + + String get toBind { + final buffer = StringBuffer(); + buffer.write(name); + if (buffer.length < 8) { + buffer.write('\t'); + } + if (buffer.length > 10) { + buffer.write('\t'); + } + buffer.writeAll( + [ttl, '\tIN\t', rType.name.toUpperCase(), '\t', '"', data, '"'], + ); + + return buffer.toString(); + } +} + +/// Returns a Service (SRV) record containing information about a server or +/// service in the domain name system (DNS). +class SRVRecord { + /// Constructs an [SRVRecord] with the specified parameters. + const SRVRecord({ + required this.priority, + required this.weight, + required this.port, + this.target, + required this.fqdn, + }); + + /// The priority of this SRV record. + final int priority; + + /// The weight of this SRV record. + final int weight; + + /// The port on which the service is available. + final int port; + + /// The target domain name of the server. + final String? target; + + /// Fully Qualified Domain Name. + final String fqdn; + + /// Sorts a list of [SRVRecord] instances based on their priority and weight. + static List sort(List records) { + records.sort(_srvRecordSortComparator); + + return records; + } + + /// Comparator function for sorting [SRVRecord] instances. + static int _srvRecordSortComparator(SRVRecord a, SRVRecord b) { + if (a.priority < b.priority) { + return -1; + } else { + if (a.priority > b.priority) { + return 1; + } + + if (a.weight < b.weight) { + return -1; + } else if (a.weight > b.weight) { + return 1; + } else { + return 0; + } + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is SRVRecord && + other.runtimeType == runtimeType && + other.priority == priority && + other.weight == weight && + other.port == port && + other.target == other.target; + } + + @override + int get hashCode => Object.hash(priority, weight, port, target); +} diff --git a/lib/hj_utils/dns_solve/_dnsolve.dart b/lib/hj_utils/dns_solve/_dnsolve.dart new file mode 100644 index 0000000..46d6174 --- /dev/null +++ b/lib/hj_utils/dns_solve/_dnsolve.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'exception.dart'; + +part '_answer.dart'; +part '_question.dart'; +part '_response.dart'; + +/// An enumeration that represents various DNS record types. +enum RecordType { + A, + aaaa, + any, + caa, + cds, + cert, + cname, + dname, + dnskey, + ds, + hinfo, + ipseckey, + nsec, + nsec3PARAM, + naptr, + ptr, + rp, + rrsig, + soa, + spf, + srv, + sshfp, + tlsa, + wks, + txt, + ns, + mx, +} + +/// An enumeration that represents different DNS service providers. +enum DNSProvider { google, cloudflare, aliyun } + +class DNSolve { + DNSolve() { + _client = http.Client(); + } + + late final http.Client _client; + + /// A map that associates [DNSProvider] enum values with their respective DNS + /// provider URLs. + static const _dnsProviders = { + DNSProvider.google: 'https://dns.google.com/resolve', + DNSProvider.cloudflare: 'https://cloudflare-dns.com/dns-query', + DNSProvider.aliyun: 'https://dns.alidns.com/resolve' + }; + + /// Performs a DNS lookup for the given domain. + Future lookup( + /// The domain to lookup. + String domain, { + /// Whether to enable DNSSEC (Domain Name System Security Extensions). + bool dnsSec = false, + + /// The DNS record type to look up (defaults to A). + RecordType type = RecordType.A, + + /// The DNS provider to use (defaults to Google). + DNSProvider provider = DNSProvider.google, + }) async { + assert(domain.isNotEmpty, 'domain should not be empty'); + + final queryParams = {}; + queryParams + ..putIfAbsent('name', () => domain) + ..putIfAbsent('type', () => _typeToInt(type).toString()) + ..putIfAbsent('dnssec', () => dnsSec.toString()); + + final headers = {'Accept': 'application/dns-json'}; + final url = _dnsProviders[provider] ?? 'https://dns.google.com/resolve'; + + final body = + await _get(url, queryParameters: queryParams, headers: headers); + + return ResolveResponse.fromJson(json.decode(body) as Map); + } + + /// Performs a reverse DNS lookup for the given IP address. + Future> reverseLookup( + /// The IP address to perform a reverse lookup for. + String ip, { + /// THE DNS provider to use (defaults to Google). + DNSProvider provider = DNSProvider.google, + }) async { + final queryParams = {}; + String? reverse() { + if (ip.contains('.')) { + return '${ip.split('.').reversed.join('.')}.in-addr.arpa'; + } else if (ip.contains(':')) { + return '${ip.split(':').join().split('').reversed.join('.')}.ip6.arpa'; + } else { + return null; + } + } + + final reversed = reverse(); + if (reversed == null) { + return []; + } + + queryParams + ..putIfAbsent('name', () => reversed) + ..putIfAbsent('type', () => _records[RecordType.ptr]!.toString()); + + final headers = {'Accept': 'application/dns-json'}; + final url = _dnsProviders[provider] ?? 'https://dns.google.com/resolve'; + + final body = + await _get(url, queryParameters: queryParams, headers: headers); + final response = + ResolveResponse.fromJson(json.decode(body) as Map); + return response.answer!.records ?? []; + } + + /// Sends an HTTP GET request to the specified URL with optional query + /// parameters and headers. + Future _get( + String url, { + Map? queryParameters, + Map? headers, + }) async { + late Uri uri; + { + if (queryParameters == null || queryParameters.isEmpty) { + uri = Uri.parse(url); + } else { + uri = Uri.parse(url).replace(queryParameters: queryParameters); + } + } + + final response = await _client.get(uri, headers: headers); + return _handleResponse(response); + } + + /// A map that associates RecordType enum values with their corresponding DNS + /// record types (integer values). + static const _records = { + RecordType.A: 1, + RecordType.aaaa: 28, + RecordType.any: 255, + RecordType.caa: 257, + RecordType.cds: 59, + RecordType.cert: 37, + RecordType.cname: 5, + RecordType.dname: 39, + RecordType.dnskey: 48, + RecordType.ds: 43, + RecordType.hinfo: 13, + RecordType.ipseckey: 45, + RecordType.mx: 15, + RecordType.naptr: 35, + RecordType.ns: 2, + RecordType.nsec: 47, + RecordType.nsec3PARAM: 51, + RecordType.ptr: 12, + RecordType.rp: 17, + RecordType.rrsig: 46, + RecordType.soa: 6, + RecordType.spf: 99, + RecordType.srv: 33, + RecordType.sshfp: 44, + RecordType.tlsa: 52, + RecordType.txt: 16, + RecordType.wks: 11, + }; + + /// Converts an integer DNS record type to a [RecordType] enum value. + static RecordType intToRecord(int type) { + final records = _records.map((key, value) => MapEntry(value, key)); + + return records[type] ?? RecordType.A; + } + + /// Converts a [RecordType] enum value to its corresponding integer DNS record + /// type. + static int _typeToInt(RecordType type) => _records[type] ?? 1; +} diff --git a/lib/hj_utils/dns_solve/_question.dart b/lib/hj_utils/dns_solve/_question.dart new file mode 100644 index 0000000..ccb69e1 --- /dev/null +++ b/lib/hj_utils/dns_solve/_question.dart @@ -0,0 +1,22 @@ +part of '_dnsolve.dart'; + +class _Question { + const _Question({required this.name, required this.rType}); + + final String? name; + final RecordType? rType; + + factory _Question.fromJson(Map? json) { + if (json == null) { + return const _Question(name: null, rType: null); + } + + return _Question( + name: json['name'] as String, + rType: DNSolve.intToRecord(json['type'] as int), + ); + } + + @override + String toString() => '''(name: $name, rType: $rType)'''; +} diff --git a/lib/hj_utils/dns_solve/_response.dart b/lib/hj_utils/dns_solve/_response.dart new file mode 100644 index 0000000..c0f6446 --- /dev/null +++ b/lib/hj_utils/dns_solve/_response.dart @@ -0,0 +1,90 @@ +part of '_dnsolve.dart'; + +String _handleResponse(http.Response response) { + if (response.statusCode >= 200 && response.statusCode <= 209) { + return response.body; + } else { + throw ResponseException( + body: response.body, + headers: response.headers, + statusCode: response.statusCode, + ); + } +} + +/// Represents a response from a DNS resolution operation. +/// +/// This class includes information about the resolution status, flags, +/// comments, resolved answer, and the list of questions queried. +class ResolveResponse { + const ResolveResponse({ + required this.status, + required this.tc, + required this.rd, + required this.ra, + required this.ad, + required this.cd, + required this.comment, + required this.answer, + required this.questions, + }); + + /// The status code indicating the result of the DNS resolution. + final int? status; + + /// Indicates if the response was truncated. + final bool? tc; + + /// Indicates if recursion was desired in the request. + final bool? rd; + + /// Indicates if recursion is available in the response. + final bool? ra; + + /// Indicates if the data in the response is authenticated. + final bool? ad; + + /// Indicates if checking is disabled in the response. + final bool? cd; + + /// Additional comments or information related to the resolution response. + final String? comment; + + /// The resolved answer containing DNS records. + final _Answer? answer; + + /// List of questions queried in the resolution request. + final List<_Question>? questions; + + /// Constructs a [ResolveResponse] instance from JSON data. + /// + /// The [json] parameter should be a map containing the fields of a DNS + /// resolution response. Returns a [ResolveResponse] instance with parsed + /// data. + factory ResolveResponse.fromJson(Map json) => ResolveResponse( + status: json['Status'] as int?, + tc: json['TC'] as bool?, + rd: json['RD'] as bool?, + ra: json['RA'] as bool?, + ad: json['AD'] as bool?, + cd: json['CD'] as bool?, + comment: json['comment'] as String?, + answer: _Answer.fromJson(json['Answer'] as List?), + questions: () { + final data = json['Question']; + if (data == null) return null; + if (data is List) { + return (data as List) + .map( + (question) => _Question.fromJson(question as Map), + ) + .toList(); + } + if (data is Map) return [_Question.fromJson(Map.from(data))]; + }(), + ); + + @override + String toString() => + '''status: $status, truncation: $tc, recursion desired(rd): $rd, recursion available(ra): $ra, authenticated data(ad): $ad, checking disabled(cd): $cd, comment: $comment, answer: $answer, questions: $questions'''; +} diff --git a/lib/hj_utils/dns_solve/dnsolve.dart b/lib/hj_utils/dns_solve/dnsolve.dart new file mode 100644 index 0000000..d9767a1 --- /dev/null +++ b/lib/hj_utils/dns_solve/dnsolve.dart @@ -0,0 +1,5 @@ +/// Provider of an easy way of performing DNS lookups. +library; + +export '_dnsolve.dart'; +export 'exception.dart'; diff --git a/lib/hj_utils/dns_solve/exception.dart b/lib/hj_utils/dns_solve/exception.dart new file mode 100644 index 0000000..fc225d3 --- /dev/null +++ b/lib/hj_utils/dns_solve/exception.dart @@ -0,0 +1,42 @@ +/// An abstract class representing an exception related to DNS solving. +/// +/// This serves as a base class for exceptions that may occur during DNS +/// resolution or parsing operations. +abstract class DNSolveException implements Exception { + const DNSolveException(); +} + +/// Represents an [Exception] that occured while processing an DNS request. +/// +/// It contains information about the status code, headers, and body of the +/// response. +class ResponseException extends DNSolveException { + const ResponseException({ + required this.statusCode, + required this.headers, + required this.body, + }) : super(); + + /// The status code of the response. + final int statusCode; + + /// The headers of the response. + final Map headers; + + /// The body of the response. + final String body; + + @override + String toString() => + '''Exception(Status Code: $statusCode, Response Headers: $headers, Response Body: $body)'''; +} + +/// An exception indicating that an error occurred while parsing or processing a +/// Service (SRV) record. +/// +/// This is a specific type of [DNSolveException]. +class SRVRecordFormatException extends DNSolveException { + const SRVRecordFormatException(this.message); + + final String message; +} diff --git a/lib/hj_utils/store_keys.dart b/lib/hj_utils/store_keys.dart index 7bcee40..c7453aa 100644 --- a/lib/hj_utils/store_keys.dart +++ b/lib/hj_utils/store_keys.dart @@ -35,6 +35,9 @@ class StoreKeys { static const String DEVICE_ID = 'device_id'; // ===== 网络 / 资源 ===== + /// 本地 ping 通的线路(DNS 解析成功后写入) + static const String DETECT_LINE = 'detectLineKey'; + /// 本地广告列表(按 DEBUG 标志区分) static const String ADS_LIST = '_key_ads_list${Config.isDebug}';