update(ToBypassTrackers): 异步查询DNS

This commit is contained in:
wumode
2025-05-14 20:28:10 +08:00
parent cfb2295c6c
commit 501932649e
5 changed files with 124 additions and 137 deletions

View File

@@ -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"
}
}
}

View File

@@ -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:

View File

@@ -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}')
: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

View File

@@ -1 +1,2 @@
dnspython~=2.7.0
dnspython~=2.7.0
aioquic~=1.2.0

View File

@@ -1 +1 @@
eyJoZGRvbGJ5LmNvbSI6IFsidC5oZGRvbGJ5LmNvbSJdLCAidGp1cHQub3JnIjogWyJ0cmFja2VyLXB1YmxpYy50anVwdC5vcmciXSwgIm5pY2VwdC5uZXQiOiBbInd3dy5uaWNlcHQubmV0Il0sICJyb3VzaS56aXAiOiBbImhpdHB0LmNvbSJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgInB0dGltZS5vcmciOiBbInd3dy5wdHRpbWUub3JnIl0sICJtLXRlYW0uY2MiOiBbInRyYWNrZXIubS10ZWFtLmNjIiwgInRyYWNrZXIubS10ZWFtLmlvIl0sICI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAicWluZ3dhcHQuY29tIjogWyJ0cmFja2VyLnFpbmd3YS5wcm8iLCAidHJhY2tlci5xaW5nd2FwdC5jb20iXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgInB0bGdzLm9yZyI6IFsicHRsLmdzIiwgInJlbGF5MDEucHRsLmdzIl0sICJtb25pa2FkZXNpZ24udWsiOiBbInRyYWNrZXIubW9uaWthZGVzaWduLnVrIiwgImRhaWtpcmFpLm1vbmlrYWRlc2lnbi51ayIsICJhbmltZS1uby1pbmRleC5jb20iXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJodWRidC5odXN0LmVkdS5jbiI6IFsiaHVkYnQuaHVzdC5lZHUuY24iXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImJ0c2Nob29sLmNsdWIiOiBbInB0LmJ0c2Nob29sLmNsdWIiXSwgImhkYXJlYS5jbHViIjogWyJ0cmFja2VyLmhkYXJlYS5jbHViIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCJdLCAiem1wdC5jYyI6IFsiem1wdC5jYyJdLCAiY2FycHQubmV0IjogWyJ0cmFja2VyLmNhcnB0Lm5ldCJdLCAiaWNjMjAyMi5jb20iOiBbInRyYWNrZXIuaWNjMjAyMi54eXoiXSwgImtlZXBmcmRzLmNvbSI6IFsidHJhY2tlci5rZWVwZnJkcy5jb20iXSwgInB0em9uZS54eXoiOiBbInB0em9uZS54eXoiXSwgImNzcHQudG9wIjogWyJjc3B0LnRvcCJdLCAiY3JhYnB0LnZpcCI6IFsiY3JhYnB0LnZpcCJdLCAib2twdC5uZXQiOiBbInd3dy5va3B0Lm5ldCJdLCAiZ2FtZWdhbWVwdC5jb20iOiBbInd3dy5nYW1lZ2FtZXB0LmNvbSJdfQ==
eyJoZGRvbGJ5LmNvbSI6IFsidC5oZGRvbGJ5LmNvbSJdLCAidGp1cHQub3JnIjogWyJ0cmFja2VyLXB1YmxpYy50anVwdC5vcmciXSwgIm5pY2VwdC5uZXQiOiBbInd3dy5uaWNlcHQubmV0Il0sICJyb3VzaS56aXAiOiBbImhpdHB0LmNvbSJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgInB0dGltZS5vcmciOiBbInd3dy5wdHRpbWUub3JnIl0sICJtLXRlYW0uY2MiOiBbInRyYWNrZXIubS10ZWFtLmNjIiwgInRyYWNrZXIubS10ZWFtLmlvIl0sICI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAicWluZ3dhcHQuY29tIjogWyJ0cmFja2VyLnFpbmd3YS5wcm8iLCAidHJhY2tlci5xaW5nd2FwdC5jb20iXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgInB0bGdzLm9yZyI6IFsicHRsLmdzIiwgInJlbGF5MDEucHRsLmdzIl0sICJtb25pa2FkZXNpZ24udWsiOiBbInRyYWNrZXIubW9uaWthZGVzaWduLnVrIiwgImRhaWtpcmFpLm1vbmlrYWRlc2lnbi51ayIsICJhbmltZS1uby1pbmRleC5jb20iXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJodWRidC5odXN0LmVkdS5jbiI6IFsiaHVkYnQuaHVzdC5lZHUuY24iXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImJ0c2Nob29sLmNsdWIiOiBbInB0LmJ0c2Nob29sLmNsdWIiXSwgImhkYXJlYS5jbHViIjogWyJ0cmFja2VyLmhkYXJlYS5jbHViIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCIsICJvbi5zcHJpbmdzdW5kYXkubmV0Il0sICJ6bXB0LmNjIjogWyJ6bXB0LmNjIl0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJpY2MyMDIyLmNvbSI6IFsidHJhY2tlci5pY2MyMDIyLnh5eiJdLCAia2VlcGZyZHMuY29tIjogWyJ0cmFja2VyLmtlZXBmcmRzLmNvbSJdLCAicHR6b25lLnh5eiI6IFsicHR6b25lLnh5eiJdLCAiY3NwdC50b3AiOiBbInRyYWNrZXIuY3NwdC50b3AiLCAidHJhY2tlci5jc3B0LmNjIiwgInRyYWNrZXIuY3NwdC5kYXRlIl0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJva3B0Lm5ldCI6IFsid3d3Lm9rcHQubmV0Il0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJhdWRpZW5jZXMubWUiOiBbInQuYXVkaWVuY2VzLm1lIiwgInRyYWNrZXIuY2luZWZpbGVzLmluZm8iXSwgInhpbmd5dW5nZS50b3AiOiBbInRyYWNrZXIueGluZ3l1bmdlLnRvcCIsICJ0cmFja2VyLnhpbmd5dW5nZS5zYnMiXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJkaXNjZmFuLm5ldCI6IFsiZGlzY2Zhbi54eXoiXX0=