diff --git a/package.v2.json b/package.v2.json index 08d008b..5e04993 100644 --- a/package.v2.json +++ b/package.v2.json @@ -404,7 +404,7 @@ "name": "绕过Trackers", "description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash", "labels": "工具", - "version": "1.3", + "version": "1.4", "icon": "Clash_A.png", "author": "wumode", "level": 2, @@ -412,7 +412,8 @@ "v1.0": "支持自定义Trackers", "v1.1": "更新列表后发送通知", "v1.2": "修复Trackers加载错误", - "v1.3": "新增一些Trackers" + "v1.3": "新增一些Trackers", + "v1.4": "异步查询DNS" } } } diff --git a/plugins.v2/tobypasstrackers/__init__.py b/plugins.v2/tobypasstrackers/__init__.py index a5ca47a..16d8bec 100644 --- a/plugins.v2/tobypasstrackers/__init__.py +++ b/plugins.v2/tobypasstrackers/__init__.py @@ -4,6 +4,7 @@ import ipaddress import socket import base64 import json +import asyncio from apscheduler.schedulers.background import BackgroundScheduler from fastapi import Response @@ -30,7 +31,7 @@ class ToBypassTrackers(_PluginBase): # 插件图标 plugin_icon = "Clash_A.png" # 插件版本 - plugin_version = "1.3" + plugin_version = "1.4" # 插件作者 plugin_author = "wumode" # 作者主页 @@ -47,7 +48,6 @@ class ToBypassTrackers(_PluginBase): site_chain: SiteChain = None siteoper: SiteOper = None - # 事件管理器 event: EventManager = None # 定时器 @@ -161,7 +161,7 @@ class ToBypassTrackers(_PluginBase): def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: site_options = ([{"title": site.name, "value": site.id} for site in self.siteoper.list_order_by_pri()] - ) + ) return [ { 'component': 'VForm', @@ -429,9 +429,8 @@ class ToBypassTrackers(_PluginBase): 'type': 'info', 'variant': 'tonal', 'text': '【订阅URL】' - '「IPv4 Api」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey=moviepilot&protocol=4; ' - '「IPv6 Api」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey=moviepilot&protocol=6; ' - '其中moviepilot修改为实际配置中的API_TOKEN的值。' + f'「IPv4 API」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey={settings.API_TOKEN}&protocol=4; ' + f'「IPv6 API」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey={settings.API_TOKEN}&protocol=6' } } ] @@ -574,7 +573,38 @@ class ToBypassTrackers(_PluginBase): remaining_ranges = list(net_b.address_exclude(net_a)) return [str(sub_net) for sub_net in remaining_ranges] - # replacing = data.get('replace') + + async def resolve_and_check(domain_, results_, failed_msg_, dns_type_, ip_list_): + try: + addresses = await query_helper.query_dns(domain_, dns_type_) + if addresses is None: + failed_msg_.append(f"【{domain_name_map.get(domain_, domain_)}】 {domain_}: {dns_type_} 记录查询失败") + results_[domain_name_map.get(domain_, domain_)] = False + return + + for address in addresses: + has_flag = any(__is_ip_in_subnet(address, subnet) for subnet in ip_list_) + if not has_flag: + if dns_type_ == "AAAA": + ip_list_.append(address) + else: + ip_list_.append(address) + logger.info(f"Resolving【{domain_name_map.get(domain_, domain_)}】{address} ({domain_})") + except Exception as e: + logger.exception(f"处理 {domain_} 出错: {e}") + results_[domain_name_map.get(domain_, domain_)] = False + + async def resolve_all(domains_, ipv6_list_, ip_list_): + tasks = [ + resolve_and_check(domain_, results_v6, failed_msg, "AAAA", ipv6_list_) + for domain_ in domains_ + ] + tasks.extend([resolve_and_check(domain_, results, failed_msg, "A", ip_list_) + for domain_ in domains_]) + await asyncio.gather(*tasks) + + query_helper = DnsHelper(self._dns_input) + logger.info(f"开始通过 {query_helper.method_name} 解析DNS") chnroute6_lists_url = "https://ispip.clang.cn/all_cn_ipv6.txt" chnroute_lists_url = "https://ispip.clang.cn/all_cn.txt" ipv6_list = [] @@ -584,6 +614,7 @@ class ToBypassTrackers(_PluginBase): failed_msg = [] results = {} unsupported_msg = [] + results_v6 = {} if self._china_ipv6_route: # Load Chnroute6 Lists res = RequestUtils().get_res(url=chnroute6_lists_url) @@ -598,7 +629,8 @@ class ToBypassTrackers(_PluginBase): chnroute_lists = res.text[:-1].split('\n') for ipr in chnroute_lists: ip_list.append(ipr) - do_sites = {site.domain: site.name for site in self.siteoper.list_order_by_pri() if site.id in self._bypassed_sites} + do_sites = {site.domain: site.name for site in self.siteoper.list_order_by_pri() if + site.id in self._bypassed_sites} domain_name_map = {} for site in do_sites: site_domains = self.trackers.get(site) @@ -623,44 +655,17 @@ class ToBypassTrackers(_PluginBase): ipv6_list.append(ipaddress.ip_network(f"{custom_tracker}/128", strict=False).compressed) except socket.error: domains.append(custom_tracker) - for domain in domains: - if self._bypass_ipv6: - ipv6_addresses = DnsHelper.query_domain(domain, self._dns_input, "AAAA") - if ipv6_addresses is None: - logger.warn(f"{domain} AAAA 记录查询失败") - failed_msg.append(f"【{domain_name_map.get(domain, domain)}】 {domain}: AAAA记录查询失败") - results[{domain_name_map.get(domain, domain)}] = False - continue - for address in ipv6_addresses: - has_flag = False - for subnet in ipv6_list: - if __is_ip_in_subnet(address, subnet): - has_flag = True - break - if not has_flag: - ipv6_list.append(ipaddress.ip_network(f"{address}/128", strict=False).compressed) - logger.info(f"【{domain_name_map.get(domain, domain)}】{address} ({domain}) 已被添加") - if self._bypass_ipv4: - ip_addresses = DnsHelper.query_domain(domain, self._dns_input, 'A') - if ip_addresses is None: - logger.warn(f"{domain} A 记录查询失败") - failed_msg.append(f"【{domain_name_map.get(domain, '')}】 {domain}: A记录查询失败") - results[{domain_name_map.get(domain, domain)}] = False - continue - for address in ip_addresses: - has_flag = False - for subnet in ip_list: - if __is_ip_in_subnet(address, subnet): - has_flag = True - break - if not has_flag: - ip_list.append(f"{address}/32") - logger.info(f"【{domain_name_map.get(domain, domain)}】{address} ({domain}) 已被添加") + v6_ips = [] + v4_ips = [] + asyncio.run(resolve_all(domains, v6_ips, v4_ips)) + ipv6_list.extend([ipaddress.ip_network(f"{ad}/128", strict=False).compressed for ad in v6_ips]) + ip_list.extend([f"{ad}/32" for ad in v4_ips]) for result in results: if results[result]: success_msg.append(f"【{result}】 Trackers已被添加") exempted_ip = [] exempted_ipv6 = [] + exempted_domains = [] for exempted_domain in self._exempted_domains.split('\n'): if exempted_domain: try: @@ -673,12 +678,9 @@ class ToBypassTrackers(_PluginBase): if self._bypass_ipv6: exempted_ipv6.append(f"{exempted_domain}") except socket.error: - ipv6_addresses = DnsHelper.query_domain(exempted_domain, self._dns_input, 'AAAA') - if ipv6_addresses: - exempted_ipv6.extend(ipv6_addresses) - ipv4_addresses = DnsHelper.query_domain(exempted_domain, self._dns_input, 'A') - if ipv4_addresses: - exempted_ip.extend(ipv4_addresses) + exempted_domains.append(exempted_domain) + + asyncio.run(resolve_all(exempted_domains, exempted_ip, exempted_ipv6)) for ip in exempted_ip: index = __search_ip(ip, ip_list) if index == -1: diff --git a/plugins.v2/tobypasstrackers/dns_helper.py b/plugins.v2/tobypasstrackers/dns_helper.py index 000b682..d25a3db 100644 --- a/plugins.v2/tobypasstrackers/dns_helper.py +++ b/plugins.v2/tobypasstrackers/dns_helper.py @@ -1,105 +1,88 @@ -import socket import re -from typing import Optional +from typing import Optional, List, Callable +import aioquic +import dns.asyncresolver import dns.resolver from app.log import logger -from app.utils.http import RequestUtils class DnsHelper: + def __init__(self, dns_server: str): + self.method_name = "Local" + self.doh_url = "https://dns.alidns.com/dns-query" + self.__resolver = dns.asyncresolver.Resolver() + self.__dns_query_method = self.__query_method(dns_server) - @staticmethod - def query_dns_udp(domain: str, dns_server: str, port: int =53, dns_type: str ='A') -> list[str]: - resolver = dns.resolver.Resolver() - resolver.nameservers = [dns_server] - resolver.port = port - - try: - ip_answer = resolver.resolve(domain, dns_type) - ip_addresses = [record.address for record in ip_answer] - except dns.resolver.NoAnswer: - ip_addresses = [] - except: - return None - return ip_addresses - - @staticmethod - def query_doh(domain: str, doh_url: str, dns_type: str = 'A') -> Optional[list]: - params = { - 'name': domain, - 'type': dns_type, - } - headers = { - 'Accept': 'application/dns-json', - } - response = RequestUtils().get_res(url=doh_url, headers=headers, params=params) - if not response.status_code == 200: - return None - data = response.json() - return [answer['data'] for answer in data.get('Answer', []) if - answer.get('type') == 28 or answer.get('type') == 1] - - @staticmethod - def parse_dns_input(dns_input: str): + def __query_method(self, dns_input: str) -> Callable: if not dns_input: - return 'local', dns_input - # Check if it's a DoH URL (starts with https://) + return self.query_dns_local if dns_input.startswith('https://'): - return 'doh', dns_input + self.doh_url = dns_input + self.method_name = dns_input + return self.query_dns_doh + udp_match = re.match(r"^(?:udp://)?(\[?.+?]?)(?::(\d+))?$", dns_input) + if udp_match: + try: + self.__resolver.nameservers = [udp_match.group(1).strip('[]')] + if udp_match.group(2): + self.__resolver.port = int(udp_match.group(2)) + self.method_name = f"udp://{self.__resolver.nameservers[0]}:{self.__resolver.port}" + except Exception as e: + logger.warn(f'{e}, using default resolver') + return self.query_dns_local + return self.query_dns_udp + logger.warn(f'Unknown method {dns_input}, using default resolver') + return self.query_dns_local - # Check if it's a UDP DNS with hostname (e.g., udp://unfiltered.adguard-dns.com) - if dns_input.startswith('udp://'): - hostname = dns_input[len('udp://'):] - return 'udp', hostname, 53 + async def query_dns(self, domain: str, dns_type: str = "A") -> Optional[List[str]]: + answers = await self.__dns_query_method(domain, dns_type) + return answers - # Check if it's an IP address with port (e.g., 94.140.14.140:53 or [2a10:50c0::1:ff]:53) - port_match = re.match(r'^(\[?.+?\]?):(\d+)$', dns_input) - if port_match: - dns_server = port_match.group(1).strip('[]') - port = int(port_match.group(2)) - return 'udp', dns_server, port - - # Default to regular DNS over UDP with default port 53 - return 'udp', dns_input, 53 - - @staticmethod - def query_dns_local(domain_name: str, dns_type: str = 'A') -> Optional[list]: + async def query_dns_local(self, domain: str, dns_type: str = "A") -> Optional[List[str]]: try: - # Get address info for both IPv4 and IPv6 - addr_info = socket.getaddrinfo(domain_name, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + answer = await self.__resolver.resolve(domain, dns_type) + return [record.address for record in answer if hasattr(record, "address")] + except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): + return [] + except Exception as e: + # logger.error(f"本地DNS查询错误: {e} {domain}") + return None - ipv4_addresses = [] - ipv6_addresses = [] + async def query_dns_doh(self, domain: str, dns_type: str = 'A') -> Optional[List[str]]: + """ + 使用 DNS-over-HTTPS (DoH) 异步解析域名。 - # Iterate over the address info - for info in addr_info: - ip_address = info[4][0] + :param domain: 要解析的域名 + :param dns_type: DNS 记录类型,例如 'A', 'AAAA' + :return: IP 地址列表,或 None + """ - # Check if the IP address is IPv4 or IPv6 - if '.' in ip_address: - ipv4_addresses.append(ip_address) - elif ':' in ip_address: - ipv6_addresses.append(ip_address) - if dns_type == 'A': - return ipv4_addresses - elif dns_type == 'AAAA': - return ipv6_addresses + try: + query = dns.message.make_query(domain, dns_type) + response = await dns.asyncquery.https(query, self.doh_url) + return [ + item.address for rrset in response.answer for item in rrset.items + if hasattr(item, "address") + ] + except Exception as e: + return None - except socket.gaierror as e: - logger.error(f"本地DNS查询错误: {e} {domain_name}") + async def query_dns_udp(self, domain: str, dns_type: str = 'A') -> Optional[List[str]]: + """ + 使用 UDP 异步方式解析域名 - @staticmethod - def query_domain(domain: str, dns_input: str, dns_type='A') -> Optional[list]: - method, *args = DnsHelper.parse_dns_input(dns_input) - if method == 'local': - return DnsHelper.query_dns_local(domain, dns_type) - elif method == 'udp': - dns_server, port = args - return DnsHelper.query_dns_udp(domain, dns_server, port, dns_type) - elif method == 'doh': - doh_url = args[0] - return DnsHelper.query_doh(domain, doh_url, dns_type) - else: - logger.error(f'Unknown method {method}') \ No newline at end of file + :param domain: 域名 + :param port: DNS服务器端口(默认53) + :param dns_type: 记录类型,如 A、AAAA + :return: IP地址列表 或 None + """ + + try: + answer = await self.__resolver.resolve(domain, dns_type) + return [record.address for record in answer] + except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): + return [] + except Exception: + return None diff --git a/plugins.v2/tobypasstrackers/requirements.txt b/plugins.v2/tobypasstrackers/requirements.txt index 1b7439e..88ef819 100644 --- a/plugins.v2/tobypasstrackers/requirements.txt +++ b/plugins.v2/tobypasstrackers/requirements.txt @@ -1 +1,2 @@ -dnspython~=2.7.0 \ No newline at end of file +dnspython~=2.7.0 +aioquic~=1.2.0 diff --git a/plugins.v2/tobypasstrackers/sites/trackers b/plugins.v2/tobypasstrackers/sites/trackers index 14eee84..c48216e 100644 --- a/plugins.v2/tobypasstrackers/sites/trackers +++ b/plugins.v2/tobypasstrackers/sites/trackers @@ -1 +1 @@ -eyJoZGRvbGJ5LmNvbSI6IFsidC5oZGRvbGJ5LmNvbSJdLCAidGp1cHQub3JnIjogWyJ0cmFja2VyLXB1YmxpYy50anVwdC5vcmciXSwgIm5pY2VwdC5uZXQiOiBbInd3dy5uaWNlcHQubmV0Il0sICJyb3VzaS56aXAiOiBbImhpdHB0LmNvbSJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgInB0dGltZS5vcmciOiBbInd3dy5wdHRpbWUub3JnIl0sICJtLXRlYW0uY2MiOiBbInRyYWNrZXIubS10ZWFtLmNjIiwgInRyYWNrZXIubS10ZWFtLmlvIl0sICI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAicWluZ3dhcHQuY29tIjogWyJ0cmFja2VyLnFpbmd3YS5wcm8iLCAidHJhY2tlci5xaW5nd2FwdC5jb20iXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgInB0bGdzLm9yZyI6IFsicHRsLmdzIiwgInJlbGF5MDEucHRsLmdzIl0sICJtb25pa2FkZXNpZ24udWsiOiBbInRyYWNrZXIubW9uaWthZGVzaWduLnVrIiwgImRhaWtpcmFpLm1vbmlrYWRlc2lnbi51ayIsICJhbmltZS1uby1pbmRleC5jb20iXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJodWRidC5odXN0LmVkdS5jbiI6IFsiaHVkYnQuaHVzdC5lZHUuY24iXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImJ0c2Nob29sLmNsdWIiOiBbInB0LmJ0c2Nob29sLmNsdWIiXSwgImhkYXJlYS5jbHViIjogWyJ0cmFja2VyLmhkYXJlYS5jbHViIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCJdLCAiem1wdC5jYyI6IFsiem1wdC5jYyJdLCAiY2FycHQubmV0IjogWyJ0cmFja2VyLmNhcnB0Lm5ldCJdLCAiaWNjMjAyMi5jb20iOiBbInRyYWNrZXIuaWNjMjAyMi54eXoiXSwgImtlZXBmcmRzLmNvbSI6IFsidHJhY2tlci5rZWVwZnJkcy5jb20iXSwgInB0em9uZS54eXoiOiBbInB0em9uZS54eXoiXSwgImNzcHQudG9wIjogWyJjc3B0LnRvcCJdLCAiY3JhYnB0LnZpcCI6IFsiY3JhYnB0LnZpcCJdLCAib2twdC5uZXQiOiBbInd3dy5va3B0Lm5ldCJdLCAiZ2FtZWdhbWVwdC5jb20iOiBbInd3dy5nYW1lZ2FtZXB0LmNvbSJdfQ== \ No newline at end of file +eyJoZGRvbGJ5LmNvbSI6IFsidC5oZGRvbGJ5LmNvbSJdLCAidGp1cHQub3JnIjogWyJ0cmFja2VyLXB1YmxpYy50anVwdC5vcmciXSwgIm5pY2VwdC5uZXQiOiBbInd3dy5uaWNlcHQubmV0Il0sICJyb3VzaS56aXAiOiBbImhpdHB0LmNvbSJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgInB0dGltZS5vcmciOiBbInd3dy5wdHRpbWUub3JnIl0sICJtLXRlYW0uY2MiOiBbInRyYWNrZXIubS10ZWFtLmNjIiwgInRyYWNrZXIubS10ZWFtLmlvIl0sICI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAicWluZ3dhcHQuY29tIjogWyJ0cmFja2VyLnFpbmd3YS5wcm8iLCAidHJhY2tlci5xaW5nd2FwdC5jb20iXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgInB0bGdzLm9yZyI6IFsicHRsLmdzIiwgInJlbGF5MDEucHRsLmdzIl0sICJtb25pa2FkZXNpZ24udWsiOiBbInRyYWNrZXIubW9uaWthZGVzaWduLnVrIiwgImRhaWtpcmFpLm1vbmlrYWRlc2lnbi51ayIsICJhbmltZS1uby1pbmRleC5jb20iXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJodWRidC5odXN0LmVkdS5jbiI6IFsiaHVkYnQuaHVzdC5lZHUuY24iXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImJ0c2Nob29sLmNsdWIiOiBbInB0LmJ0c2Nob29sLmNsdWIiXSwgImhkYXJlYS5jbHViIjogWyJ0cmFja2VyLmhkYXJlYS5jbHViIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCIsICJvbi5zcHJpbmdzdW5kYXkubmV0Il0sICJ6bXB0LmNjIjogWyJ6bXB0LmNjIl0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJpY2MyMDIyLmNvbSI6IFsidHJhY2tlci5pY2MyMDIyLnh5eiJdLCAia2VlcGZyZHMuY29tIjogWyJ0cmFja2VyLmtlZXBmcmRzLmNvbSJdLCAicHR6b25lLnh5eiI6IFsicHR6b25lLnh5eiJdLCAiY3NwdC50b3AiOiBbInRyYWNrZXIuY3NwdC50b3AiLCAidHJhY2tlci5jc3B0LmNjIiwgInRyYWNrZXIuY3NwdC5kYXRlIl0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJva3B0Lm5ldCI6IFsid3d3Lm9rcHQubmV0Il0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJhdWRpZW5jZXMubWUiOiBbInQuYXVkaWVuY2VzLm1lIiwgInRyYWNrZXIuY2luZWZpbGVzLmluZm8iXSwgInhpbmd5dW5nZS50b3AiOiBbInRyYWNrZXIueGluZ3l1bmdlLnRvcCIsICJ0cmFja2VyLnhpbmd5dW5nZS5zYnMiXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJkaXNjZmFuLm5ldCI6IFsiZGlzY2Zhbi54eXoiXX0= \ No newline at end of file